Modelagem e normalização
Tabelas, chaves e formas normais, e quando desnormalizar de propósito.
O problema: o modelo dura mais que o código
O código de uma aplicação é reescrito, refatorado, trocado de framework. As tabelas ficam. Mudar uma coluna numa tabela com milhões de linhas exige uma migração cuidadosa, e os dados ruins que entraram por um modelo frouxo continuam lá, esperando a próxima consulta que não sabe tratá-los.
A modelagem relacional decide três coisas: quais tabelas existem, quais colunas cada uma tem e como elas se ligam.
Tabelas, linhas e chaves
Cada tabela representa um tipo de coisa: clientes, produtos, pedidos. Cada linha é uma ocorrência. E cada tabela tem uma chave primária, a coluna (ou o conjunto de colunas) que identifica a linha sem ambiguidade.
As ligações entre tabelas são feitas por chaves estrangeiras: a coluna pedidos.cliente_id aponta para clientes.id, e o banco recusa um pedido de um cliente que não existe.
Os relacionamentos têm três formas:
| Relacionamento | Exemplo | Como se modela |
|---|---|---|
| Um para muitos | Um cliente tem vários pedidos | A chave estrangeira fica no lado "muitos": pedidos.cliente_id |
| Muitos para muitos | Um pedido tem vários produtos; um produto está em vários pedidos | Uma tabela no meio: itens_pedido (pedido_id, produto_id, quantidade) |
| Um para um | Um cliente tem um endereço de cobrança | A chave estrangeira com UNIQUE, ou as colunas na mesma tabela |
A tabela do meio, nos muitos para muitos, costuma ganhar vida própria: itens_pedido não guarda só os dois identificadores, mas a quantidade e o preço daquele item naquele pedido.
Normalização: cada fato num lugar só
Imagine uma tabela de pedidos que guarda, em cada linha, o nome e o e-mail do cliente:
| pedido | cliente_email | cliente_nome | total |
|---|---|---|---|
| 1 | ana@exemplo.com | Ana | 120.00 |
| 2 | ana@exemplo.com | Ana | 80.00 |
| 3 | ana@exemplo.com | Ana Souza | 45.00 |
O nome da Ana aparece três vezes, e já está diferente numa delas. Qual é o certo? Para mudar o e-mail dela, é preciso atualizar todas as linhas, e uma atualização que esquece uma linha deixa o banco contraditório. Esses são os problemas de atualização que a normalização evita.
Normalizar é organizar as tabelas para que cada fato fique num lugar só. As formas normais formalizam isso. Na prática, as três primeiras resolvem quase tudo:
| Forma normal | Regra | Violação típica |
|---|---|---|
| 1ª | Cada coluna guarda um valor só | telefones = "1199..., 1198..." numa coluna de texto |
| 2ª | Em chaves compostas, cada coluna depende da chave inteira | itens_pedido com o nome do produto, que depende só de produto_id |
| 3ª | Colunas não dependem de outras colunas que não são a chave | pedidos com o nome do cliente, que depende de cliente_id |
A frase que resume as três: cada coluna depende da chave, da chave inteira e de nada além da chave.
Desnormalizar de propósito
Normalizar não é dogma. Às vezes, repetir um dado é exatamente o certo:
- O preço na hora da compra.
itens_pedido.preco_unitariorepete o preço do produto, mas não é redundância: o preço do produto muda amanhã, e o pedido de hoje precisa continuar com o valor cobrado. São dois fatos diferentes, que hoje coincidem. - Totais para leitura rápida. Guardar
pedidos.totalevita somar os itens a cada listagem, ao custo de manter o total certo sempre que um item muda. - Cópias para relatórios. Tabelas desnormalizadas, montadas a partir das normalizadas, para consultas analíticas.
A diferença entre desnormalizar e bagunçar é saber qual é a fonte da verdade e quem mantém a cópia atualizada.
As regras ficam no banco
As restrições do banco (NOT NULL, UNIQUE, CHECK, FOREIGN KEY) garantem as regras para qualquer código que escreva na tabela: a aplicação de hoje, o script de amanhã, a correção manual feita às pressas. A tentação é conferir na aplicação ("já existe um cliente com esse e-mail?") e pular a restrição. Medimos o que acontece com 20 cadastros simultâneos do mesmo e-mail, escrito ora em minúsculas, ora com maiúsculas, todos passando pela conferência antes de gravar:
| Tabela | Cadastros criados | Linhas com o mesmo e-mail |
|---|---|---|
| Só a conferência na aplicação | 18 | 18 |
Conferência e índice único em lower(email) | 1 | 1 |
Com a conferência sozinha, 18 dos 20 pedidos consultaram a tabela antes de qualquer outro gravar, não acharam nada e gravaram. Com o índice único, o banco recusou 16 e a conferência barrou 3. A conferência na aplicação continua útil para dar uma mensagem amigável, mas quem garante a regra é o banco. O índice em lower(email) também resolve a diferença entre Ana@Exemplo.com e ana@exemplo.com, que um UNIQUE (email) simples deixaria passar.
Os tipos certos
Três escolhas de tipo que custam caro quando erradas, medidas no Postgres 18:
Dinheiro não é ponto flutuante.
| Conta | double precision | real | numeric |
|---|---|---|---|
| Somar 0,1 dez vezes | 0,9999999999999999 | — | 1,0 |
| 0,1 + 0,2 = 0,3? | falso | — | verdadeiro |
| 19,99 × 3 | 59,97 | 59,96999931335449 | 59,97 |
Ponto flutuante representa números em base 2, e 0,1 não tem representação exata. Os erros aparecem em somas e comparações. Para dinheiro, use numeric(12, 2) ou um inteiro em centavos.
Datas precisam do fuso. Gravamos now() numa coluna timestamp e noutra timestamptz, com a sessão em America/Sao_Paulo, e lemos com a sessão em UTC. A coluna timestamptz mostrou 04:48 em UTC, o instante correto. A coluna timestamp mostrou 01:48, o horário de Brasília guardado sem fuso, agora interpretado como se fosse UTC: três horas de erro, sem nenhum aviso. Use timestamptz.
O tipo da chave primária pesa. Inserimos 2 milhões de linhas em três tabelas iguais, mudando só a chave:
| Chave primária | Tempo de inserção | Tamanho do índice |
|---|---|---|
bigint sequencial | 3,8 s | 43 MB |
| UUID v4 (aleatório) | 8,9 s | 76 MB |
| UUID v7 (ordenado pelo tempo) | 5,0 s | 60 MB |
O UUID v4 é aleatório: cada linha nova cai num ponto qualquer do índice, que se divide em páginas meio vazias. O UUID v7 começa pelo carimbo de tempo, e as linhas novas vão para o fim do índice, como num sequencial. Os números são de uma inserção em lote numa máquina de desenvolvimento, e servem para comparar os três tipos entre si, não como medida absoluta.
A chave estrangeira precisa de índice
O Postgres cria um índice para a chave primária e para UNIQUE, mas não para a coluna da chave estrangeira. Isso pesa ao apagar ou atualizar a linha apontada: para cada cliente apagado, o banco precisa achar os pedidos dele.
Com 100 mil clientes e 2 milhões de pedidos, apagar 100 clientes (com ON DELETE CASCADE) levou:
pedidos.cliente_id | Tempo |
|---|---|
| Sem índice | 10,0 s |
| Com índice | 5 ms |
Sem o índice, cada cliente apagado gerou uma varredura completa da tabela de pedidos. O mesmo índice serve para a consulta mais comum desse modelo: "os pedidos deste cliente".
Por que isso importa quando a IA escreve o código
Um assistente que cria as tabelas de um sistema costuma gerar um modelo que funciona nos testes e envelhece mal:
- Nenhuma restrição além da chave primária: sem
UNIQUE, semCHECK, às vezes sem chave estrangeira. - Unicidade conferida só na aplicação.
floatoudoublepara dinheiro,timestampsem fuso para datas.- Chaves estrangeiras sem índice.
- Dados copiados entre tabelas sem uma fonte da verdade, ou o oposto: o preço do item lido do produto, mudando pedidos antigos.
- Listas guardadas como texto separado por vírgulas, ou um
jsonbno lugar de tabelas.
O próximo nível compara as decisões de modelagem uma a uma: chaves, exclusão, histórico, tipos e quando desnormalizar.
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Modelagem relacional em cinco minutos
Explique em no máximo cinco minutos de leitura o que é modelar um banco relacional, para alguém que já escreveu consultas SQL, mas nunca desenhou as tabelas de um sistema. Cubra só o essencial: tabelas e chaves, os três tipos de relacionamento, o que a normalização evita, por que as regras ficam no banco e quando repetir um dado é o certo. Use o exemplo de uma loja com clientes, produtos e pedidos. No final, me faça três perguntas curtas para eu verificar se entendi, e corrija as minhas respostas.
Formas normais na prática
Quero entender as formas normais sem decorar definições. Aja como um mentor e vá por etapas, esperando a minha resposta em cada uma: 1. Me mostre uma tabela única de pedidos com tudo dentro e peça que eu aponte os problemas de atualização. 2. A 1ª forma normal, com um exemplo de violação para eu corrigir. 3. A 2ª forma normal, com uma chave composta. 4. A 3ª forma normal, com uma dependência entre colunas. 5. Um caso em que repetir o dado é o certo, e por quê. Em cada etapa, me deixe propor o novo desenho antes de mostrar o seu.
Restrições do banco contra validação na aplicação
Quero entender por que as regras de integridade devem ficar no banco, mesmo com validação na aplicação. Explique: 1. NOT NULL, UNIQUE, CHECK e FOREIGN KEY, com um exemplo de cada. 2. Por que conferir "já existe?" antes de gravar não impede duplicatas com pedidos simultâneos. 3. Índices únicos com expressão, como lower(email), e índices parciais. 4. Como a aplicação traduz a recusa do banco numa resposta útil. 5. O que acontece com uma transação depois de uma recusa, e para que servem os savepoints. Depois me dê três regras de negócio e pergunte como eu as garantiria no banco.
Tipos de dados que custam caro
Quero entender as escolhas de tipo que mais causam problemas em bancos relacionais. Explique, com um exemplo do erro e da correção: 1. Dinheiro em ponto flutuante. 2. Datas sem fuso horário. 3. UUID aleatório como chave primária, comparado com bigint e UUID v7. 4. Listas guardadas como texto separado por vírgulas. 5. jsonb usado no lugar de tabelas. Para cada um, me faça uma pergunta para eu prever o problema antes de você explicar.