Comment lire les gros fichiers avec PHP (sans tuer votre serveur)

Il fut un temps où la seule façon de s’authentifier auprès d’une application était de fournir ses informations d’identification (généralement un nom d’utilisateur ou une adresse e-mail et un mot de passe) et une session était ensuite utilisée pour maintenir l’état de l’utilisateur jusqu’à ce qu’il se déconnecte. Un peu plus tard, nous avons commencé

Comment lire les gros fichiers avec PHP (sans tuer votre serveur)
Réponse rapide

Un générateur lit un fichier de n'importe quelle taille avec environ 2 Mo de mémoire, là où file() en consomme 229 Mo sur un fichier de 73 Mo. Sur PHP 8.5, le rapport mesuré est de 114 pour 1, sans surcoût en temps. Pour transformer un fichier sans l'examiner, les filtres de flux évitent de tout charger.

Un script PHP qui traite un fichier de quelques mégaoctets fonctionne. Le même script sur un fichier de 70 Mo s’arrête sur Allowed memory size exhausted. La cause est presque toujours la même : le fichier entier est chargé en mémoire alors qu’il suffisait de le parcourir. Cet article mesure les différentes stratégies de lecture sur un fichier réel et montre laquelle choisir selon le traitement.

Le protocole de mesure

Fichier de test : un CSV de 73,1 Mo, 2 000 000 de lignes, quatre colonnes.

generer.php
<?php

$h = fopen('gros.csv', 'wb');
$tampon = '';

for ($i = 1; $i <= 2_000_000; $i++) {
    $tampon .= $i . ',client-' . str_pad((string) ($i % 999999), 6, '0', STR_PAD_LEFT)
        . ',' . (($i % 977) + 0.5)
        . ',2026-' . str_pad((string) (($i % 12) + 1), 2, '0', STR_PAD_LEFT) . "-15\n";

    if (($i % 50_000) === 0) {
        fwrite($h, $tampon);
        $tampon = '';
    }
}

fwrite($h, $tampon);
fclose($h);

Chaque stratégie tourne dans un processus séparé, avec memory_limit à 512 Mo, dans un conteneur limité à 1 cœur et 1 Go. C’est indispensable : memory_get_peak_usage() retient le maximum atteint depuis le début du script, donc mesurer plusieurs stratégies dans le même processus donne des chiffres faux, la première mesure contamine toutes les suivantes.

une-strategie.php
<?php

declare(strict_types=1);

$t0 = hrtime(true);
$resultat = /* … la stratégie à mesurer … */;

printf(
    "%7.0f ms  pic %7.1f Mo  %s\n",
    (hrtime(true) - $t0) / 1e6,
    memory_get_peak_usage(true) / 1_048_576,
    $resultat,
);

Une remarque de méthode qui nous a coûté une heure : les premières mesures étaient prises sur un dossier monté depuis macOS dans Docker. Le nombre de lignes lues diminuait à chaque exécution, 1 990 083, puis 1 989 958, puis 1 989 132, alors que la taille du fichier ne bougeait pas. Le montage tronquait des lectures séquentielles longues. Déplacer le fichier dans un volume Docker a rendu les mesures reproductibles, avec exactement 2 000 000 de lignes à chaque passe. Une mesure qui varie sans raison est une mesure fausse, pas une mesure bruitée.

Parcourir un fichier ligne par ligne

Quatre façons de compter les lignes, même fichier, même machine.

php
// 1. file() : tout le fichier dans un tableau
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);

// 2. fgets accumulé dans un tableau
$lignes = [];
$h = fopen('gros.csv', 'rb');
while (($ligne = fgets($h)) !== false) {
    $lignes[] = rtrim($ligne, "\r\n");
}
fclose($h);
$n = count($lignes);

// 3. Générateur
function lireLignes(string $chemin): Generator
{
    $h = fopen($chemin, 'rb');
    try {
        while (($ligne = fgets($h)) !== false) {
            yield rtrim($ligne, "\r\n");
        }
    } finally {
        fclose($h);
    }
}

$n = 0;
foreach (lireLignes('gros.csv') as $ligne) {
    $n++;
}

// 4. SplFileObject
$f = new SplFileObject('gros.csv', 'rb');
$f->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::READ_AHEAD);
$n = 0;
foreach ($f as $ligne) {
    if ($ligne !== false && $ligne !== '') {
        $n++;
    }
}
Stratégie Temps médian Pic mémoire
file() 612 ms 229,1 Mo
fgets dans un tableau 492 ms 156,0 Mo
Générateur 652 ms 2,0 Mo
SplFileObject 594 ms 2,0 Mo

