Zum Inhalt springen
jsonbeautifiers
Deutsch

NDJSON und JSON Lines: ein Datensatz je Zeile

Ein riesiges JSON-Array lässt sich nicht streamen, nicht anhängen und nicht teilweise retten. Ein JSON-Wert je Zeile behebt alle drei Punkte.

Jede Aussage auf dieser Seite ist entweder gemessen oder belegt. Wo sie keines von beidem ist, steht das dabei.

Sie laden einen Export herunter, er trägt die Endung .json, und die ersten beiden Zeilen sehen so aus:

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

Jede Zeile ist gültiges JSON. Die Datei ist es nicht. Es gibt keine öffnende Klammer, keine Kommas und keine schließende Klammer, jeder Parser, den Sie auf das Ganze richten, scheitert also am zweiten Datensatz. Das ist NDJSON, und Sie haben es nicht gewählt; gewählt hat es das, was die Datei erzeugt hat, denn die Alternative funktioniert bei der Größe, die diese Datei erreichen sollte, nicht.

Das Format, genau

  • Ein vollständiger JSON-Wert je Zeile. Meist ein Objekt, aber eine nackte Zahl oder Zeichenkette ist zulässig.
  • UTF-8, ohne Byte-Reihenfolge-Markierung.
  • Datensätze durch einen Zeilenvorschub getrennt. Die meisten Leser dulden CRLF, und Sie sollten es nicht ausgeben.
  • Leerzeilen werden ignoriert, ein abschließender Zeilenumbruch am Dateiende ist also in Ordnung und ist die Konvention.
  • Kein umschließendes Array. Keine Kommas zwischen Datensätzen.
  • Endungen .ndjson und .jsonl, auch wenn reichlich Dateien draußen .json oder .log heißen.

Dass das Format überhaupt hält, verdankt sich einer Eigenschaft von JSON-Zeichenketten: Eine JSON-Zeichenkette darf keinen wörtlichen Zeilenumbruch enthalten. Steuerzeichen unterhalb von U+0020 müssen escapt werden, ein korrekt serialisierter Datensatz kann also nie ein rohes \n enthalten. Genau das macht „am Zeilenumbruch trennen“ zu einem sicheren Tokenizer statt zu einer Vermutung.

NDJSON oder JSON Lines?

Dasselbe. Zwei kleine Spezifikationen wurden getrennt geschrieben, und sie sind sich in allem einig, was darüber entscheidet, ob eine Datei parst: ein JSON-Wert je Zeile, UTF-8, zeilenumbruchgetrennt. Die JSON-Lines-Seite bevorzugt die Endung .jsonl, die NDJSON-Seite .ndjson, und jeder Leser akzeptiert beides. Nichts in einem der Dokumente ändert eine Zeile Ihres Codes. Wenn jemand fragt, welches Sie erzeugen, ist die ehrliche Antwort „beides“.

Drei Dinge, die ein einzelnes Array nicht kann

Streamen. Ein JSON-Array ist ein Wert, ein herkömmlicher Parser muss also das Ganze halten, bevor er Ihnen irgendetwas gibt. Einen Dokumentbaum zu bauen kostet ein Vielfaches der Eingabegröße an Heap: Beim Parser dieser Seite kostet ein 10-MB-Dokument rund 294 MB. Im Browser stoßen Sie vorher an eine härtere Wand, denn eine JavaScript-Engine begrenzt eine einzelne Zeichenkette auf 536.870.888 Zeichen, etwa 512 MB. Nichts darüber lässt sich überhaupt als Text in den Speicher lesen, geschweige denn parsen. NDJSON hat diese Decke nicht, weil Sie nie mehr als einen Datensatz halten. Die Parser-Sicht dazu steht in große JSON-Dateien.

Anhängen. Einen Datensatz an ein JSON-Array anzufügen heißt: zurück über die schließende Klammer springen, ein Komma schreiben, den Datensatz schreiben, die Klammer erneut schreiben. Zwei Schreiber, die das gleichzeitig tun, produzieren Müll. An NDJSON anzuhängen ist ein einziger Schreibvorgang am Dateiende, ohne vorher irgendetwas lesen zu müssen, und genau deshalb ist jeder Log-Versender der Welt darauf gebaut.

Beschädigung überstehen. Schneiden Sie ein JSON-Array irgendwo ab und Sie verlieren das ganze Dokument: Unexpected end of JSON input, kein Datensatz gerettet. Schneiden Sie NDJSON ab und Sie verlieren die letzte Zeile. Ein kaputter Datensatz kostet einen Datensatz, und ein Leser, der je Zeile abfängt, macht weiter.

Wo Sie ihm schon begegnet sind

