JSON para CSV: de onde vem o cabeçalho e por que os valores mudam
Resumo: Nove experimentos em um conversor real: a estratégia "chaves da primeira linha" perde campos em silêncio, null, ausência e string vazia viram todos células vazias, um inteiro de 20 dígitos perde precisão já no JSON.parse, e um ponto numa chave faz dois caminhos diferentes colidirem na mesma coluna. Como o cabeçalho é construído, quais valores se distorcem e como escolher uma estratégia conforme o uso a jusante.

Quando uma conversão de JSON para CSV perde dados, quase nunca é durante a etapa de "conversão". A perda acontece em duas perguntas que ninguém fez: quem decidiu o cabeçalho, e cada célula continua reconhecível como o valor que era?
Um campo desaparece em trinta segundos
Muitos conversores — incluindo o pipeline jq idiomático — pegam o cabeçalho apenas do primeiro registro. Reproduza as consequências em Python:
data = [{"id": 1, "name": "A"}, {"id": 2, "email": "b@x.com"}]
first_keys = list(data[0].keys()) # ['id', 'name']
rows = [[r.get(k, "") for k in first_keys] for r in data]
# O e-mail do segundo registro desaparece sem erro nem aviso
A chave email não está no cabeçalho: ela nem sequer tem o direito de virar uma célula vazia — a coluna nunca foi criada, e o valor é descartado em silêncio. A correção é a união de chaves: percorrer todos os registros e criar uma coluna para cada chave que aparecer em qualquer lugar. Os mesmos dados passados pela união produzem o cabeçalho id,name,email, com uma célula vazia onde name falta — e nada se perde.
O conversor direto da nossa página /convert/csv aplica exatamente essa estratégia. Com a entrada [{"id":1,"name":"A"},{"id":2,"email":"b@x.com"}], ele produz:
id,name,email
1,A,
2,,b@x.com
As chaves são ordenadas por primeira aparição e os campos ausentes viram células vazias. jsonoperations e jsonbeam chamam ambas isso de "a correção do bug clássico" — mas "usa a união" é só metade da história. A união tem seu próprio preço; essa é a próxima seção.
Três formas de construir um cabeçalho — e a quem cada uma serve
| Estratégia de cabeçalho | Como se constrói | Força | Custo |
|---|---|---|---|
| Chaves da primeira linha | Só o primeiro registro é lido | Poucas colunas, estável, uma linha de jq | Novos campos dos registros seguintes são perdidos em silêncio |
| União de chaves | Todos os registros são lidos | Nenhum campo se perde | Colunas esparsas e muitos vazios quando as formas variam |
| Lista explícita de colunas | Definida à mão ou por esquema | Totalmente controlável, reproduzível | Exige manutenção; campos novos pedem atualização manual |
O critério de escolha não é "qual é mais sofisticado", mas o que o destino final faz com este CSV:
- Um colega que dá uma olhada: chaves da primeira linha bastam, as formas de registro costumam ser uniformes.
- Um relatório ou pipeline de dados: a união de chaves é o piso — uma célula vazia é, ela própria, informação.
- Um export automático recorrente: a lista explícita de colunas. Deriva de forma (campo adicionado a montante, chave renomeada) deve falhar com barulho, não produzir em silêncio uma tabela que ninguém percebe estar errada.
A terceira opção é a mais negligenciada. Em automação, "a conversão teve sucesso" e "a conversão está correta" são duas afirmações diferentes — e a lista explícita de colunas é o único mecanismo que as separa.
Quando uma chave contém um ponto, os caminhos de achatamento colidem
O achatamento em notação de ponto transforma {"a": {"b": 2}} na coluna a.b. Mas e se a chave em si contiver um ponto? Testado com pandas:
import pandas as pd
d = [{"a.b": 1, "a": {"b": 2}}]
pd.json_normalize(d, sep=".")
# Apenas uma coluna sobrevive: ['a.b'] — duas chaves totalmente diferentes colidem no mesmo caminho
A chave literal a.b e o caminho aninhado a → b produzem o mesmo nome de coluna, um sobrescreve o outro, e os dados são corrompidos em silêncio. É um exemplo construído, mas acontece com dados reais: nomes de métricas de monitoramento (cpu.usage) e campos de log (error.type) adoram pontos.
Dois remédios:
- Trocar o separador.
sep="_"ou um separador personalizado, depois de verificar que as chaves originais nunca o contêm. - Trocar a notação de caminhos. JSON Pointer usa
/como separador (/a/b); o conversor Excel do toolvore segue esse caminho. Ele tem a mesma classe de ambiguidade — uma chave com/deve ser escrita como~1— mas as regras de escape são normalizadas na RFC 6901 em vez de inventadas por cada ferramenta.
Qualquer que seja a escolha, escreva a regra na documentação do export. O cabeçalho CSV é o único "esquema" que esses dados carregam; o leitor não encontra suas regras de achatamento em lugar nenhum.
Arrays: três estratégias, cada uma perdendo algo diferente
Arrays são o verdadeiro obstáculo do JSON para CSV: uma célula não pode conter vários valores. Três estratégias, com perdas verificadas na prática:
| Estratégia | Forma de saída | O que se perde | Ideal para |
|---|---|---|---|
| JSON-na-célula | O array serializado como string JSON na célula | Ferramentas de tabela não conseguem filtrar nem agregar os elementos | Fidelidade primeiro, inspeção manual |
| Colunas indexadas | tags.0, tags.1, uma coluna cada |
O número de colunas explode com o array mais longo; muitos vazios | Arrays pequenos de comprimento fixo |
| Linhas explodidas | Uma linha por elemento, campos pai duplicados | Inflação de linhas; comportamento de arrays vazios precisa ser definido à parte | Análise de detalhe, agregação por elemento |
Nossos conversores /convert/csv e /convert/table usam JSON-na-célula. A entrada [{"orderId":"A001","items":[{"sku":"X1","qty":2},{"sku":"Y9","qty":1}]}] produz:
orderId,items
A001,"[{""sku"":""X1"",""qty"":2},{""sku"":""Y9"",""qty"":1}]"
Quando você precisa de linhas explodidas, o record_path do pandas faz isso direto:
orders = [{"orderId": "A001", "total": 99,
"items": [{"sku": "X1", "qty": 2}, {"sku": "Y9", "qty": 1}]}]
items = pd.json_normalize(orders, record_path=["items"],
meta=["orderId", "total"], sep=".")
# Duas linhas de detalhe; orderId e total duplicados em cada uma
Ao explodir há três armadilhas para decidir antes: explodir vários arrays de uma vez constrói um produto cartesiano; arrays vazios não geram linha nenhuma por padrão (sua lista de clientes perde pessoas); e campos pai duplicados exigem deduplicação quando você agrega por orderId a jusante. O guia do convertcsvonline lista "decidir primeiro a granularidade" como primeiro passo exatamente por isso — fixada a granularidade, as três perguntas têm cada uma uma única resposta.
Testado: valores que saem mudados da conversão
Um teste de fidelidade executado no nosso conversor real (Node v22.13.1 + papaparse, a mesma implementação do /convert/csv):
1. null, ausência e string vazia viram todos células vazias. Entrada [{"a":null},{"b":""},{"c":1}] — as três células saem vazias. O CSV não tem três estados, só "há caracteres" e "não há caracteres". Depois de um round-trip, os três se leem como ""; a semântica original é irrecuperável.
2. Objetos e arrays aninhados viram strings JSON. As aspas são duplicadas conforme a RFC 4180 ({""name"":""张三""}), e ao colar a célula em qualquer parser JSON o valor volta — por isso o JSON-na-célula preserva a fidelidade.
3. Um inteiro de 20 dígitos perde precisão já no parse — e a culpa não é do CSV. Entrada "id": 9007199254740993; no CSV já está 9007199254740992. O JSON.parse do JavaScript usa doubles IEEE 754: a perda acontece no parsing, antes de qualquer conversor CSV. O json.load do Python preserva integralmente. Campos tipo identificador (IDs snowflake, números de pedido) deveriam chegar como strings desde o produtor. Mais sobre precisão numérica em por que números grandes perdem precisão.
4. Zeros à esquerda em strings estão seguros na camada de texto do CSV. "phone": "00123" continua 00123 no CSV, porque é string do começo ao fim e nada o arranha. Quem arranha os zeros é a etapa posterior em que o Excel trata a célula como número — veja quatro formas de converter JSON em Excel.
5. Campos com vírgulas, aspas ou quebras de linha. O papaparse segue a RFC 4180: citar o campo, duplicar as aspas internas. "He said \"hi\", ok" sai como "He said ""hi"", ok" e sobrevive intacto ao round-trip. Serializadores escritos à mão que contornam uma biblioteca quase sempre falham exatamente aí.
Round-trip: o que o interruptor de inferência muda
CSV → JSON precisa adivinhar um tipo para cada célula. Nosso conversor desliga a inferência de tipos por padrão e devolve tudo como string:
[{ "id": "9007199254740992", "phone": "00123" }]
Com typeInference ativado, id vira número (a precisão já se foi), enquanto phone continua string — as regras de inferência excluem formas com zero à esquerda como 010, que não são literais numéricos legais, então a string sobrevive por sorte.
Grave essa assimetria: inferência desligada no pior caso custa tipos; inferência ligada pode custar dados. Colunas que precisam de números devem ser convertidas por definições explícitas de coluna, não confiadas a uma inferência global. A implementação rowsToRecords do nosso artigo anterior JSON, CSV e dados tabulares é exatamente esse princípio em código.
Escolher a estratégia conforme o destino: uma tabela de decisão
| Quem recebe o CSV | Estratégia de cabeçalho | Tratamento de arrays | Tipos |
|---|---|---|---|
| Um colega que abre uma vez | União de chaves | JSON-na-célula | Sem inferência |
| Excel / relatórios | União de chaves | JSON-na-célula, ou colunas indexadas se comprimento fixo | Colunas de ID forçadas como texto |
| Importação em banco de dados | Lista explícita de colunas | Tabelas mestre/detalhe separadas, ou explosão | Definições explícitas de coluna |
| BI / agregação | Lista explícita de colunas | Linhas explodidas, granularidade = menor unidade de análise | Definições explícitas de coluna |
| Pipeline automatizado | Lista explícita de colunas | JSON-na-célula, explosão a jusante | Inferência desligada, conversão coluna a coluna |
A lei comum: quanto mais perto do humano, mais se pode permitir afrouxar; quanto mais perto da máquina, mais tudo precisa ser explícito.
Uma checklist para copiar
- [ ] O cabeçalho vem de uma união completa de chaves ou de uma lista explícita? Se não, você consegue provar que nenhum registro perdeu campo?
- [ ] As chaves podem conter pontos ou barras? A colisão com o separador de achatamento foi verificada?
- [ ] A estratégia de arrays está documentada? O comportamento de arrays vazios está decidido?
- [ ] Para
null, ausência e string vazia: o que o destino lê de cada um? Escolha uma regra e congele-a. - [ ] Colunas tipo identificador (IDs, números de pedido, telefones) viajam como strings de ponta a ponta?
- [ ] Depois da explosão, o número de linhas é igual à soma dos comprimentos dos arrays? Verificado por amostragem?
- [ ] Campos de exemplo com vírgulas, aspas e quebras de linha sobrevivem intactos a um round-trip?
- [ ] A inferência global de tipos está desligada no reimport, com definições explícitas para colunas numéricas?
Só quando as oito têm resposta clara é que este CSV está "correto" — e não apenas "convertido com sucesso".
FAQ
Por que o conversor não achata objetos aninhados em colunas por padrão?
O achatamento faz o número de colunas fugir ao controle: aninhamento profundo produz nomes como user.address.geo.lat, e combinado com a união de chaves, um único registro de outra forma adiciona uma pilha de colunas. Strings JSON nas células preservam a fidelidade e um número estável de colunas; achate explicitamente quando precisar.
E se o cabeçalho contiver nomes de coluna duplicados?
A colisão de duas chaves diferentes após o achatamento (como a.b contra o aninhado a→b) deve ser resolvida a montante: troque o separador ou passe para JSON Pointer. Para duplicatas dentro do mesmo objeto, os conversores em geral guardam o último valor; duplicatas no cabeçalho dependem da implementação — o unparse do papaparse não deduplica, e o Excel funde ou descarta duplicatas em silêncio ao abrir. É por isso que está na checklist.
Campos pai duplicados após a explosão são um bug?
Não — é design. Uma linha explodida é uma linha de detalhe, e uma linha de detalhe deve poder ser religada ao registro pai por chave. Agregue a jusante pela chave pai; se o tamanho preocupa, exporte o arquivo de detalhe e uma tabela mestre separada.
Como verificar que minha conversão não perdeu nenhum campo?
Faça uma auditoria de chaves: calcule a união de todas as chaves do JSON de origem, depois o conjunto de cabeçalhos de coluna do CSV, e faça a diferença. Uma diferença não vazia significa campos perdidos. Em pipelines em lote, codifique isso como uma asserção nos seus testes: qualquer troca de conversor que comece a perder campos falha automaticamente.