Le rapport est de 114 pour un entre file() et le générateur. Les temps, eux, se tiennent dans un mouchoir : entre 490 et 660 ms, avec une dispersion d’une passe à l’autre qui dépasse l’écart entre les stratégies. Autrement dit, ne pas charger le fichier en mémoire ne coûte rien en vitesse et divise la consommation par cent.

Le chiffre de 229 Mo pour un fichier de 73 Mo surprend souvent. Un tableau PHP de deux millions de chaînes courtes ne stocke pas seulement les caractères : chaque entrée porte une structure de tableau et chaque chaîne un en-tête. Le surcoût dépasse largement la donnée elle-même.

Ce que ça change concrètement

Avec memory_limit = 128M, valeur courante en hébergement mutualisé :

code
file()        -> Fatal error: Allowed memory size of 134217728 bytes exhausted
                 (tried to allocate 16777224 bytes)
générateur    -> 659 ms, pic 2,0 Mo, 2 000 000 lignes

À 192 Mo, file() échoue encore. Il lui faut 256 Mo pour passer. Le générateur fonctionne avec 2 Mo, et fonctionnerait pareillement sur un fichier de 700 Mo : sa consommation ne dépend pas de la taille du fichier, seulement de la plus longue ligne.

Traiter les colonnes d’un CSV

Même comparaison, cette fois en additionnant la troisième colonne.

php
// A. Tout charger puis découper
$texte = file_get_contents('gros.csv');
$somme = 0.0;
foreach (explode("\n", $texte) as $ligne) {
    if ($ligne === '') { continue; }
    $colonnes = explode(',', $ligne);
    $somme += (float) ($colonnes[2] ?? 0);
}

// B. Lire ligne à ligne et découper
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($ligne = fgets($h)) !== false) {
    $colonnes = explode(',', $ligne);
    $somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);

// C. fgetcsv, qui gère les guillemets et les séparateurs échappés
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
    $somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);
Stratégie Temps médian Pic mémoire
file_get_contents + explode 583 ms 229,1 Mo
fgets + explode 704 ms 2,0 Mo
fgetcsv 4 729 ms 2,0 Mo

Les trois donnent la même somme, au centime près. Mais fgetcsv est sept fois plus lent que explode. Cet écart n’est pas un défaut : fgetcsv applique les règles du format CSV, gère les guillemets, les séparateurs à l’intérieur des champs et les retours à la ligne encadrés. explode(',') ne fait rien de tout ça et casse dès qu’un champ contient une virgule.

Le choix se fait donc sur la nature du fichier, pas sur la performance : fgetcsv pour un CSV venu de l’extérieur, explode uniquement pour un format dont vous garantissez qu’il n’a ni guillemets ni séparateurs échappés.

Depuis PHP 8.4, les trois derniers paramètres de fgetcsv() doivent être passés explicitement si l’on veut éviter un avis de dépréciation sur le paramètre d’échappement.

Transformer un fichier sans le lire

Autre situation : compresser un fichier sans jamais avoir besoin d’examiner son contenu. Charger la totalité pour la passer à gzencode() fonctionne, mais les filtres de flux font le travail par blocs.

php
// En mémoire
$compresse = gzencode(file_get_contents('gros.csv'), 6);
file_put_contents('gros.csv.gz', $compresse);

// En flux
$entree = fopen('gros.csv', 'rb');
$sortie = fopen('gros.csv.gz', 'wb');

stream_filter_append($sortie, 'zlib.deflate', STREAM_FILTER_WRITE, [
    'level'  => 6,
    'window' => 31,   // 31 = en-tête gzip ; 15 produirait du zlib brut
]);

stream_copy_to_stream($entree, $sortie);

fclose($entree);
fclose($sortie);
Stratégie Temps médian Pic mémoire Sortie
gzencode en mémoire 1 692 ms 149,3 Mo 13,8 Mo
Filtre de flux 1 928 ms 91,1 Mo 13,8 Mo

Le gain en mémoire est réel mais moins spectaculaire que sur la lecture : stream_copy_to_stream utilise un tampon interne conséquent. Les deux produisent un fichier de taille identique. Le paramètre window à 31 est ce qui distingue un vrai fichier gzip d’un flux zlib brut que gunzip refusera d’ouvrir.

