Achatar JSON aninhado para CSV sem perder dados
Todo conversor de JSON para CSV toma uma dúzia de decisões não documentadas por você. Estas são as decisões.
Cada afirmação desta página foi medida ou tem fonte. Quando não é nem uma coisa nem outra, a página diz isso.
Passe isto por praticamente qualquer conversor:
[
{ "id": 1, "name": "Ada", "tags": ["admin"] },
{ "id": 2, "name": "Grace", "tags": ["admin", "ops"], "team": { "name": "core" } }
]
Muitos te devolvem três colunas: id, name, tags. O objeto team sumiu. Não foi truncado, não foi sinalizado, só está ausente, porque o conversor leu as chaves do primeiro objeto e tratou aquilo como o schema.
CSV é um retângulo: um conjunto fixo de colunas, um escalar por célula. JSON é uma árvore com chaves opcionais, profundidade arbitrária e arrays em qualquer lugar. Não existe mapeamento correto entre os dois, só um conjunto de políticas, e os conversores que parecem simples são os que escolheram as políticas por você sem avisar.
Descoberta de colunas: união, não a primeira linha
Há duas formas de decidir quais são as colunas. Percorrer todas as linhas e juntar a união dos caminhos folha, ou ler um objeto e pegar as chaves dele.
A segunda não é otimização de desempenho, é perda de dados com uma desculpa plausível. O Papa Parse tira os campos das chaves do primeiro objeto, a menos que você passe uma opção columns explícita:
Papa.unparse([{ a: 1 }, { a: 2, b: 3 }]);
// "a\r\n1\r\n2" a coluna b nunca existiu
Papa.unparse(rows, { columns: ['a', 'b'] });
// a união é você quem fornece
O json_normalize do pandas pega a união, o que é um dos motivos de as pessoas recorrerem a ele. O custo é que um documento esparso produz uma tabela larga e quase vazia, que é a representação honesta de um documento esparso. Se você quer menos colunas, descarte-as de propósito.
Streaming torna isso genuinamente difícil: com NDJSON você não conhece o conjunto de colunas até a última linha, então ou você bufferiza o arquivo ou faz duas passadas.
Objetos aninhados e o separador que você precisa expor
Objetos aninhados achatam para caminhos com ponto, então {"team": {"name": "core"}} vira team.name. O pandas usa . por padrão e deixa você mudar:
pd.json_normalize({"user": {"name": {"first": "Ada"}}})
# coluna: user.name.first
pd.json_normalize(data, sep="__")
# coluna: user__name__first
O separador tem que ser configurável, porque o ponto é um caractere legal numa chave JSON. Estes dois documentos achatam para a mesma coluna:
{ "a": { "b": 1 } }
{ "a.b": 1 }
Assim que colidem, a viagem de volta vira adivinhação, e um conversor que deixa um sobrescrever o outro em silêncio produziu um arquivo que se reconstrói na forma errada. Ou escolha um separador ausente das suas chaves, ou escape ele onde aparecer dentro de uma. Não presuma que pontos nunca aparecem em chaves: em payloads de eventos e analytics eles aparecem o tempo todo.
Arrays: quatro políticas, um padrão sensato
É aqui que os conversores mais divergem.
| Política | Saída para tags: ["admin","ops"] |
O que custa |
|---|---|---|
| Colunas indexadas | tags.0 = admin, tags.1 = ops |
A contagem de colunas é definida pelo array mais longo do arquivo. Uma linha com 400 tags dá 400 colunas a todas as linhas |
| Juntar numa célula | tags = admin,ops |
Quebra assim que um valor contém o caractere de junção, e [] e [""] ficam idênticos |
| JSON numa célula | tags = ["admin","ops"] |
Feio, exige aspas corretas, sobrevive à ida e volta exatamente |
| Explodir em linhas | Duas linhas, os outros campos repetidos | A contagem de linhas deixa de bater com a de registros, então agregados sobre as outras colunas contam em dobro |
Colunas indexadas servem bem para aridade pequena e fixa: um par lat/long, um trio RGB. Para qualquer coisa ilimitada, a contagem de colunas é decidida pela sua pior linha e não pela típica.
Juntar é o padrão mais comum e o pior, com perda em três direções ao mesmo tempo: o delimitador pode ocorrer nos dados, array vazio e array de string vazia colapsam juntos, e objetos aninhados acabam virando texto de qualquer jeito.
JSON numa célula é o padrão certo para arrays que você não vai explodir, porque é a única política exatamente reversível. A célula é colocada entre aspas conforme a RFC 4180 com as aspas internas duplicadas, e qualquer leitor que saiba que a coluna carrega JSON faz o parsing de volta direto. Fica mais feio no Excel e está correto.
Explodir é o certo quando o array é o assunto: itens de um pedido, eventos de uma sessão. É isso que o record_path faz:
import pandas as pd
data = [
{"id": 1, "name": "Ada", "orders": [{"sku": "A1", "qty": 2}]},
{"id": 2, "name": "Grace", "orders": [{"sku": "B7", "qty": 1},
{"sku": "C3", "qty": 5}]},
]
pd.json_normalize(data, record_path="orders", meta=["id", "name"])
# sku qty id name
# 0 A1 2 1 Ada
# 1 B7 1 2 Grace
# 2 C3 5 2 Grace
record_path nomeia o array que vira linhas e meta nomeia os campos do pai copiados em cada uma. Repare no que acontece com um registro cujo array orders está vazio: ele não produz linha nenhuma e some por completo. Você também recebe um array por passada, já que dois arrays irmãos exigiriam um produto cartesiano, então faça uma passada por array e junte pelo id.
Arrays heterogêneos
Um array cujos objetos têm chaves diferentes é o mesmo problema da união um nível abaixo. Com colunas indexadas, [{"a":1},{"b":2}] dá items.0.a e items.1.b, duas colunas nunca preenchidas juntas, com o conjunto de colunas agora dependendo da posição do elemento. Com explosão dá duas linhas com as colunas a e b, o que é melhor porque a posição deixa de fazer parte da identidade. Arrays que misturam escalares e objetos não têm forma retangular nenhuma; serialize como JSON numa célula.
Valores sem equivalente em CSV
CSV tem um tipo só: texto. Todo o resto é convenção.
null versus string vazia. JSON distingue os dois, CSV não: ,, e ,"", são o mesmo valor para a maioria dos leitores, então a ida e volta funde um no outro. Se isso importa, escreva um sentinela como \N (a convenção do COPY do Postgres), ou aceite que os nulos voltam como strings vazias e diga isso.
Booleanos. true e false em minúsculas é a grafia do JSON e sobrevive. O Excel exibe TRUE/FALSE e algumas ferramentas emitem 1/0, e qualquer um dos dois precisa de um mapeamento explícito na volta.
Números. Uma string JSON contendo 007 é lida pelo Excel como 7, e 1E5 vira 100000. As aspas do CSV não impedem isso, já que o Excel adivinha o tipo depois de tirar as aspas. Inteiros grandes batem no limite de precisão se qualquer elo da cadeia os fizer passar por um float, então emita o texto de origem do número tal e qual.
Datas. JSON não tem tipo data; strings ISO 8601 ou RFC 3339 são a convenção. O Excel converte uma string com cara de data como 2026-03-04 num valor de data e reexibe no formato local da máquina, e formatos ambíguos como 03/04/2026 podem voltar como um dia completamente diferente, então nunca deixe uma planilha ser uma escala intermediária.
Mecânica de CSV que morde
A RFC 4180 é curta e vale a pena seguir. Campos contendo vírgula, aspa dupla ou quebra de linha precisam de aspas; uma aspa dupla literal dentro de um campo entre aspas é escrita duas vezes; os fins de linha são CRLF. Quebras de linha embutidas num campo entre aspas são legais e um monte de leitores de CSV ainda erra nisso, então se as suas strings contêm quebras de linha, teste o consumidor primeiro.
O delimitador nem sempre é vírgula. O Excel, numa localidade em que o separador decimal é a vírgula, espera arquivos separados por ponto e vírgula, e é por isso que um CSV válido abre como uma coluna só na máquina de um colega. Ofereça uma configuração de delimitador, ou envie a primeira linha sep=; que o Excel entende.
Depois vem a marca de ordem de bytes: o Excel lê um CSV em UTF-8 como UTF-8 só se o arquivo começar com uma, e sem ela os caracteres acentuados são decodificados com a página de código do sistema e ficam destroçados. Esses três bytes são ruído para toda outra ferramenta, então faça do BOM um interruptor e ligue-o no caminho do Excel.
Injeção de CSV
Se o primeiro caractere de uma célula é =, +, - ou @, o Excel, o Google Sheets e o LibreOffice tratam a célula como fórmula e a avaliam na abertura. A OWASP chama isso de injeção de CSV. Algumas orientações acrescentam tabulação e retorno de carro à lista de gatilhos.
Você não escapa disso com aspas: o leitor remove as aspas da RFC 4180 antes de a fórmula ser avaliada. Então, se alguma string do seu JSON veio de um usuário e chega a um CSV que alguém abre, você entregou a um atacante uma fórmula rodando num contexto confiável. Fórmulas podem buscar URLs remotas, o que significa que as células vizinhas podem sair do prédio.
A mitigação é neutralizar o caractere inicial na hora de escrever a célula:
const RISKY = /^[=+\-@\t\r]/;
function safeCell(value) {
const s = String(value);
return RISKY.test(s) ? "'" + s : s;
}
O apóstrofo força o Excel a tratar o conteúdo como texto. Não sai de graça: para um leitor que não é planilha, ele agora faz parte do dado, então a ida e volta quebra para esses valores. Prefixar é certo para arquivos que uma pessoa abre numa planilha e errado para arquivos que uma máquina lê de volta, o que faz disso um interruptor por exportação em vez de um padrão escondido.
O caminho de volta
CSV para JSON tem uma armadilha grande: a inferência de tipos. Todo valor do arquivo é texto, então o conversor adivinha quais são números e erra de forma previsível. 007 vira 7, 1E5 vira 100000, 1.0 vira 1. CEPs, números de peça, telefones e strings de versão morrem todos pela mesma regra. O padrão seguro é emitir todo valor como string e deixar quem chama converter o que sabe, com a inferência habilitada por coluna em vez de uma heurística para o arquivo inteiro. A ferramenta CSV para JSON torna esse interruptor explícito exatamente por isso.
Uma política padrão que vale declarar
| Decisão | Padrão | Por quê |
|---|---|---|
| Colunas | União de todos os caminhos folha, ordenada | Amostrar o primeiro objeto descarta campos sem aviso |
| Objetos aninhados | Caminho com ponto, separador configurável | Chaves podem legalmente conter o separador |
| Arrays | JSON numa célula | A única política reversível. Exploda quando o array é o registro |
| Contêineres vazios | [] e {} literalmente |
Distinguíveis de null e de string vazia |
| Nulos | Célula vazia, documentado | Ou um sentinela onde a distinção sustenta peso |
| Números | Texto de origem tal e qual | Nunca passe por um float na saída |
| Fins de linha | CRLF | RFC 4180, e LF quebra mais leitores que CRLF |
| BOM | Desligado, com interruptor para Excel | Certo para pipelines, errado para Excel, então deixe o usuário dizer qual |
| Caracteres de fórmula | Prefixados só no caminho do Excel | Prefixar muda o dado, então não deve acontecer em silêncio |
Essas não são as únicas respostas defensáveis. A questão é que todo conversor toma as nove decisões, avisando ou não, e só dá para confiar nos que avisam quando o payload é grande demais para conferir no olho. O achatador mostra o conjunto de caminhos antes de você se comprometer, e a visão de tabela mostra o retângulo que você está prestes a receber.