Vai al contenuto
jsonbeautifiers
Italiano

NDJSON e JSON Lines: un record per riga

Un array JSON gigantesco non si può trasmettere, accodare né recuperare in parte. Un valore JSON per riga risolve tutt’e tre le cose.

Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.

Scarichi un export, ha estensione .json, e le prime due righe sono così:

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

Ogni riga è JSON valido. Il file no. Non c’è parentesi di apertura, non ci sono virgole e non c’è parentesi di chiusura, quindi qualsiasi parser tu punti sull’insieme fallirà sul secondo record. Questo è NDJSON, e non l’hai scelto tu: l’ha scelto ciò che ha prodotto il file, perché l’alternativa non funziona alle dimensioni che quel file avrebbe raggiunto.

Il formato, con precisione

  • Un valore JSON completo per riga. Di solito un oggetto, ma un numero o una stringa nudi sono leciti.
  • UTF-8, senza byte order mark.
  • Record separati da un avanzamento di riga. Quasi tutti i lettori tollerano CRLF, e tu non dovresti emetterlo.
  • Le righe vuote vengono ignorate, quindi un a capo finale in fondo al file va bene ed è la convenzione.
  • Nessun array che racchiude. Nessuna virgola tra i record.
  • Estensioni .ndjson e .jsonl, anche se parecchi file in giro si chiamano .json o .log.

Il motivo per cui il formato sta in piedi è una proprietà delle stringhe JSON: una stringa JSON non può contenere un a capo letterale. I caratteri di controllo sotto U+0020 vanno sottoposti a escape, quindi un record serializzato correttamente non può mai contenere un \n grezzo. È questo che rende «spezzare sull’a capo» un tokenizzatore sicuro anziché una scommessa.

NDJSON o JSON Lines?

Sono la stessa cosa. Due piccole specifiche sono state scritte separatamente e concordano su tutto ciò che decide se un file si analizza: un valore JSON per riga, UTF-8, separazione a fine riga. Il fronte JSON Lines preferisce l’estensione .jsonl, quello NDJSON preferisce .ndjson, e ogni lettore accetta entrambe. Nulla nei due documenti cambia una riga del tuo codice. Se qualcuno ti chiede quale stai producendo, la risposta onesta è «entrambi».

Tre cose che un array singolo non sa fare

Trasmettersi. Un array JSON è un unico valore, quindi un parser convenzionale deve tenerlo tutto prima di consegnarti qualcosa. Costruire un albero di documento costa multipli della dimensione dell’ingresso in heap: con il parser di questo sito un documento da 10 MB costa circa 294 MB. In un browser urti prima contro un muro più duro, perché un motore JavaScript limita una singola stringa a 536.870.888 caratteri, circa 512 MB. Nulla oltre quella soglia può nemmeno essere letto in memoria come testo, figurarsi analizzato. NDJSON non ha quel tetto, perché non tieni mai più di un record. Per la versione a livello di parser vedi file JSON di grandi dimensioni.

Accodarsi. Aggiungere un record a un array JSON significa tornare indietro oltre la parentesi di chiusura, scrivere una virgola, scrivere il record, riscrivere la parentesi. Due scrittori che lo fanno insieme producono spazzatura. Accodare a NDJSON è una sola scrittura in fondo al file senza nulla da leggere prima, ed è esattamente per questo che ogni spedizioniere di log del pianeta ci è costruito sopra.

Sopravvivere alla corruzione. Tronca un array JSON in un punto qualsiasi e perdi l’intero documento: Unexpected end of JSON input, nessun record recuperato. Tronca un NDJSON e perdi l’ultima riga. Un record guasto costa un record, e un lettore che intercetta riga per riga tira dritto.

Dove l’hai già incontrato

