PostgreSQL JSONB vs tabelas normalizadas: como decidir e migrar
PostgreSQL JSONB vs tabelas normalizadas: um quadro prático para escolher em protótipos e um caminho de migração seguro à medida que a app escala.

O problema real: mover-se rápido sem prender-se
Requisitos que mudam toda semana são normais quando está a construir algo novo. Um cliente pede mais um campo. Vendas quer um fluxo diferente. Suporte precisa de um rasto de auditoria. A sua base de dados acaba por carregar o peso de todas essas mudanças.
Iterar rápido não é só entregar ecrãs mais depressa. Significa que pode adicionar, renomear e remover campos sem partir relatórios, integrações ou registos antigos. Também significa que consegue responder a novas perguntas ("Quantas encomendas tiveram notas de entrega em falta no mês passado?") sem transformar cada query numa script pontual.
Por isso a escolha entre JSONB e tabelas normalizadas importa logo cedo. Ambos funcionam, e ambos podem causar dor quando usados para o trabalho errado. JSONB parece liberdade porque pode guardar quase tudo hoje. Tabelas normalizadas parecem mais seguras porque impõem estrutura. O objetivo real é alinhar o modelo de armazenamento com o grau de incerteza dos seus dados agora e a rapidez com que precisam de se tornar fiáveis.
Quando equipas escolhem o modelo errado, os sintomas costumam ser óbvios:
- Perguntas simples tornam-se queries lentas e desorganizadas ou código personalizado.
- Dois registos representam a mesma coisa mas usam nomes de campos diferentes.
- Campos opcionais tornam-se obrigatórios mais tarde e dados antigos não batem certo.
- Não consegue impor regras (valores únicos, relações obrigatórias) sem soluções alternativas.
- Relatórios e exports continuam a partir após pequenas mudanças.
A decisão prática é esta: onde precisa de flexibilidade (e pode tolerar inconsistência por um tempo) e onde precisa de estrutura (porque os dados movem dinheiro, operações ou compliance)?
JSONB e tabelas normalizadas, explicadas de forma simples
PostgreSQL pode armazenar dados em colunas clássicas (text, number, date). Também pode guardar um documento JSON inteiro dentro de uma coluna usando JSONB. A diferença não é “novo vs velho”. É o que quer que a base de dados garanta.
JSONB armazena chaves, valores, arrays e objetos aninhados. Não garante automaticamente que cada linha tenha as mesmas chaves, que os valores tenham sempre o mesmo tipo, ou que um item referenciado exista noutra tabela. Pode adicionar verificações, mas tem de as decidir e implementar.
Tabelas normalizadas significam dividir dados em tabelas separadas pelo que cada coisa é e ligá-las com IDs. Um cliente está numa tabela, uma encomenda noutra, e cada encomenda aponta para um cliente. Isto dá-lhe proteção mais forte contra contradições.
No dia-a-dia, as trocas são diretas:
- JSONB: flexível por defeito, fácil de mudar, mais propenso a deriva.
- Tabelas normalizadas: mais deliberadas para mudar, mais fáceis de validar, mais fáceis de consultar de forma consistente.
Um exemplo simples são campos personalizados de um ticket de suporte. Com JSONB, pode adicionar um novo campo amanhã sem migração. Com tabelas normalizadas, adicionar um campo é mais intencional, mas relatórios e regras ficam mais claros.
Quando JSONB é a ferramenta certa para iteração rápida
JSONB é uma boa escolha quando o maior risco é construir a forma errada de dados, não impor regras estritas. Se o seu produto ainda está a encontrar o seu fluxo, forçar tudo para tabelas fixas pode atrasá-lo com migrações constantes.
Um bom sinal é quando os campos mudam semanalmente. Pense num formulário de onboarding onde marketing continua a adicionar perguntas, renomear labels e remover passos. JSONB permite guardar cada submissão tal como ela é, mesmo que a versão de amanhã seja diferente.
JSONB também encaixa bem para “incógnitas”: dados que não entende totalmente ainda, ou dados que não controla. Se recebe payloads de webhooks de parceiros, guardar o payload bruto em JSONB permite suportar novos campos imediatamente e decidir mais tarde o que deve tornar-se colunas de primeira classe.
Usos comuns em fase inicial incluem formulários que mudam rápido, captura de eventos e logs de auditoria, definições por cliente, feature flags e experiências. É especialmente útil quando escreve principalmente os dados, os lê de forma integral e a forma ainda está em movimento.
Uma salvaguarda ajuda mais do que as pessoas esperam: mantenha uma nota curta e partilhada das chaves que está a usar para não acabar com cinco grafias diferentes do mesmo campo entre linhas.
Quando tabelas normalizadas são a escolha mais segura a longo prazo
Tabelas normalizadas vencem quando os dados deixam de ser “só para esta funcionalidade” e passam a ser partilhados, consultados e confiáveis. Se as pessoas vão fatiar e filtrar registos de muitas formas (status, responsável, região, período), colunas e relações tornam o comportamento previsível e mais fácil de otimizar.
A normalização também importa quando regras têm de ser impostas pela base de dados, não por código de aplicação “por boa vontade”. JSONB pode guardar qualquer coisa, que é precisamente o problema quando precisa de garantias fortes.
Sinais para normalizar agora
Normalmente é hora de afastar-se de um modelo JSON-first quando vários destes são verdadeiros:
- Precisa de reporting consistente e dashboards.
- Precisa de constraints como campos obrigatórios, valores únicos ou relações com outras entidades.
- Mais do que um serviço ou equipa lê e escreve os mesmos dados.
- Queries começam a escanear muitas linhas porque não conseguem usar índices simples bem.
- Está num ambiente regulado ou auditado e as regras têm de ser prováveis.
Performance é um ponto de viragem comum. Com JSONB, filtrar muitas vezes significa extrair valores repetidamente. Pode indexar caminhos JSON, mas os requisitos tendem a crescer para um mosaico de índices difícil de manter.
Um exemplo concreto
Um protótipo guarda “pedidos de cliente” como JSONB porque cada tipo de pedido tem campos diferentes. Mais tarde, operações precisa de uma fila filtrada por prioridade e SLA. Finance precisa de totais por departamento. Suporte precisa de garantir que cada pedido tem um customer ID e um status. É aí que tabelas normalizadas brilham: colunas claras para campos comuns, chaves estrangeiras para clientes e equipas, e constraints que impedem dados inválidos de entrar.
Um quadro de decisão simples que pode usar em 30 minutos
Não precisa de um grande debate sobre teoria de bases de dados. Precisa de uma resposta rápida e escrita a uma pergunta: onde é que a flexibilidade vale mais do que a estrutura rígida?
Faça isto com as pessoas que constroem e usam o sistema (construtor, operações, suporte e talvez finanças). O objetivo não é escolher um vencedor único. É escolher o ajuste certo por parte do produto.
A checklist de 5 passos
-
Liste as 10 telas mais importantes e as perguntas exatas por trás delas. Exemplos: “abrir um registo de cliente”, “encontrar encomendas em atraso”, “exportar pagamentos do mês passado”. Se não consegue nomear a pergunta, não consegue desenhar para ela.
-
Destaque campos que têm de estar corretos sempre. Estas são regras fortes: status, montantes, datas, propriedade, permissões. Se um valor errado custaria dinheiro ou desencadearia um incidente de suporte, geralmente pertence a colunas normais com constraints.
-
Marque o que muda frequentemente vs raramente. Mudanças semanais (novas perguntas de formulário, detalhes específicos de parceiros) são fortes candidatos para JSONB. Campos “core” que raramente mudam tendem a ser normalizados.
-
Decida o que tem de ser pesquisável, filtrável ou ordenável na UI. Se os utilizadores filtram constantemente por isso, normalmente é melhor como coluna de primeira classe (ou um caminho JSONB cuidadosamente indexado).
-
Escolha um modelo por área. Uma divisão comum é tabelas normalizadas para entidades e fluxos core, mais JSONB para extras e metadados que mudam rápido.
Noções básicas de performance sem se perder nos detalhes
Velocidade vem geralmente de uma coisa: tornar as suas perguntas mais comuns baratas de responder. Isso importa mais do que ideologia.
Se usar JSONB, mantenha-o pequeno e previsível. Alguns campos extras são aceitáveis. Um blob gigante e em constante mudança é difícil de indexar e fácil de usar mal. Se sabe que uma chave vai existir (como "priority" ou "source"), mantenha o nome da chave consistente e o tipo de valor consistente.
Índices não são mágicos. Eles trocam leituras mais rápidas por escritas mais lentas e mais disco. Índice apenas o que filtra ou junta com frequência, e apenas na forma que realmente consulta.
Regras práticas para indexação
- Coloque índices btree normais em filtros comuns como status, owner_id, created_at, updated_at.
- Use um índice GIN numa coluna JSONB quando pesquisa dentro dela frequentemente.
- Prefira índices de expressão para um ou dois campos JSON quentes (como (meta->>'priority')) em vez de indexar o JSONB todo.
- Use índices parciais quando só um segmento importa (por exemplo, apenas linhas onde status = 'open').
Evite armazenar números e datas como strings dentro do JSONB. "10" ordena antes de "2", e matemática de datas fica incómoda. Use tipos numéricos e timestamps reais em colunas, ou pelo menos armazene números JSON como números.
Um modelo híbrido muitas vezes vence: campos core em colunas, extras flexíveis em JSONB. Exemplo: uma tabela de operações com id, status, owner_id, created_at como colunas, mais meta JSONB para respostas opcionais.
Erros comuns que criam dor mais tarde
JSONB pode parecer liberdade no início. A dor costuma aparecer meses depois, quando mais pessoas mexem nos dados e “o que funcionar” transforma-se em “não conseguimos mudar isto sem partir algo”.
Estes padrões causam a maior parte do trabalho de limpeza:
- Tratar o JSONB como um despejo. Se cada equipa armazena formas ligeiramente diferentes, acaba por escrever lógica de parsing personalizada em todo o lado. Defina convenções básicas: nomes de chaves consistentes, formatos claros de data e um pequeno campo de versão dentro do JSON.
- Esconder entidades core dentro do JSONB. Guardar clientes, encomendas ou permissões apenas como blobs parece simples no início, depois joins tornam-se incómodos, constraints são difíceis de aplicar e duplicados aparecem. Mantenha o quem/o quê/quando em colunas e coloque detalhes opcionais em JSONB.
- Esperar pela migração até ser urgente. Se não acompanhar que chaves existem, como mudaram e quais são “oficiais”, a primeira migração real torna-se arriscada.
- Assumir que JSONB automaticamente significa flexibilidade e velocidade. Flexibilidade sem regras é apenas inconsistência. A velocidade depende dos padrões de acesso e índices.
- Partir a análise ao mudar chaves ao longo do tempo. Renomear status para state, trocar números por strings ou misturar fusos horários vai arruinar relatórios silenciosamente.
Um exemplo concreto: uma equipa começa com uma tabela de tickets e um campo details JSONB para respostas de formulários. Mais tarde, finanças quer cortes semanais por categoria, operações quer tracking de SLA, e suporte quer dashboards “abertos por equipa”. Se categorias e timestamps andarem à deriva entre chaves e formatos, cada relatório vira uma query especial.
Um plano de migração quando o protótipo vira crítico
Quando um protótipo começa a gerir salários, inventário ou suporte ao cliente, “vamos arranjar os dados depois” deixa de ser aceitável. O caminho mais seguro é migrar em passos pequenos, com os dados JSONB antigos a funcionar enquanto a nova estrutura se prova.
Uma abordagem faseada evita um rewrite arriscado de uma só vez:
- Design primeiro o destino. Escreva as tabelas alvo, as chaves primárias e regras de nomenclatura. Decida o que é uma entidade real (Customer, Ticket, Order) e o que fica flexível (notas, atributos opcionais).
- Construa novas tabelas ao lado dos dados antigos. Mantenha a coluna JSONB, adicione tabelas normalizadas e índices em paralelo.
- Backfill em lotes e valide. Copie campos JSONB para as novas tabelas em chunkes. Valide com contagens de linhas, campos obrigatórios not null e verificações pontuais.
- Mude leituras antes de escritas. Atualize queries e relatórios para ler das novas tabelas primeiro. Quando as saídas coincidirem, comece a escrever novas alterações nas tabelas normalizadas.
- Trave tudo. Pare de escrever para o JSONB, depois elimine ou congele campos antigos. Adicione constraints (foreign keys, regras de unicidade) para que dados maus não voltem.
Antes do corte final:
- Corra ambos os caminhos durante uma semana (antigo vs novo) e compare as saídas.
- Monitorize queries lentas e adicione índices onde necessário.
- Prepare um plano de rollback (feature flag ou switch de configuração).
- Comunique o tempo exato de troca de escrita à equipa.
Verificações rápidas antes de se comprometer
Antes de fechar a sua abordagem, faça um reality check. Estas perguntas apanham a maioria dos problemas futuros enquanto a mudança ainda é barata.
Cinco perguntas que decidem a maior parte do resultado
- Precisamos de unicidade, campos obrigatórios ou tipos estritos agora (ou na próxima release)?
- Quais campos têm de ser filtráveis e ordenáveis para os utilizadores (pesquisa, status, proprietário, datas)?
- Precisamos de dashboards, exports ou relatórios para enviar a finanças/operações em breve?
- Conseguimos explicar o modelo de dados a um novo colega em 10 minutos, sem rodeios?
- Qual é o nosso plano de rollback se uma migração partir um fluxo?
Se responder “sim” às primeiras três, já está a inclinar-se para tabelas normalizadas (ou pelo menos um híbrido: campos core normalizados, atributos long-tail em JSONB). Se o único “sim” for o último, o problema maior é processo, não esquema.
Uma regra prática simples
Use JSONB quando a forma dos dados ainda não estiver clara, mas consiga nomear um pequeno conjunto de campos estáveis de que sempre vai precisar (como id, owner, status, created_at). No momento em que as pessoas dependem de filtros consistentes, exports fiáveis ou validação estrita, o custo da “flexibilidade” sobe rápido.
Exemplo: de um formulário flexível a um sistema de operações fiável
Imagine um formulário de entrada de suporte ao cliente que muda semanalmente. Uma semana adiciona-se “modelo do dispositivo”, na outra adiciona-se “motivo do reembolso”, depois renomeia-se “priority” para “urgency”. No início, guardar o payload do formulário numa única coluna JSONB parece perfeito. Pode lançar mudanças sem migração e ninguém reclama.
Três meses depois, os gestores querem filtros como “urgency = high and device model starts with iPhone”, SLAs baseados no tier do cliente e um relatório semanal que tem de casar com os números da semana passada.
O modo de falhar é previsível: alguém pergunta “Onde foi esse campo?” Registos antigos usaram um nome de chave diferente, o tipo de valor mudou ("3" vs 3), ou o campo nunca existiu para metade dos tickets. Os relatórios viram um mosaico de casos especiais.
Um meio-termo prático é um design híbrido: mantenha campos estáveis e críticos para o negócio como colunas reais (created_at, customer_id, status, urgency, sla_due_at) e reserve uma área JSONB para campos novos ou raros que ainda mudam.
Uma timeline de baixa disrupção que funciona bem:
- Semana 1: Escolha 5 a 10 campos que têm de ser filtráveis e reportáveis. Adicione colunas.
- Semana 2: Faça backfill dessas colunas a partir do JSONB para registos recentes primeiro, depois os mais antigos.
- Semana 3: Atualize escritas para que novos registos povoem tanto colunas como o JSONB (double-write temporário).
- Semana 4: Mude leituras e relatórios para as colunas. Mantenha o JSONB só para extras.
Próximos passos: decidir, documentar e continuar a entregar
Se não fizer nada, a decisão é tomada por si. O protótipo cresce, as bordas endurecem e cada mudança começa a parecer arriscada. Um movimento melhor é tomar uma pequena decisão por escrito agora e continuar a construir.
Liste as 5 a 10 perguntas que a sua app tem de responder rapidamente ("Mostrar todas as encomendas abertas deste cliente", "Encontrar utilizadores por email", "Reportar receita por mês"). Ao lado de cada uma, escreva as constraints que não pode quebrar (email único, status obrigatório, totais válidos). Depois trace uma fronteira clara: mantenha JSONB para campos que mudam frequentemente e raramente são filtrados ou unidos, e promova para colunas e tabelas tudo aquilo que pesquisa, ordena, junta ou tem de validar sempre.
Se está a usar uma plataforma no-code que gera aplicações reais, esta separação pode ser mais fácil de gerir ao longo do tempo. Por exemplo, AppMaster (appmaster.io) permite modelar tabelas PostgreSQL visualmente e regenerar o backend e apps subjacentes à medida que os requisitos mudam, o que torna alterações iterativas de esquema e migrações planeadas menos penosas.
FAQ
Use JSONB quando a forma dos dados muda frequentemente e você normalmente só armazena e recupera o payload, como formulários que mudam rápido, webhooks de parceiros, feature flags ou definições por cliente. Mantenha um pequeno conjunto de campos estáveis como colunas normais para poder filtrar e reportar de forma confiável.
Normalize quando os dados são partilhados, consultados de muitas formas ou têm de ser confiáveis por omissão. Se precisa de campos obrigatórios, valores únicos, chaves estrangeiras ou dashboards/exports consistentes, tabelas com colunas claras e constraints normalmente poupam tempo mais tarde.
Sim — um híbrido costuma ser o padrão mais sensato: coloque campos críticos para o negócio em colunas e relações, e mantenha atributos opcionais ou que mudam rápido numa coluna JSONB “meta”. Assim os relatórios e regras ficam estáveis enquanto ainda permite iterar sobre campos long-tail.
Pergunte-se o que os utilizadores precisam filtrar, ordenar e exportar na UI e o que tem de estar correto sempre (dinheiro, estado, propriedade, permissões, datas). Se um campo é frequentemente usado em listas, dashboards ou joins, promova-o para uma coluna real; mantenha extras raramente usados em JSONB.
Os maiores riscos são nomes de chaves inconsistentes, tipos de valor misturados e mudanças silenciosas ao longo do tempo que estragam a análise. Previna isto usando chaves consistentes, mantendo o JSONB pequeno, armazenando números/datas em tipos apropriados (ou como números JSON) e adicionando um campo de versão simples dentro do JSON.
Pode ser, mas exige trabalho extra. JSONB não impõe estrutura por padrão, por isso vai precisar de verificações explícitas, indexação cuidadosa dos caminhos que consulta e convenções fortes. Esquemas normalizados tendem a tornar essas garantias mais simples e visíveis.
Indexe apenas aquilo que realmente consulta. Use índices btree normais para colunas comuns como status e timestamps; para JSONB, prefira índices de expressão em chaves quentes (por exemplo, extraindo um único campo) em vez de indexar todo o documento, salvo se pesquisa verdadeiramente por muitas chaves.
Procure queries lentas e desorganizadas, scans frequentes e um conjunto crescente de scripts one-off só para responder a questões simples. Outros sinais são várias equipas a escrever as mesmas chaves JSON de forma diferente e a necessidade crescente de constraints estritas ou exports estáveis.
Projete as tabelas de destino primeiro, execute-as em paralelo com os dados JSONB, faça backfill em lotes, valide os resultados, altere as leituras para as novas tabelas, depois as escritas, e finalmente bloqueie com constraints para evitar que dados ruins voltem.
Modele as suas entidades centrais (customers, orders, tickets) como tabelas com colunas claras para os campos que as pessoas filtram e reportam, depois acrescente uma coluna JSONB para extras flexíveis. Ferramentas como AppMaster ajudam porque pode atualizar o modelo PostgreSQL visualmente e regenerar backend e apps à medida que os requisitos mudam.


