Aller au contenu
jsonbeautifiers
Français

NDJSON et JSON Lines : un enregistrement par ligne

Un tableau JSON géant ne se diffuse pas, ne s’ajoute pas et ne se récupère pas partiellement. Une valeur JSON par ligne corrige les trois.

Chaque affirmation de cette page est soit mesurée, soit sourcée. Quand elle n’est ni l’une ni l’autre, la page le dit.

Vous téléchargez un export, il porte l’extension .json, et ses deux premières lignes ressemblent à ceci :

{"ts":"2026-09-01T10:00:00Z","level":"info","msg":"started"}
{"ts":"2026-09-01T10:00:01Z","level":"warn","msg":"retry 1"}

Chaque ligne est du JSON valide. Le fichier ne l’est pas. Pas de crochet ouvrant, pas de virgules, pas de crochet fermant : n’importe quel analyseur pointé sur l’ensemble échouera sur le deuxième enregistrement. C’est du NDJSON, et vous ne l’avez pas choisi ; ce qui a produit le fichier l’a choisi, parce que l’alternative ne fonctionne pas à la taille que le fichier allait atteindre.

Le format, précisément

  • Une valeur JSON complète par ligne. Généralement un objet, mais un nombre ou une chaîne nus sont légaux.
  • UTF-8, sans marque d’ordre des octets.
  • Enregistrements séparés par un saut de ligne. La plupart des lecteurs tolèrent CRLF, et vous ne devriez pas l’émettre.
  • Les lignes vides sont ignorées : un saut de ligne final en fin de fichier est correct et c’est la convention.
  • Aucun tableau englobant. Aucune virgule entre les enregistrements.
  • Extensions .ndjson et .jsonl, même si quantité de fichiers dans la nature s’appellent .json ou .log.

Si le format tient debout, c’est grâce à une propriété des chaînes JSON : une chaîne JSON ne peut pas contenir un saut de ligne littéral. Les caractères de contrôle sous U+0020 doivent être échappés, donc un enregistrement correctement sérialisé ne peut jamais contenir un \n brut. C’est ce qui fait de « découper au saut de ligne » un tokeniseur sûr plutôt qu’une devinette.

NDJSON ou JSON Lines ?

C’est la même chose. Deux petites spécifications ont été écrites séparément, et elles s’accordent sur tout ce qui décide si un fichier s’analyse : une valeur JSON par ligne, UTF-8, séparation par saut de ligne. Le camp JSON Lines préfère l’extension .jsonl, le camp NDJSON préfère .ndjson, et tous les lecteurs acceptent les deux. Rien dans l’un ou l’autre document ne change une ligne de votre code. Si l’on vous demande lequel vous produisez, la réponse honnête est « les deux ».

Trois choses qu’un tableau unique ne sait pas faire

Se diffuser. Un tableau JSON est une seule valeur : un analyseur conventionnel doit donc le tenir en entier avant de vous rendre quoi que ce soit. Construire un arbre de document coûte plusieurs fois la taille de l’entrée en tas : avec l’analyseur de ce site, un document de 10 Mo coûte environ 294 Mo. Dans un navigateur, vous heurtez d’abord un mur plus dur, car un moteur JavaScript plafonne une chaîne unique à 536 870 888 caractères, environ 512 Mo. Rien au-delà ne peut même être lu en mémoire comme texte, encore moins analysé. NDJSON n’a pas ce plafond, parce que vous ne tenez jamais plus d’un enregistrement. Voyez gros fichiers JSON pour la version côté analyseur.

S’allonger. Ajouter un enregistrement à un tableau JSON, c’est revenir en arrière sur le crochet fermant, écrire une virgule, écrire l’enregistrement, réécrire le crochet. Deux rédacteurs faisant cela en même temps produisent du charabia. Ajouter à du NDJSON, c’est une seule écriture en fin de fichier sans rien à lire d’abord, et c’est exactement pourquoi tous les collecteurs de journaux du monde sont bâtis dessus.

Survivre à une corruption. Tronquez un tableau JSON n’importe où et vous perdez le document entier : Unexpected end of JSON input, aucun enregistrement récupéré. Tronquez du NDJSON et vous perdez la dernière ligne. Un enregistrement fautif coûte un enregistrement, et un lecteur qui capture ligne par ligne continue.

Où vous l’avez déjà croisé

Le pilote de journaux json-file par défaut de Docker, qui écrit un objet JSON par ligne et par conteneur. L’API _bulk d’Elasticsearch, qui en utilise une variante : une ligne d’action, puis une ligne de document, et elle exige un saut de ligne final. Les tâches de chargement de BigQuery, où le format source s’appelle littéralement NEWLINE_DELIMITED_JSON. Le JSONEachRow de ClickHouse. Tout ce qu’écrit jq -c. Les API en flux, y compris les complétions de LLM, sont généralement voisines plutôt qu’identiques : les server-sent events portent une valeur JSON par ligne data: mais ajoutent leur propre encadrement, donc un flux SSE n’est pas un fichier NDJSON même si les payloads en sont.

