Erros Comuns no Design de Bancos de Dados Relacionais
Evite erros comuns de design em bancos relacionais no Postgres e MySQL: restrições, chaves, índices e armazenamento correto de timestamps.
A maioria dos erros em esquemas relacionais se resume a um único equívoco: confiar na aplicação para garantir o que o banco de dados deveria assegurar. Uma chave estrangeira que você “trata na camada de serviço,” uma regra de unicidade verificada com um SELECT antes do INSERT, um campo de status validado pelo ORM mas não pela coluna — cada um desses casos representa uma restrição que foi removida do mecanismo projetado para aplicá-la e transferida para um código que só é executado quando você se lembra de chamá-lo. O resultado é um dado que o esquema jamais deveria ter permitido: registros órfãos, contas duplicadas, preços negativos, timestamps que ninguém consegue interpretar. Este guia percorre os erros recorrentes de design que desenvolvedores de aplicações cometem no Postgres e no MySQL, explica por que cada um causa problemas e apresenta a solução — com um pequeno trecho de código “antes e depois” para cada caso. Os exemplos utilizam a sintaxe moderna do Postgres 18 quando relevante, mas os princípios se aplicam a qualquer mecanismo relacional.
Principais Conclusões
- Aplique integridade no banco de dados com as restrições
FOREIGN KEY,NOT NULL,CHECKeUNIQUE; a validação feita apenas na aplicação só é executada quando o código se lembra de chamá-la, e um ORM que permite declarar um relacionamento sem uma chave estrangeira no nível do banco de dados está adiando um bug de integridade para a produção. - Use uma chave primária substituta (
bigintidentity ou um UUID comuuidv7()) e adicione uma restriçãoUNIQUEseparada para a chave natural, de modo que uma mudança de nome ou de e-mail seja um simplesUPDATE, não uma migração. - A partir do PostgreSQL 18 (lançado em 25 de setembro de 2025), você não precisa mais de uma extensão para identificadores ordenados por tempo:
id uuid PRIMARY KEY DEFAULT uuidv7()oferece localidade de índice próxima à de umbigint, mantendo a unicidade global. - Armazene timestamps como
timestamptz, não comotimestamp, e indexe as colunas de chave estrangeira e de filtro nas quais você realmente faz joins — o Postgres não indexa automaticamente o lado referenciador de uma chave estrangeira. - Trate o esquema como código vivo: versione-o com migrações, prefira exclusões lógicas (
deleted_at timestamptz) quando outros registros referenciam um dado, e documente colunas comCOMMENT ON COLUMN.
A raiz da maioria dos erros de design em bancos de dados relacionais: regras no código da aplicação, não no esquema
O erro de maior impacto é aplicar regras de dados apenas no código da aplicação. Uma restrição na sua camada de serviço protege exatamente um caminho de código; uma restrição no esquema protege todos os caminhos — sua API, um job em background, uma sessão manual no psql, uma migração de dados mal executada e o próximo serviço que alguém escrever contra o mesmo banco de dados. Quando a regra existe apenas na aplicação, o primeiro processo que a contornar corromperá a tabela permanentemente.
Esta é a armadilha dos ORMs modernos. Ferramentas como Prisma e Drizzle modelam relacionamentos no código da aplicação sem nunca emitir uma chave estrangeira no nível do banco de dados, e um @default ou um schema Zod parece uma validação. Não é. Um ORM que permite declarar um relacionamento sem uma chave estrangeira no banco de dados não está poupando trabalho; está adiando um bug de integridade de dados para a produção, onde a reprodução de sessão — e não o seu conjunto de testes — é o que finalmente o detecta, pois a falha não se manifesta como um stack trace. Os dados corrompidos surgem como uma avaliação atribuída a um usuário que não existe mais, ou como um pedido duplicado — uma tela confusa que o usuário realmente vê.
A solução é mover a regra para onde ela não pode ser ignorada. As restrições de verificação do PostgreSQL, por exemplo, rejeitam dados inválidos independentemente de qual cliente os escreveu: para exigir preços positivos em produtos, você pode usar uma restrição CHECK (price > 0) na definição da tabela.
-- Antes: nada impede um pagamento negativo ou um e-mail NULL
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint, -- sem FK: registros órfãos à espreita
amount numeric, -- sem CHECK: valores negativos permitidos
email text -- sem NOT NULL, sem UNIQUE
);
-- Depois: o mecanismo garante as invariantes
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
amount numeric NOT NULL CHECK (amount > 0),
email text NOT NULL UNIQUE
);
Uma chave estrangeira não é um custo de desempenho — é a única barreira entre você e registros órfãos que se renderizam como uma avaliação de um usuário que não existe mais. A partir do Postgres 18, você também pode adicionar restrições gradualmente em tabelas com alto volume de escrita: o ALTER TABLE agora suporta o atributo NOT VALID para restrições NOT NULL, permitindo que você adicione a restrição sem uma varredura imediata na tabela inteira e a valide posteriormente com um lock mais fraco.
Discover how at OpenReplay.com.
Ignorar a normalização — ou aplicá-la em excesso
Normalização é a prática de armazenar cada fato uma única vez. A primeira forma normal (1FN) significa ausência de grupos repetidos e de campos com múltiplos valores; a terceira forma normal (3FN) significa que cada coluna não-chave depende da chave e somente dela. Para esquemas de aplicação, a 3FN é o padrão sensato — consulte a documentação de definição de dados do Postgres para os detalhes técnicos. As duas violações clássicas são valores separados por vírgula em uma única coluna e colunas repetidas como payment_1 … payment_12.
Valores separados por vírgula em uma única coluna é uma desnormalização da qual você se arrependerá na primeira vez que precisar escrever WHERE … LIKE '%,42,%'; a solução é uma tabela filha ou uma tabela de junção, não uma consulta de string mais elaborada.
-- Antes: tags como string delimitada, números de telefone como colunas
CREATE TABLE users (
id bigint PRIMARY KEY,
tags text, -- 'admin,beta,vip' — impossível de consultar, sem restrições
phone1 text, phone2 text, phone3 text -- grupo repetido, que eventualmente se esgota
);
-- Depois: uma tabela de junção para tags, uma tabela filha para telefones
CREATE TABLE user_tags (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
tag text NOT NULL,
PRIMARY KEY (user_id, tag)
);
CREATE TABLE user_phones (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
phone text NOT NULL
);
O erro inverso é a normalização excessiva: dividir um único campo address em seis tabelas com joins, ou modelar um relacionamento um-para-um como duas tabelas sem nenhuma justificativa. Cada join extra representa custo de consulta e de compreensão. Normalize até que cada fato exista uma única vez e, então, pare.
Usar campos de negócio como chaves primárias
Não use um campo de negócio como email, username ou company_name como chave primária; use uma chave substituta e adicione uma restrição UNIQUE separada para o valor natural, de modo que uma mudança de nome ou de endereço seja um UPDATE, não uma migração. Valores de negócio mudam, e quando uma chave primária muda, todas as chaves estrangeiras que a referenciam precisam ser propagadas em cascata — um valor que nunca deveria ter sido uma identidade se torna uma migração em todo o esquema.
O conselho de 2007 de evitar chaves substitutas está desatualizado para desenvolvedores de aplicações. O padrão moderno é uma chave primária substituta combinada com uma restrição UNIQUE real na chave natural — você obtém uma identidade estável e a garantia de unicidade.
CREATE TABLE companies (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- identidade substituta estável
name text NOT NULL UNIQUE -- chave natural ainda aplicada
);
UUID vs bigint, resolvido
A partir do Postgres 18, você não precisa mais de uma extensão para identificadores ordenados por tempo: o Postgres 18 adiciona a função uuidv7() de geração de UUID, cujo valor é ordenável temporalmente, além de um alias uuidv4() para gerar explicitamente UUIDs versão 4. O UUIDv7 — definido na RFC 9562 (maio de 2024, que torna obsoleta a RFC 4122) — incorpora um prefixo de timestamp, de modo que novos registros são inseridos à direita do índice em vez de se espalharem aleatoriamente como os UUIDs v4. Observe que, para versões mais antigas do Postgres, gen_random_uuid() (v4) faz parte do núcleo desde a versão 13; a extensão pgcrypto não é mais necessária.
| Tipo de chave | Armazenamento | Localidade do índice | Unicidade global | Observações |
|---|---|---|---|---|
bigint identity | 8 bytes | Excelente (sequencial) | Não | Menor e mais rápido; expõe contagem de registros; requer alocação centralizada |
uuidv4() | 16 bytes | Ruim (inserções aleatórias) | Sim | Adequado para sistemas distribuídos; fragmentação de índice sob alta escrita |
uuidv7() | 16 bytes | Bom (ordenado por tempo) | Sim | Ordenável por tempo; expõe o momento de criação; padrão recomendado no PG18 |
Use bigint quando os IDs ficam dentro de um único banco de dados, e uuidv7() quando você gera IDs no lado do cliente ou entre serviços.
Ausência de uma estratégia deliberada de indexação
Indexe as colunas que você realmente filtra e usa em joins — especialmente chaves estrangeiras, que o Postgres não indexa automaticamente — mas evite indexar todas as colunas, pois cada índice representa amplificação de escrita em cada inserção e atualização. Esta é a regressão de desempenho mais comum em esquemas de aplicação, e é fácil errar em ambas as direções.
A armadilha é específica do Postgres: ele cria índice automaticamente no lado referenciado (pai) de uma chave estrangeira, mas não na coluna referenciadora (filho). Como a indexação das colunas referenciadores nem sempre é necessária e há muitas opções de indexação disponíveis, declarar uma restrição de chave estrangeira não cria automaticamente um índice nas colunas referenciadores. (O InnoDB do MySQL cria automaticamente — portanto, esta armadilha é específica do Postgres.)
-- payments.user_id é uma FK mas não está indexada: todo join e todo
-- DELETE em users realiza uma varredura sequencial em payments
CREATE INDEX ON payments (user_id);
Indexe as colunas de FK e de filtro; evite índices em colunas de baixa seletividade e em tabelas que raramente são consultadas por aquele campo.
Uma tabela fazendo muitos trabalhos
Uma única tabela forçada a modelar múltiplos domínios — a tabela genérica de entidade-atributo-valor (EAV), ou uma tabela notifications que armazena alertas, eventos de auditoria e logs de sistema — troca clareza de esquema por uma falsa flexibilidade. Você perde segurança de tipos, não consegue aplicar restrições significativas, e cada leitura se torna uma bagunça filtrada e com auto-joins. Use tabelas separadas por domínio para que cada uma tenha suas próprias colunas, tipos e chaves estrangeiras.
-- Antes: um balde de tudo, tipado como text, sem restrições reais
CREATE TABLE entity_attributes (
entity_id bigint, attr_name text, attr_value text
);
-- Depois: tabelas reais com colunas e restrições reais
CREATE TABLE user_alerts (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
level text NOT NULL, body text);
CREATE TABLE audit_events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_id bigint REFERENCES users(id),
action text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now());
Se você realmente precisar de atributos flexíveis, use uma coluna jsonb tipada na tabela real — não um EAV tipado como string que anula todas as restrições que o mecanismo oferece.
Nomenclatura inconsistente
Escolha uma convenção de nomenclatura e aplique-a em todo lugar. Para o Postgres e a maioria dos ORMs, isso significa:
snake_case(identificadores sem aspas são convertidos para minúsculas);- nomes de tabelas no plural ou singular, definidos uma única vez;
created_at/updated_atpara timestamps;- sem metadados nos nomes (
tbl_users,col_varchar_address); - sem espaços ou aspas; e
- sem palavras reservadas como
user,orderougroupcomo identificadores sem aspas.
A nomenclatura é o aspecto mais barato de acertar no momento da criação da tabela e o mais caro de alterar depois que dados e consultas dependem dela.
Tratar o esquema como definitivo
Um esquema é código vivo, não um artefato criado uma única vez. Gerencie cada alteração por meio de migrações versionadas, revisadas e reversíveis — a ferramenta de migração do seu ORM (Prisma Migrate, Drizzle Kit) ou um executor independente — para que o histórico do esquema seja reproduzível entre ambientes.
O histórico também determina como você exclui dados. Prefira uma exclusão lógica (um campo deleted_at timestamptz) em vez de um DELETE físico quando outros registros referenciam o dado, pois um DELETE físico ou gera registros órfãos ou, com ON DELETE CASCADE, apaga silenciosamente um histórico que você poderá ser solicitado a apresentar mais tarde.
ALTER TABLE orders ADD COLUMN deleted_at timestamptz;
-- "Pedidos ativos" passa a ser um filtro, não uma operação destrutiva
CREATE VIEW active_orders AS SELECT * FROM orders WHERE deleted_at IS NULL;
Armazenar datas e horas sem fuso horário
Armazene timestamps como timestamptz, não como timestamp — uma coluna sem fuso horário registra silenciosamente o que quer que o servidor considerasse “agora”, e essa ambiguidade se torna irrecuperável no momento em que seus servidores abrangem diferentes regiões. timestamptz armazena um ponto absoluto no tempo (normalizado para UTC) e o exibe no fuso horário da sessão; timestamp armazena uma leitura de relógio de parede sem nenhuma âncora. Consulte a documentação de data/hora do Postgres. Para agendamentos e reservas, o Postgres 18 também adicionou restrições temporais: ele suporta restrições PRIMARY KEY, UNIQUE e de chave estrangeira sem sobreposição, especificadas com WITHOUT OVERLAPS e PERIOD. (Chaves estrangeiras temporais não suportam ações de cascata ON DELETE/ON UPDATE, portanto, não dependa de cascatas nesse contexto.)
Ausência de documentação ou testes de esquema
Um esquema sem documentação e sem testes é um erro que se agrava silenciosamente. Valores mágicos são o sintoma clássico: um status_code de 1 a 3 que quebra no dia em que alguém adiciona o valor 4, sem nenhum registro do que cada número significa. As soluções são concretas:
- documente a intenção diretamente no banco de dados com
COMMENT ON COLUMN; - mantenha um diagrama ER no repositório; e
- teste as migrações da mesma forma que testa o código — aplique, reverta e valide contra entradas extremas e inválidas.
COMMENT ON COLUMN orders.status IS
'enum: pending|paid|shipped|cancelled — veja app/orders/status.ts';
Uma restrição CHECK (status IN (...)) combinada com um comentário transforma um inteiro frágil em uma coluna autodescritiva e autoaplicável.
Conclusão
Cada erro abordado aqui converge para a mesma solução: faça com que o banco de dados — não a aplicação, e não o ORM — seja a autoridade sobre quais dados são válidos. Adicione a chave estrangeira, o NOT NULL, o CHECK e a restrição UNIQUE antes de escrever a consulta que os pressupõe. Comece pela sua tabela com maior volume de escrita: liste as invariantes que você atualmente aplica no código e mova cada uma para o esquema, onde não podem ser ignoradas.
Perguntas Frequentes
Quando devo usar uma chave primária UUID em vez de uma coluna bigint identity?
Use bigint identity quando os IDs são gerados dentro de um único banco de dados e nunca precisam ser criados por clientes, pois ocupa 8 bytes, indexa sequencialmente e é a opção mais rápida. Use UUID, especificamente uuidv7() no Postgres 18, quando você gera IDs no lado do cliente ou entre múltiplos serviços e precisa de unicidade global. O UUIDv7 mantém localidade de índice quase sequencial porque incorpora um prefixo de timestamp, ao contrário do UUIDv4 aleatório.
Declarar uma chave estrangeira no meu ORM também cria uma restrição no nível do banco de dados?
Não necessariamente. Ferramentas como Prisma e Drizzle podem modelar um relacionamento no código da aplicação sem emitir uma restrição FOREIGN KEY no banco de dados, de modo que o relacionamento existe apenas no entendimento do ORM, não no mecanismo. Sem a restrição no banco de dados, um job em background, uma consulta manual ou outro serviço pode criar registros órfãos que o ORM jamais antecipou. Confirme que a sua migração realmente gera uma cláusula REFERENCES em vez de confiar apenas na declaração do relacionamento.
Qual é a diferença entre timestamp e timestamptz no PostgreSQL?
timestamptz armazena um ponto absoluto no tempo normalizado para UTC e o exibe no fuso horário da sessão, enquanto timestamp armazena uma leitura de relógio de parede sem âncora de fuso horário. Um timestamp simples registra silenciosamente o que o servidor considerava hora local, e essa ambiguidade se torna irrecuperável quando os servidores abrangem diferentes regiões. Use timestamptz para datas e horas na aplicação, de modo que um valor sempre se refira a um instante único e inequívoco, independentemente de onde seja lido.
Ainda preciso das extensões pgcrypto ou uuid-ossp para gerar UUIDs no Postgres?
Não. A função gen_random_uuid(), que produz um UUID versão 4, faz parte do núcleo do PostgreSQL desde a versão 13, portanto nenhuma extensão é necessária. A documentação do pgcrypto no PG18 agora marca sua própria gen_random_uuid() como obsoleta, pois ela passa a chamar a função nativa. A partir do Postgres 18, você também conta com uuidv7() integrado para identificadores ordenados por tempo e um alias uuidv4(), tudo sem instalar nenhuma extensão.