JOINs: juntando tabelas
A normalização espalhou os dados por várias tabelas de propósito. O JOIN é como remontá-los na hora da consulta — e é a operação em que a maioria dos bugs sutis de relatório nasce. Não porque a sintaxe seja difícil, mas porque é fácil não perceber quais linhas você acabou de descartar, ou multiplicar.
A pergunta é o que fazer com quem não tem par
Imagine duas listas: os clientes cadastrados e os pedidos feitos. Juntá-las é fácil enquanto todo pedido tem um cliente e todo cliente tem um pedido. A questão interessante são as sobras: o cliente que nunca comprou, e o pedido cujo cliente foi apagado.
É só isso que diferencia os tipos de JOIN. INNER descarta as duas sobras. LEFT preserva as sobras da esquerda, preenchendo com NULL. RIGHT preserva as da direita. FULL preserva as duas. E CROSS nem tenta casar: combina todo mundo com todo mundo.
Os cinco tipos, e como o banco os executa
Na primeira aba, troque o tipo de JOIN e veja quais linhas de cada tabela sobrevivem e onde aparecem NULLs — repare no cliente sem pedidos e no pedido órfão. Na segunda, compare o custo dos três algoritmos de execução conforme as tabelas crescem.
A normalização espalhou os dados por várias tabelas. O JOIN é como remontá-los — e o tipo de join decide o que acontece com as linhas que não encontram par.
SELECT c.nome, p.id, p.valor FROM clientes c INNER JOIN pedidos p ON p.cliente_id = c.id
Os tipos, e a pegadinha do LEFT JOIN
-- INNER JOIN: só o que casa dos dois lados (o padrão) SELECT c.nome, p.valor FROM clientes c JOIN pedidos p ON p.cliente_id = c.id; -- LEFT JOIN: tudo da esquerda; NULL onde não casou SELECT c.nome, p.valor FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id; -- O truque do "anti-join": quem NÃO tem correspondência. -- LEFT JOIN + IS NULL responde "clientes que nunca compraram". SELECT c.nome FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id WHERE p.id IS NULL; -- ⚠ A pegadinha mais comum com LEFT JOIN: -- pôr a condição da tabela da direita no WHERE em vez do ON. -- ✗ Isto vira um INNER JOIN disfarçado: as linhas sem pedido -- têm p.valor = NULL, e "NULL > 100" nunca é verdadeiro. FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id WHERE p.valor > 100 -- ✓ Assim as linhas sem pedido sobrevivem, com NULL: FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id AND p.valor > 100 -- Regra: no LEFT JOIN, filtro sobre a tabela da DIREITA vai no ON. -- No WHERE, ele descarta as linhas que o LEFT JOIN preservou.
Como o banco executa um JOIN
-- O SQL diz O QUE. O otimizador escolhe COMO. -- Para o mesmo JOIN, há três estratégias principais: -- 1. NESTED LOOP — O(n × m) -- para cada linha da esquerda, varre a direita -- Bom quando: uma tabela é pequena, OU existe índice do lado -- interno (aí cada varredura vira uma busca: O(n · log m)) -- 2. HASH JOIN — O(n + m) -- fase 1 (build): hash table da tabela MENOR, na memória -- fase 2 (probe): varre a maior, consultando a hash -- Bom quando: tabelas grandes, join por IGUALDADE, sem índice útil -- Limite: a tabela menor precisa caber na memória de trabalho. -- Se não couber, o banco particiona em disco (grace hash join) -- e o custo dispara — é o que se vê como "spill to disk" no EXPLAIN. -- 3. MERGE JOIN — O(n + m), se já ordenados -- dois ponteiros avançando em paralelo, como no merge sort -- Bom quando: as entradas já vêm ordenadas (de um índice) -- Usa pouquíssima memória e funciona com <, >, não só com = -- Você não escolhe. Mas vê a escolha no plano: EXPLAIN ANALYZE SELECT ... FROM a JOIN b ON ...; -- Hash Join (cost=... rows=... actual time=...) -- -> Seq Scan on b -- -> Hash -- -> Seq Scan on a
ANALYZE. Quando uma consulta fica misteriosamente lenta
depois de uma carga grande de dados, a causa mais comum não é o SQL: são estatísticas desatualizadas
levando o otimizador a estimar mil linhas onde há dez milhões.
O problema N+1
# O problema N+1: o antipadrão mais caro que existe em ORMs. # ✗ 1 consulta para os pedidos + 1 POR PEDIDO para o cliente. # Com 1000 pedidos, são 1001 idas ao banco. Cada uma paga a # latência de rede inteira — o tempo total é dominado por ela, # não pelo trabalho do banco. pedidos = Pedido.objects.all() # 1 consulta for p in pedidos: print(p.cliente.nome) # +1 consulta CADA # ✓ Uma consulta só, com JOIN, resolvida pelo banco: pedidos = Pedido.objects.select_related("cliente") for p in pedidos: print(p.cliente.nome) # zero consultas extras # Equivalentes: prefetch_related (Django, para muitos-para-muitos), # joinedload (SQLAlchemy), include (Prisma), with (Laravel). # # O sintoma clássico: a página está rápida em desenvolvimento # (banco local, latência ~0) e lentíssima em produção (banco em # outra máquina, 2 ms por ida = 2 segundos em 1000 pedidos). # Ligue o log de SQL do ORM em desenvolvimento e conte as consultas.
Quando o JOIN multiplica linhas
-- A armadilha silenciosa: JOIN que MULTIPLICA linhas. -- Um pedido com 3 itens. Este JOIN devolve 3 linhas por pedido: SELECT p.id, p.frete, i.produto FROM pedidos p JOIN itens i ON i.pedido_id = p.id; -- 1 | 20 | Teclado -- 1 | 20 | Mouse ← o frete de 20 aparece 3 vezes -- 1 | 20 | Monitor -- ✗ Então SOMAR o frete aqui conta 3 vezes o mesmo valor: SELECT SUM(p.frete) FROM pedidos p JOIN itens i ... -- ERRADO -- ✓ Agregue ANTES de juntar (numa CTE), e junte os resultados: WITH por_pedido AS ( SELECT pedido_id, SUM(preco) AS total_itens FROM itens GROUP BY pedido_id ) SELECT SUM(p.frete), SUM(t.total_itens) FROM pedidos p JOIN por_pedido t ON t.pedido_id = p.id; -- Sintoma para reconhecer em produção: um total que cresce -- quando você adiciona um JOIN que "só ia trazer uma coluna". -- Regra: antes de agregar, pergunte qual é a granularidade -- de UMA linha do resultado. Se não for o que você acha, some errado.
Juntando de verdade
Mini projeto: volte ao banco da biblioteca da lição 12.1 e responda só com JOINs: quais livros estão emprestados agora e para quem; quais leitores nunca pegaram nada (anti-join); quantos exemplares cada livro tem; e quais livros nunca foram emprestados. Confira cada resultado contando à mão numa base pequena — especialmente os anti-joins.
Projeto principal: reproduza o problema N+1 e meça-o. Crie 1000 pedidos com clientes num SQLite ou Postgres local, escreva a versão em laço e a versão com JOIN, e cronometre as duas. Depois repita com o banco em outra máquina, ou simule latência (num Postgres local, um proxy simples que atrase 2 ms por consulta já basta). O objetivo é ver com os próprios olhos que a diferença não é de 10%, é de duas ordens de grandeza — e que ela só aparece quando há latência.
Desafio extra: implemente os três algoritmos de join em Python sobre listas de dicionários — nested loop, hash join e merge join — e meça-os com tabelas de 10 mil e 100 mil linhas. Confirme na prática o que a teoria diz: o nested loop cresce quadraticamente, o hash join ganha em tabelas grandes, e o merge join só compensa se os dados já vierem ordenados (inclua o custo da ordenação e veja o resultado mudar).
Teste sua intuição
Onde você encontra isso
O N+1 que derruba APIs
É provavelmente a causa nº 1 de endpoints lentos em aplicações com ORM. GraphQL amplifica o problema — cada campo aninhado pode virar uma consulta —, e é exatamente por isso que existe o padrão DataLoader: ele acumula os ids pedidos dentro de um mesmo tick do event loop e faz uma consulta só, em lote. É o mesmo remédio do select_related, num lugar diferente.
Star schema em data warehouses
Bancos analíticos organizam os dados numa tabela de fatos gigante (vendas) cercada por tabelas de dimensão pequenas (produto, loja, tempo, cliente). Toda consulta é um JOIN da fato com algumas dimensões — e os motores são otimizados justamente para esse formato, com técnicas como star join e filtros bitmap que descartam a maior parte da tabela de fatos antes de lê-la.
EXPLAIN: a ferramenta que separa palpite de diagnóstico
Todo banco tem um comando que mostra o plano de execução escolhido: EXPLAIN ANALYZE no PostgreSQL, EXPLAIN FORMAT=JSON no MySQL. É ali que se vê qual algoritmo de join foi usado, quantas linhas o otimizador estimou versus quantas realmente apareceram, e se alguma etapa vazou para disco. Uma estimativa muito distante do real é quase sempre a explicação de um plano ruim.