À quoi cela ressemble quand on se trompe

Analysez l’exemple à deux enregistrements ci-dessus comme un seul document, et les messages sont assez précis pour identifier le problème sur-le-champ.

Node (V8) :

Unexpected non-whitespace character after JSON at position 61 (line 2 column 1)

Python :

Extra data: line 2 column 1 (char 61)

Les deux veulent dire la même chose : une valeur JSON complète a été analysée avec succès, puis l’entrée a continué. Si vous poursuivez celle-là, Extra data en Python en couvre les variantes.

L’erreur inverse est tout aussi courante. Donnez un document indenté à un lecteur NDJSON et il tente d’analyser la première ligne, {, toute seule :

# Node :   Expected property name or '}' in JSON at position 1 (line 1 column 2)
# Python : Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

Une erreur en colonne 2 de la ligne 1 à chaque lecture ligne à ligne est la signature d’un document indenté avant d’être écrit.

Le lire et l’écrire

Python. Itérez sur le descripteur de fichier ; ne le chargez pas en mémoire.

import json

with open("events.ndjson", encoding="utf-8") as f:
    for n, line in enumerate(f, 1):
        line = line.strip()
        if not line:
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError as e:
            print(f"line {n}: {e}")

C’est à l’écriture que l’on casse le format, et cela tient à un argument :

with open("out.ndjson", "w", encoding="utf-8") as f:
    for record in records:
        f.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")

separators=(",", ":") supprime les espaces que json.dumps ajoute par défaut. Ne passez jamais indent=, qui émet des sauts de ligne à l’intérieur de l’enregistrement et détruit le fichier. ensure_ascii=False est facultatif et conserve les caractères non ASCII tels quels au lieu d’échappements \uXXXX ; la valeur par défaut est True, valide et plus volumineuse.

Node. readline gère les frontières de tampon, et crlfDelay: Infinity empêche qu’un \r\n coupé entre deux blocs soit lu comme deux sauts de ligne.

import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const rl = createInterface({
  input: createReadStream("events.ndjson", "utf8"),
  crlfDelay: Infinity,
});

let n = 0;
for await (const line of rl) {
  n++;
  if (!line.trim()) continue;
  try {
    handle(JSON.parse(line));
  } catch (e) {
    console.error(`line ${n}: ${e.message}`);
  }
}

Go est déjà correct par défaut : json.NewEncoder(w).Encode(v) écrit un enregistrement compact et ajoute un saut de ligne. Appelez SetEscapeHTML(false) si vous ne voulez pas que <, > et & deviennent des échappements.

jq lit nativement un flux de valeurs séparées par des blancs. -c émet une valeur compacte par ligne, -s avale le flux dans un tableau unique.

jq -c '.[]' big-array.json > events.ndjson   # tableau vers NDJSON
jq -s '.'   events.ndjson  > big-array.json  # NDJSON vers tableau
jq -c 'select(.level == "warn")' events.ndjson

pandas accepte lines=True des deux côtés, et chunksize transforme la lecture en itérateur de frames pour que vous ne matérialisiez jamais le fichier.

import pandas as pd

df = pd.read_json("events.ndjson", lines=True)
df.to_json("out.ndjson", orient="records", lines=True)

for chunk in pd.read_json("events.ndjson", lines=True, chunksize=50_000):
    ...

Les règles que l’on casse

La seule règle dure est qu’un enregistrement occupe exactement une ligne, ce qui veut dire que chaque enregistrement doit être minifié. Si vous produisez du NDJSON depuis un formateur, minifiez chaque enregistrement plutôt que le fichier. Terminez le fichier par un saut de ligne : les lecteurs sautent les lignes vides, certains consommateurs exigent le terminateur, et cat a.ndjson b.ndjson ne marche que si les deux fichiers en ont un.

Un bénéfice sous-estimé de la discipline de ligne : le fichier est désormais du texte que vos outils habituels comprennent. wc -l compte les enregistrements, grep les filtre, sort et diff fonctionnent, split découpe le fichier sans analyseur. Un tableau indenté ne vous donne rien de tout cela, et c’est pourquoi comparer deux exports revient d’ordinaire à charger les deux dans un diff structurel.

Quand ne pas l’utiliser

Tout ce qu’un navigateur consomme d’un bloc. fetch(...).then(r => r.json()) ne sait pas lire du NDJSON, et un bloc <script type="application/json"> non plus. Tout ce qui doit être un unique document valide : fichiers de configuration, corps de réponse d’API, un payload que vous validez contre un schéma, un fichier que vous confiez à un visualiseur pour explorer. NDJSON est un format de transport et de stockage pour des flux d’enregistrements, pas un format de document.

Quand vous devez franchir cette ligne, convertissez plutôt que d’éditer à la main. L’outil NDJSON vers JSON va dans les deux sens dans le navigateur et, quand un enregistrement échoue, vous dit à quel numéro de ligne il se trouvait au lieu de faire échouer tout le fichier.