Organizando tabelas em camadas dentro do SGBD
O banco de dados relacional tradicional não impõe uma hierarquia rígida entre as tabelas, mas na prática você acaba construindo uma dependência em camadas todo dia. Tem o esquema lógico, o físico, as views, os triggers e as procedures que ficam no meio do caminho. A maioria das pessoas começa achando que só precisa de uma tabela principal e uma tabela cliente, e ai vem a primeira dor de cabeça quando precisa consultar seis junções seguidas. Eu já vi gente criar tabelas aninhadas dentro de colunas VARCHAR usando delimitadores. Tipo, gravar "101;joao;102;maria" direto numa coluna. Isso funciona quando o mundo tá calmo e você não precisa fazer query por um desses IDs. Quando precisa, você passa as próximas três horas quebrando a cabeça com SUBSTRING_INDEX ou PATINDEX dependendo do banco.
O que é hierarquia banco de dados na prática
Hierarquia banco de dados refere-se ao modelo em que registros se relacionam com seus próprios filhos, formando árvores. É diferente de árvore genealógica porque cada nó representa uma entidade do banco, não uma pessoa. O padrão mais usado é o closure table, mas tem também o nested sets e o parent_id direto na mesma tabela. Cada um tem um custo diferente em leitura e escrita. Aqui vai algo que poucos mencionam. A abordagem com parent_id parece a mais óbvia, mas ela exige queries recursivas. No PostgreSQL você usa CTEs recursivas, no SQL Server tem o RECURSIVE, e no MySQL a partir da versão 8.0 também tem suporte. Antes disso, você precisava de procedures ou subconsultas aninhadas com profundidade fixa, o que limitava a estrutura a uns quatro ou cinco níveis no máximo. Eu trabalhava num sistema onde a profundidade variava entre sete e doze níveis, e a query recursiva pura travava o servidor em horários de pico. A solução que funcinou foi criar uma view materializada que calculava todos os ancestrais a cada INSERT ou UPDATE, mantendo uma tabela auxiliar com caminho completo separado por barras. Isso reduziu o tempo médio de consulta de três segundos para dezenove milissegundos em média.
O nested sets é ainda menos intuitivo. Você asigna dois números a cada nó, left e right, e todo o subtree fica entre esses dois valores. Consultar descendentes vira uma simples comparação de intervalos. A desvantagem é que qualquer inserção ou movimentação de um nó exige atualizar centenas de registros vizinhos. Se sua árvore recebe atualizações frequentes, esquece esse modelo. Funciona bem apenas para dados que quase nunca mudam de lugar. O closure table mantém uma tabela separada que armazena todas as relações possíveis entre ancestrais e descendentes, incluindo a relação do nó consigo mesmo. Consultas ficam triviais. Inserções e deleções são mais caros, mas não catastróficos como no nested sets. Eu recomendo esse modelo para a maioria dos casos onde a profundidade varia muito e você precisa de performance consistente.
Também tem o materialized path, que guarda um caminho como string separada por delimitadores. Muito comum em CMSs. A desvantagem é que índices naturais não funcionam bem com strings compostas, e consultar por prefixo exige funções de substring que podem ignorar índices se não forem escritas direito. No PostgreSQL, usar ilike com operador de prefixo ^^ pode manter a indexação com B-tree, mas isso não vale a pena em tabelas muito grandes.
Implementando passo a passo
Vamos supor que você tenha uma tabela de departamentos com hierarquia de pais e filhos. Comece com a estrutura básica. Crie a tabela com parent_id como nullable, id como primary key, e nome do departamento.
👉 Clique no botão abaixo para saber mais sobre o assunto!
Adicione uma restrição de chave estrangeira apontando para a própria tabela. Isso garante integridade referencial. Sem isso, você pode ter departamentos apontando para pais que não existem, e a consulta recursiva retorna resultados errados silenciosamente. Erros silenchosos são piores que erros explícitos porque ninguém percebe até o relatório final dar errado. Se você for usar closure table, crie uma segunda tabela com ancestor, descendant e depth. O depth zero representa a relação do nó com ele mesmo. Sempre insira essa linha na hora do INSERT inicial. Isso evita aquela armadilha clássica onde a primeira consulta falha porque falta o registro autoreferencial.
Para consultas, use CTE recursivo no PostgreSQL. Defina o anchor com parent_id is null, que representa o topo da árvore, e depois una com a própria CTE usando o parent_id. Limite a recursão com MAXRECURSION se estiver no SQL Server. Sem o limite, um ciclo acidental entre registros pode travar a sessão indefinidamente. No MySQL 8.0+, a sintaxe é similar. Ative a variável cte_max_recursion_depth se a árvore tiver mais de mil níveis. Por padrão, o limite é cem, e você nem percebe que atingiu esse teto até ver o erro aparecer.
Problemas reais que aparecem depois
O primeiro problema que você encontra é migração de dados. Migrar uma árvore de mil categorias com milhares de filhos usando apenas parent_id é tedioso. Cada nó precisa ser inserido na ordem certa, do topo para a base. Se inserir fora de ordem, a chave estrangeira falha. Eu resolvi isso gerando um arquivo CSV com a ordem topológica correta, importando via programinha Python que lê linha por linha e faz INSERT com ON CONFLICT DO NOTHING no PostgreSQL. O segundo problema é consistência. Quando alguém move um nó de lugar, todas as relações no closure table precisam ser recalculadas. Um trigger AFTER UPDATE no parent_id resolve, mas triggers têm limitações em termos de performance e manutenção. Outro gatilho é manter o materialized path sincronizado também. Cada atualização do parent_id exige regenerar o caminho daquele nó e de todos os seus descendentes. Para árvores grandes, isso pode levar segundos. Eu implementei um procedimento armazenado que calcula tudo em lote, usando uma fila de processamento assíncrono. O usuário vê a mudança instantaneamente porque a UI lê a versão em cache, e o background processa a atualização dos caminhos em poucos segundos.
A terceira dor é backup e restore. Tabelas com hierarquia profunda e closure table duplicada podem triplicar o tamanho do dump. Em um projeto que mantive, o dump passava de cento e vinte megabytes para trezentos e trinta. A solução foi comprimir com pg_dump e zstd, reduzindo para cerca de quarenta e cinco megabytes no final.
Quando não usar hierarquia
Se sua estrutura é sempre fixa com no máximo três níveis, use colunas separadas como level1_id, level2_id, level3_id. Mais simples, mais rápido, menos manutenção. Hierarquia recursiva justifica-se quando a profundidade é imprevisível ou varia entre diferentes registros. Se você precisa de consultas complexas que cruzam múltiplos ramos da árvore ao mesmo tempo, considere uma modelagem graph com Neo4j ou uma aba special em PostgreSQL com extensions como postgresql-graph. Não é sobre hierarquia banco de dados no sentido tradicional, mas resolve o problema melhor quando o grafo é o foco principal.
A escolha do modelo depende do perfil de leitura versus escrita, da profundidade máxima esperada e da frequência de movimentação de nós. Não existe solução universal. Teste com dados reais do seu sistema antes de decidir. Dados sintéticos raramente revelam os gargalos que aparecem na produção. Se quiser um ponto de partida rápido para closure table com PostgreSQL, posso indicar o repositório com o script completo de criação de tabelas, triggers e funções auxiliares. Basta pedir nos comentários.