Dockers voreingestellter Logtreiber json-file, der je Container ein JSON-Objekt pro Zeile schreibt. Elasticsearchs _bulk-API, die eine Variante davon nutzt: eine Aktionszeile, dann eine Dokumentzeile, und sie besteht auf einem abschließenden Zeilenumbruch. BigQuery-Ladeaufträge, wo das Quellformat wörtlich NEWLINE_DELIMITED_JSON heißt. ClickHouse mit JSONEachRow. Alles, was jq -c schreibt. Streaming-APIs, einschließlich LLM-Vervollständigungen, sind meist benachbart statt identisch: Server-Sent Events tragen einen JSON-Wert je data:-Zeile, fügen aber ihre eigene Rahmung hinzu, ein SSE-Strom ist also keine NDJSON-Datei, auch wenn die Nutzlasten es sind.

Wie es aussieht, wenn man es falsch macht

Parsen Sie das Zweizeilen-Beispiel oben als ein einziges Dokument, und die Meldungen sind spezifisch genug, um es sofort zu erkennen.

Node (V8):

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

Python:

Extra data: line 2 column 1 (char 61)

Beides heißt dasselbe: Ein vollständiger JSON-Wert wurde erfolgreich geparst, und dann ging die Eingabe weiter. Wenn Sie diesem hinterherjagen, deckt Extra data in Python die Varianten ab.

Der umgekehrte Fehler ist genauso häufig. Geben Sie einem NDJSON-Leser ein eingerücktes Dokument, und er versucht, die erste Zeile, {, für sich zu parsen:

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

Ein Fehler in Spalte 2 von Zeile 1 bei jedem zeilenorientierten Lesen ist die Signatur eines Dokuments, das eingerückt wurde, bevor es geschrieben wurde.

Lesen und schreiben

Python. Iterieren Sie über das Dateihandle; lesen Sie es nicht in den Speicher.

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

Beim Schreiben zerlegt man das Format, und es hängt an einem 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=(",", ":") entfernt die Leerzeichen, die json.dumps standardmäßig einfügt. Übergeben Sie nie indent=, das Zeilenumbrüche innerhalb des Datensatzes erzeugt und die Datei zerstört. ensure_ascii=False ist optional und behält Nicht-ASCII-Zeichen als sie selbst statt als \uXXXX-Escapes; die Vorgabe ist True, gültig und größer.

Node. readline kümmert sich um die Puffergrenzen, und crlfDelay: Infinity verhindert, dass ein über zwei Blöcke getrenntes \r\n als zwei Zeilenumbrüche gelesen wird.

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 ist standardmäßig schon richtig: json.NewEncoder(w).Encode(v) schreibt einen kompakten Datensatz und hängt einen Zeilenumbruch an. Rufen Sie SetEscapeHTML(false) auf, wenn <, > und & nicht zu Escapes werden sollen.

jq liest von Haus aus einen Strom durch Leerraum getrennter Werte. -c gibt einen kompakten Wert je Zeile aus, -s schlürft den Strom in ein einzelnes Array.

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

pandas nimmt auf beiden Seiten lines=True, und chunksize macht aus dem Lesen einen Iterator von Frames, sodass Sie die Datei nie materialisieren.

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

Die Regeln, die gebrochen werden

Die einzige harte Regel ist, dass ein Datensatz genau eine Zeile einnimmt, was heißt, dass jeder Datensatz minifiziert sein muss. Wenn Sie NDJSON aus einem Formatierer erzeugen, minifizieren Sie jeden Datensatz statt der Datei. Beenden Sie die Datei mit einem Zeilenumbruch: Leser überspringen Leerzeilen, manche Konsumenten verlangen den Abschluss, und cat a.ndjson b.ndjson funktioniert nur, wenn beide Dateien einen haben.

Ein unterschätzter Vorteil der Zeilendisziplin: Die Datei ist jetzt Text, den Ihre vorhandenen Werkzeuge verstehen. wc -l zählt Datensätze, grep filtert sie, sort und diff funktionieren, split zerteilt die Datei ohne Parser. Ein eingerücktes Array gibt Ihnen nichts davon, und deshalb heißt der Vergleich zweier Exporte meist, beide in einen strukturellen Diff zu laden.

Wann man es nicht nimmt

Alles, was ein Browser am Stück konsumiert. fetch(...).then(r => r.json()) kann NDJSON nicht lesen, ein <script type="application/json">-Block ebenso wenig. Alles, was ein einzelnes gültiges Dokument sein muss: Konfigurationsdateien, API-Antwortkörper, ein Payload, das Sie gegen ein Schema validieren, eine Datei, die Sie einem Viewer zum Erkunden geben. NDJSON ist ein Transport- und Speicherformat für Datensatzströme, kein Dokumentformat.

Wenn Sie diese Grenze überqueren müssen, wandeln Sie um, statt von Hand zu bearbeiten. Das Werkzeug NDJSON zu JSON geht im Browser in beide Richtungen und nennt Ihnen bei einem fehlerhaften Datensatz dessen Zeilennummer, statt die ganze Datei scheitern zu lassen.