Ir para o conteúdo
jsonbeautifiers
Português

NDJSON e JSON Lines: um registro por linha

Um array JSON gigante não dá para transmitir, nem acrescentar, nem recuperar em parte. Um valor JSON por linha conserta as três coisas.

Cada afirmação desta página foi medida ou tem fonte. Quando não é nem uma coisa nem outra, a página diz isso.

Você baixa uma exportação, ela tem extensão .json, e as duas primeiras linhas são assim:

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

Cada linha é JSON válido. O arquivo não é. Não há colchete de abertura, nem vírgulas, nem colchete de fechamento, então qualquer parser que você apontar para o conjunto vai falhar no segundo registro. Isto é NDJSON, e não foi você quem escolheu; quem produziu o arquivo escolheu, porque a alternativa não funciona no tamanho que o arquivo ia alcançar.

O formato, com precisão

  • Um valor JSON completo por linha. Normalmente um objeto, mas um número ou uma string sozinhos são legais.
  • UTF-8, sem marca de ordem de bytes.
  • Registros separados por quebra de linha. A maioria dos leitores tolera CRLF, e você não deveria emitir.
  • Linhas em branco são ignoradas, então uma quebra de linha no fim do arquivo está certa e é a convenção.
  • Sem array envolvente. Sem vírgulas entre registros.
  • Extensões .ndjson e .jsonl, embora um monte de arquivos por aí se chame .json ou .log.

O motivo de o formato se sustentar é uma propriedade das strings JSON: uma string JSON não pode conter uma quebra de linha literal. Caracteres de controle abaixo de U+0020 precisam ser escapados, então um registro corretamente serializado nunca contém um \n cru. É isso que faz de “quebrar na quebra de linha” um tokenizador seguro em vez de um chute.

NDJSON ou JSON Lines?

São a mesma coisa. Duas pequenas especificações foram escritas em separado, e elas concordam em tudo que decide se um arquivo faz parsing: um valor JSON por linha, UTF-8, separado por quebras de linha. O lado JSON Lines prefere a extensão .jsonl, o lado NDJSON prefere .ndjson, e todo leitor aceita as duas. Nada em qualquer dos documentos muda uma linha do seu código. Se alguém perguntar qual você está produzindo, a resposta honesta é “os dois”.

Três coisas que um array único não consegue fazer

Transmitir. Um array JSON é um valor só, então um parser convencional precisa segurar a coisa inteira antes de te entregar qualquer coisa. Construir uma árvore de documento custa múltiplos do tamanho de entrada em heap: no parser deste site, um documento de 10 MB custa cerca de 294 MB. No navegador você bate antes num muro mais duro, porque um motor JavaScript limita uma única string a 536.870.888 caracteres, cerca de 512 MB. Nada acima disso consegue nem ser lido para a memória como texto, quanto mais parseado. NDJSON não tem esse teto, porque você nunca segura mais de um registro. Veja arquivos JSON grandes para a versão no nível do parser.

Acrescentar. Acrescentar um registro a um array JSON significa voltar por cima do colchete de fechamento, escrever uma vírgula, escrever o registro, escrever o colchete de novo. Dois escritores fazendo isso ao mesmo tempo produzem lixo. Acrescentar a NDJSON é uma única escrita no fim do arquivo sem nada para ler antes, que é exatamente o motivo de todo agente de logs do planeta ser construído em cima disso.

Sobreviver à corrupção. Trunque um array JSON em qualquer ponto e você perde o documento inteiro: Unexpected end of JSON input, nenhum registro recuperado. Trunque NDJSON e você perde a última linha. Um registro ruim custa um registro, e um leitor que captura por linha segue em frente.

Onde você já topou com isso

O driver de log padrão json-file do Docker, que escreve um objeto JSON por linha por contêiner. A API _bulk do Elasticsearch, que usa uma variante: uma linha de ação, depois uma linha de documento, e ela exige uma quebra de linha final. Os jobs de carga do BigQuery, em que o formato de origem se chama literalmente NEWLINE_DELIMITED_JSON. O JSONEachRow do ClickHouse. Tudo que o jq -c escreve. APIs de streaming, incluindo completações de LLM, costumam ser vizinhas em vez de idênticas: server-sent events carregam um valor JSON por linha data: mas acrescentam o próprio enquadramento, então um fluxo SSE não é um arquivo NDJSON mesmo que os payloads sejam.

Como fica quando você erra

Faça o parsing do exemplo de dois registros acima como um documento só e as mensagens são específicas o bastante para identificar de imediato.

Node (V8):

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

Python:

Extra data: line 2 column 1 (char 61)

Os dois querem dizer a mesma coisa: um valor JSON completo foi parseado com sucesso e então a entrada continuou. Se você está atrás desse, Extra data em Python cobre as variantes.

O erro inverso é igualmente comum. Entregue um documento indentado a um leitor de NDJSON e ele tenta fazer o parsing da primeira linha, {, sozinha:

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

Um erro na coluna 2 da linha 1 em toda leitura orientada a linhas é a assinatura de um documento que foi indentado antes de ser escrito.

Lendo e escrevendo

Python. Itere sobre o descritor do arquivo; não leia para a memória.

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

É na escrita que as pessoas quebram o formato, e tudo se resume a um 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=(",", ":") remove os espaços que o json.dumps acrescenta por padrão. Nunca passe indent=, que emite quebras de linha dentro do registro e destrói o arquivo. ensure_ascii=False é opcional e mantém os caracteres não ASCII como eles mesmos em vez de escapes \uXXXX; o padrão é True, que é válido e maior.

Node. O readline cuida das fronteiras de buffer, e crlfDelay: Infinity impede que um \r\n partido entre dois blocos seja lido como duas quebras de linha.

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 já está certo por padrão: json.NewEncoder(w).Encode(v) escreve um registro compacto e acrescenta uma quebra de linha. Chame SetEscapeHTML(false) se você não quiser <, > e & virando escapes.

jq lê nativamente um fluxo de valores separados por espaço em branco. -c emite um valor compacto por linha, -s sorve o fluxo para um array único.

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

pandas aceita lines=True dos dois lados, e chunksize transforma a leitura num iterador de frames para você nunca materializar o arquivo.

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

As regras que as pessoas quebram

A única regra dura é que um registro ocupa exatamente uma linha, o que significa que cada registro precisa estar minificado. Se você está produzindo NDJSON a partir de um formatador, minifique cada registro em vez do arquivo. Termine o arquivo com uma quebra de linha: leitores pulam linhas em branco, alguns consumidores exigem o terminador, e cat a.ndjson b.ndjson só funciona se os dois arquivos tiverem uma.

Um benefício subestimado da disciplina de linha: o arquivo agora é texto que as suas ferramentas de sempre entendem. wc -l conta registros, grep filtra, sort e diff funcionam, split fatia o arquivo sem parser. Um array indentado não te dá nada disso, e é por isso que comparar duas exportações normalmente significa carregar as duas num diff estrutural.

Quando não usar

Qualquer coisa que um navegador consuma de uma vez. fetch(...).then(r => r.json()) não lê NDJSON, e um bloco <script type="application/json"> também não. Qualquer coisa que precise ser um único documento válido: arquivos de configuração, corpos de resposta de API, um payload que você valida contra um schema, um arquivo que você entrega a um visualizador para explorar. NDJSON é formato de transporte e armazenamento para fluxos de registros, não formato de documento.

Quando você precisar cruzar essa linha, converta em vez de editar à mão. A ferramenta NDJSON para JSON vai nos dois sentidos no navegador e, quando um registro falha, te diz em que número de linha ele estava em vez de derrubar o arquivo inteiro.