Traiter par lots

Le cas le plus fréquent en pratique : parcourir un gros fichier et envoyer les lignes par paquets vers une base ou une file. Le générateur se combine bien avec un accumulateur.

src/lots.php
<?php

declare(strict_types=1);

/**
 * @param  iterable<mixed> $source
 * @return Generator<int, array>
 */
function parLots(iterable $source, int $taille = 1000): Generator
{
    $lot = [];

    foreach ($source as $element) {
        $lot[] = $element;

        if (count($lot) === $taille) {
            yield $lot;
            $lot = [];
        }
    }

    if ($lot !== []) {
        yield $lot;   // le reste
    }
}
php
$pdo->beginTransaction();
$insertion = $pdo->prepare('INSERT INTO clients (reference, nom, montant) VALUES (?, ?, ?)');

foreach (parLots(lireLignes('gros.csv'), 1000) as $numero => $lot) {
    foreach ($lot as $ligne) {
        $colonnes = str_getcsv($ligne, ',', '"', '\\');
        $insertion->execute([$colonnes[0], $colonnes[1], $colonnes[2]]);
    }

    if ($numero % 50 === 0) {
        $pdo->commit();
        $pdo->beginTransaction();
    }
}

$pdo->commit();

Le yield final après la boucle est la ligne qu’on oublie : sans lui, les dernières lignes du fichier, celles qui ne complètent pas un lot entier, sont perdues en silence.

Regrouper les insertions dans des transactions change tout sur la durée. Une transaction par ligne force une écriture disque à chaque fois, un commit toutes les 50 000 lignes en fait une par lot.

Les limites de temps

La mémoire n’est pas le seul mur. En ligne de commande, max_execution_time vaut 0 : le script tourne sans limite. Derrière un serveur web, il est généralement à 30 secondes, et le serveur frontal a son propre délai.

php
// En CLI, vérifier avant de compter dessus
if (PHP_SAPI !== 'cli') {
    throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}

Un traitement de fichier volumineux n’a pas sa place dans une requête HTTP. La requête enregistre le travail à faire, une commande en ligne de commande ou une file s’en charge.

À retenir

  • Un générateur lit un fichier de n’importe quelle taille avec 2 Mo de mémoire. file() et file_get_contents() chargent tout.
  • Le surcoût en temps est nul, la mesure le montre : le choix n’a pas de contrepartie.
  • fgetcsv est sept fois plus lent qu’explode, et c’est le prix de la correction. À payer sur un fichier dont vous ne maîtrisez pas la production.
  • Les filtres de flux compressent sans tout charger.
  • Mesurer chaque stratégie dans un processus séparé, sinon memory_get_peak_usage() ment.

La maîtrise de la mémoire compte encore davantage dans un processus qui ne s’arrête jamais : un serveur WebSocket en PHP mesure ce coût connexion par connexion. Pour traiter des colonnes de dates, voir obtenir et formater la date et l’heure.

Voir aussi la connexion à une base de données en PHP pour la partie insertion, et le hub Développement web.

Erreurs fréquentes

file() et file_get_contents() chargent tout 229 Mo de mémoire pour un fichier de 73 Mo. Avec memory_limit à 128M, c'est Allowed memory size of 134217728 bytes exhausted.
Mesurer plusieurs stratégies dans un même processus memory_get_peak_usage() retient le maximum atteint depuis le début du script : la première mesure fausse toutes les suivantes. Un processus par stratégie.
fgetcsv confondu avec explode fgetcsv est sept fois plus lent, et c'est le prix de la correction : il gère les guillemets et les séparateurs à l'intérieur des champs. explode(',') casse dès qu'un champ contient une virgule.
Le dernier lot oublié Sans un yield après la boucle, les lignes qui ne complètent pas un lot entier disparaissent en silence.
Filtre zlib sans window à 31 Produit un flux zlib brut que gunzip refuse d'ouvrir. Il faut 'window' => 31 pour un vrai fichier gzip.
Traitement lourd dans une requête HTTP max_execution_time vaut 0 en ligne de commande mais 30 secondes derrière un serveur web.
Mesures sur un montage bind macOS Les lectures séquentielles longues étaient tronquées : le nombre de lignes baissait à chaque passe alors que la taille du fichier ne bougeait pas. Un volume Docker a rendu les mesures reproductibles.

CSVFichiersGénérateursPerformancePHP

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.