Il driver di log predefinito json-file di Docker, che scrive un oggetto JSON per riga per contenitore. L’API _bulk di Elasticsearch, che ne usa una variante: una riga di azione, poi una riga di documento, e pretende un a capo finale. I job di caricamento di BigQuery, dove il formato sorgente si chiama letteralmente NEWLINE_DELIMITED_JSON. Il JSONEachRow di ClickHouse. Tutto ciò che scrive jq -c. Le API in streaming, comprese le completion degli LLM, di solito sono vicine anziché identiche: i server-sent events portano un valore JSON per riga data: ma aggiungono la propria incorniciatura, quindi un flusso SSE non è un file NDJSON anche se i payload lo sono.

Che aspetto ha quando si sbaglia

Analizza l’esempio a due record qui sopra come un unico documento e i messaggi sono abbastanza specifici da riconoscerlo subito.

Node (V8):

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

Python:

Extra data: line 2 column 1 (char 61)

Vogliono dire la stessa cosa: un valore JSON completo è stato analizzato con successo e poi l’input è proseguito. Se stai inseguendo quello, Extra data in Python ne copre le varianti.

L’errore inverso è altrettanto comune. Dai a un lettore NDJSON un documento indentato e prova ad analizzare la prima riga, {, da sola:

# 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)

Un errore in colonna 2 della riga 1 a ogni lettura per righe è la firma di un documento indentato prima di essere scritto.

Leggerlo e scriverlo

Python. Itera sul descrittore del file; non caricarlo in memoria.

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}")

È in scrittura che si rompe il formato, e tutto si gioca su un argomento:

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=(",", ":") toglie gli spazi che json.dumps aggiunge di default. Non passare mai indent=, che emette a capo dentro il record e distrugge il file. ensure_ascii=False è facoltativo e mantiene i caratteri non ASCII come sono anziché come escape \uXXXX; il valore predefinito è True, valido e più voluminoso.

Node. readline gestisce i confini del buffer, e crlfDelay: Infinity impedisce che un \r\n spezzato fra due blocchi venga letto come due a capo.

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 è già corretto di default: json.NewEncoder(w).Encode(v) scrive un record compatto e aggiunge un a capo. Chiama SetEscapeHTML(false) se non vuoi che <, > e & diventino escape.

jq legge nativamente un flusso di valori separati da spazi. -c emette un valore compatto per riga, -s risucchia il flusso in un unico array.

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

pandas accetta lines=True da entrambi i lati, e chunksize trasforma la lettura in un iteratore di frame così non materializzi mai il file.

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):
    ...

Le regole che si infrangono

L’unica regola dura è che un record occupa esattamente una riga, il che significa che ogni record dev’essere minificato. Se produci NDJSON da un formattatore, minifica ogni record anziché il file. Termina il file con un a capo: i lettori saltano le righe vuote, alcuni consumatori esigono il terminatore, e cat a.ndjson b.ndjson funziona solo se entrambi i file ne hanno uno.

Un vantaggio sottovalutato della disciplina di riga: il file ora è testo che i tuoi strumenti di sempre capiscono. wc -l conta i record, grep li filtra, sort e diff funzionano, split spezza il file senza parser. Un array indentato non ti dà niente di tutto ciò, ed è per questo che confrontare due export di solito significa caricarli entrambi in un diff strutturale.

Quando non usarlo

Tutto ciò che un browser consuma in un pezzo solo. fetch(...).then(r => r.json()) non sa leggere NDJSON, e nemmeno un blocco <script type="application/json">. Tutto ciò che deve essere un unico documento valido: file di configurazione, corpi di risposta di API, un payload che validi contro uno schema, un file che consegni a un visualizzatore per esplorarlo. NDJSON è un formato di trasporto e archiviazione per flussi di record, non un formato di documento.

Quando devi attraversare quella linea, converti anziché modificare a mano. Lo strumento da NDJSON a JSON va in entrambe le direzioni nel browser e, quando un record fallisce, ti dice a quale numero di riga si trovava invece di far fallire l’intero file.