Saltar al contenido
jsonbeautifiers
Español

NDJSON y JSON Lines: un registro por línea

Un array JSON gigante no se puede transmitir, ni ampliar, ni recuperar parcialmente. Un valor JSON por línea arregla las tres cosas.

Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.

Descargas una exportación, tiene extensión .json, y las dos primeras líneas se ven así:

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

Cada línea es JSON válido. El archivo no. No hay corchete de apertura, ni comas, ni corchete de cierre, así que cualquier parser al que apuntes al conjunto fallará en el segundo registro. Esto es NDJSON, y no lo elegiste tú; lo eligió lo que produjo el archivo, porque la alternativa no funciona al tamaño que el archivo iba a alcanzar.

El formato, con precisión

  • Un valor JSON completo por línea. Normalmente un objeto, aunque un número o una cadena desnudos son legales.
  • UTF-8, sin marca de orden de bytes.
  • Registros separados por un salto de línea. La mayoría de lectores tolera CRLF, y tú no deberías emitirlo.
  • Las líneas en blanco se ignoran, así que un salto de línea final al terminar el archivo está bien y es la convención.
  • Sin array envolvente. Sin comas entre registros.
  • Extensiones .ndjson y .jsonl, aunque montones de archivos por ahí se llaman .json o .log.

La razón por la que el formato se sostiene es una propiedad de las cadenas JSON: una cadena JSON no puede contener un salto de línea literal. Los caracteres de control por debajo de U+0020 deben escaparse, así que un registro correctamente serializado nunca puede contener un \n crudo. Eso es lo que convierte «partir por el salto de línea» en un tokenizador seguro en vez de una adivinanza.

¿NDJSON o JSON Lines?

Son lo mismo. Se escribieron dos especificaciones pequeñas por separado, y coinciden en todo lo que decide si un archivo parsea: un valor JSON por línea, UTF-8, separado por saltos de línea. El lado de JSON Lines prefiere la extensión .jsonl, el de NDJSON prefiere .ndjson, y todos los lectores aceptan las dos. Nada en ninguno de los documentos cambia una línea de tu código. Si alguien te pregunta cuál estás produciendo, la respuesta honesta es «ambos».

Tres cosas que un array único no puede hacer

Transmitirse. Un array JSON es un solo valor, así que un parser convencional tiene que sostenerlo entero antes de darte nada. Construir un árbol de documento cuesta múltiplos del tamaño de entrada en heap: con el parser de este sitio, un documento de 10 MB cuesta unos 294 MB. En un navegador chocas antes contra un muro más duro, porque un motor de JavaScript limita una sola cadena a 536.870.888 caracteres, unos 512 MB. Nada por encima de eso puede siquiera leerse a memoria como texto, ya no digamos parsearse. NDJSON no tiene ese techo, porque nunca sostienes más de un registro. Mira archivos JSON grandes para la versión a nivel de parser.

Ampliarse. Añadir un registro a un array JSON significa retroceder sobre el corchete de cierre, escribir una coma, escribir el registro y escribir de nuevo el corchete. Dos escritores haciendo eso a la vez producen basura. Añadir a NDJSON es una única escritura al final del archivo sin nada que leer antes, que es exactamente por lo que todo agente de logs del planeta está construido sobre él.

Sobrevivir a la corrupción. Trunca un array JSON por cualquier parte y pierdes el documento entero: Unexpected end of JSON input, ningún registro recuperado. Trunca NDJSON y pierdes la última línea. Un registro malo cuesta un registro, y un lector que captura por línea sigue adelante.

Dónde ya te lo has encontrado

El driver de logs json-file por defecto de Docker, que escribe un objeto JSON por línea y por contenedor. La API _bulk de Elasticsearch, que usa una variante: una línea de acción, luego una línea de documento, y exige un salto de línea final. Los trabajos de carga de BigQuery, donde el formato de origen se llama literalmente NEWLINE_DELIMITED_JSON. El JSONEachRow de ClickHouse. Todo lo que escribe jq -c. Las APIs de streaming, incluidas las completaciones de LLM, suelen ser adyacentes más que idénticas: los server-sent events transportan un valor JSON por línea data: pero añaden su propio encuadre, así que un flujo SSE no es un archivo NDJSON aunque los payloads sí lo sean.

Qué aspecto tiene cuando te equivocas

Parsea el ejemplo de dos registros de arriba como un solo documento y los mensajes son lo bastante específicos para identificarlo al momento.

Node (V8):

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

Python:

Extra data: line 2 column 1 (char 61)

Ambos significan lo mismo: se parseó con éxito un valor JSON completo y luego la entrada siguió. Si andas persiguiendo ese, Extra data en Python cubre las variantes.

El error inverso es igual de común. Dale un documento con sangrado a un lector de NDJSON y tratará de parsear la primera línea, {, por su cuenta:

# 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 error en la columna 2 de la línea 1 en toda lectura orientada a líneas es la firma de un documento que fue indentado antes de escribirse.

Leerlo y escribirlo

Python. Itera sobre el descriptor del archivo; no lo leas a 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}")

Escribir es donde la gente rompe el formato, y se reduce a un argumento:

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=(",", ":") elimina los espacios que json.dumps añade por defecto. Nunca pases indent=, que emite saltos de línea dentro del registro y destruye el archivo. ensure_ascii=False es opcional y mantiene los caracteres no ASCII como son en vez de convertirlos en escapes \uXXXX; el valor por defecto es True, que es válido y más grande.

Node. readline maneja los límites de búfer, y crlfDelay: Infinity evita que un \r\n partido entre dos trozos se lea como dos saltos de línea.

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 ya es correcto por defecto: json.NewEncoder(w).Encode(v) escribe un registro compacto y añade un salto de línea. Llama a SetEscapeHTML(false) si no quieres que <, > y & se conviertan en escapes.

jq lee de forma nativa un flujo de valores separados por espacios en blanco. -c emite un valor compacto por línea, -s sorbe el flujo hacia un único array.

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

pandas acepta lines=True en ambos sentidos, y chunksize convierte la lectura en un iterador de frames para que nunca materialices el archivo.

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

Las reglas que la gente rompe

La única regla dura es que un registro ocupa exactamente una línea, lo que significa que cada registro debe estar minificado. Si estás produciendo NDJSON desde un formateador, minifica cada registro y no el archivo. Termina el archivo con un salto de línea: los lectores se saltan las líneas en blanco, algunos consumidores exigen el terminador, y cat a.ndjson b.ndjson solo funciona si ambos archivos lo tienen.

Un beneficio infravalorado de la disciplina de líneas: el archivo ahora es texto que tus herramientas de siempre entienden. wc -l cuenta registros, grep los filtra, sort y diff funcionan, split parte el archivo sin parser. Un array con sangrado no te da nada de eso, y por eso comparar dos exportaciones normalmente significa cargar ambas en un diff estructural.

Cuándo no usarlo

Cualquier cosa que un navegador consuma de una pieza. fetch(...).then(r => r.json()) no puede leer NDJSON, y un bloque <script type="application/json"> tampoco. Cualquier cosa que tenga que ser un único documento válido: archivos de configuración, cuerpos de respuesta de API, un payload que validas contra un esquema, un archivo que le pasas a un visor para explorarlo. NDJSON es un formato de transporte y almacenamiento para flujos de registros, no un formato de documento.

Cuando necesites cruzar esa línea, convierte en vez de editar a mano. La herramienta de NDJSON a JSON va en ambos sentidos en el navegador y, cuando un registro falla, te dice en qué número de línea estaba en vez de tumbar el archivo entero.