Por que resultados “inflados” acontecem em JOINs
Quando um relatório “explode” em número de linhas ou soma valores acima do esperado após um JOIN, quase sempre a causa é uma combinação de: (1) chaves mal definidas (ou usadas de forma incompleta), (2) cardinalidade diferente da assumida (por exemplo, você achava que era 1:1, mas é 1:N), e (3) duplicidades no lado que deveria ser único. O JOIN em si não “erra”: ele apenas combina linhas que satisfazem a condição. Se uma linha de uma tabela encontra várias correspondências na outra, ela será repetida uma vez para cada correspondência. Isso é correto do ponto de vista relacional, mas pode ser desastroso para métricas como receita, quantidade vendida, número de clientes e qualquer soma/contagem que você faça depois.
O objetivo deste capítulo é te dar um método prático para: identificar a cardinalidade real antes de juntar, validar unicidade de chaves, detectar duplicidades e escolher estratégias para evitar multiplicação de linhas e somas infladas.
Chaves: o que você precisa dominar para JOINs seguros
Chave primária, chave candidata e chave composta
Em análise de dados, você nem sempre controla o modelo, mas precisa entender o papel das chaves:
- Chave primária (PK): coluna(s) que identificam unicamente cada linha de uma tabela. Ex.:
customers.customer_id. - Chave candidata: qualquer conjunto de coluna(s) que também seria único, mesmo que não esteja declarado como PK (ex.:
emailpode ser candidato, mas nem sempre é confiável). - Chave composta: quando a unicidade depende de mais de uma coluna. Ex.: em uma tabela de itens de pedido, o identificador único pode ser (
order_id,line_number) ou (order_id,product_id) dependendo do sistema.
Um erro comum é juntar usando apenas parte de uma chave composta. Isso cria correspondências indevidas e multiplica linhas.
Chaves naturais vs. chaves substitutas (surrogate keys)
Você pode encontrar:
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
- Chaves naturais: têm significado de negócio (ex.: CPF, e-mail, código do produto). Podem mudar, ter formatação inconsistente ou duplicar.
- Chaves substitutas: IDs internos (ex.:
customer_id). Em geral, são mais estáveis para JOIN.
Para evitar resultados inflados, prefira chaves substitutas quando disponíveis. Se precisar usar chave natural, valide duplicidade e padronização (ex.: remover espaços, normalizar caixa) antes de confiar no JOIN.
Cardinalidade: a “matemática” por trás da multiplicação
O que é cardinalidade em JOIN
Cardinalidade descreve quantas linhas de um lado podem se relacionar com quantas do outro:
- 1:1: cada linha em A corresponde a no máximo uma em B, e vice-versa.
- 1:N: uma linha em A corresponde a várias em B.
- N:N: várias linhas em A correspondem a várias em B (normalmente via tabela de associação).
Resultados inflados acontecem quando você assume 1:1, mas na prática é 1:N ou N:N. Ex.: você junta orders com order_items e depois soma orders.order_total. Cada pedido aparece repetido para cada item, então o total do pedido é somado várias vezes.
Exemplo mental rápido: por que a soma infla
Imagine:
- Pedido 10 tem
order_total = 100 - Pedido 10 tem 3 itens em
order_items
Ao juntar pedidos com itens, o pedido 10 vira 3 linhas. Se você somar order_total após o JOIN, você obtém 300 para esse pedido, mesmo que o correto seja 100. O JOIN não está errado; a métrica é que está sendo calculada no nível de detalhe errado.
Duplicidades: quando o “lado 1” não é realmente 1
Mesmo que o relacionamento de negócio seja 1:N, você ainda pode ter inflação adicional quando a tabela do lado “1” tem duplicidades na chave que deveria ser única, ou quando a tabela do lado “N” tem duplicidades inesperadas (ex.: registros repetidos por reprocessamento, múltiplas versões, linhas de log).
Exemplos típicos de duplicidade que causam inflação:
- Dimensão com múltiplas linhas por entidade: tabela de clientes com histórico (SCD), onde um cliente aparece várias vezes por mudança de endereço.
- Tabela de preços com múltiplos preços ativos para o mesmo produto e data.
- Tabela de pagamentos com múltiplas tentativas, estornos e reprocessamentos sem um filtro claro de status.
- Chave natural suja: e-mails duplicados, CPFs com formatação diferente, códigos com zeros à esquerda inconsistentes.
Checklist prático antes de fazer JOIN (passo a passo)
Use este roteiro sempre que uma consulta envolver métricas agregadas (soma/contagem) após JOIN.
Passo 1 — Defina o “grão” (nível de detalhe) do resultado
Antes de escrever o JOIN, responda: “uma linha do meu resultado final representa o quê?” Exemplos:
- Uma linha por pedido
- Uma linha por cliente por mês
- Uma linha por produto por dia
Se você quer “uma linha por pedido”, então qualquer JOIN que traga tabelas no nível de item (order_items) precisa ser agregado de volta ao nível de pedido antes (ou depois, com cuidado) para não multiplicar.
Passo 2 — Verifique se a chave do lado esperado como único é realmente única
Para validar unicidade, conte ocorrências por chave. Se houver valores com contagem maior que 1, você tem duplicidade.
-- Verificando se customer_id é único em customers (deveria ser 1 linha por cliente) SELECT customer_id, COUNT(*) AS qtd FROM customers GROUP BY customer_id HAVING COUNT(*) > 1;Se aparecerem linhas, você precisa entender por quê: histórico? múltiplos cadastros? dados sujos? A estratégia de correção depende do caso (ver seções adiante).
Passo 3 — Meça a cardinalidade real do relacionamento
Uma forma prática é medir “quantas correspondências em média” cada chave encontra do outro lado.
-- Quantos itens por pedido? (esperado: 1:N) SELECT oi.order_id, COUNT(*) AS itens FROM order_items oi GROUP BY oi.order_id ORDER BY itens DESC;Para relações que você espera 1:1, qualquer valor acima de 1 é sinal de risco.
-- Você espera 1:1 entre orders e shipments (1 pedido, 1 envio). Confirme: SELECT s.order_id, COUNT(*) AS envios FROM shipments s GROUP BY s.order_id HAVING COUNT(*) > 1;Passo 4 — Faça um “JOIN de auditoria” e compare contagens
Antes de calcular métricas, compare:
- Linhas da tabela base
- Linhas após o JOIN
- Quantidade de chaves distintas antes/depois
-- Exemplo: base é orders (1 linha por pedido) SELECT COUNT(*) AS linhas_orders, COUNT(DISTINCT order_id) AS pedidos_distintos FROM orders; -- Depois do JOIN com order_items SELECT COUNT(*) AS linhas_join, COUNT(DISTINCT o.order_id) AS pedidos_distintos FROM orders o JOIN order_items oi ON oi.order_id = o.order_id;Se linhas_join for muito maior que linhas_orders, isso pode ser esperado (se você trouxe itens), mas é um alerta de que qualquer métrica no nível de pedido precisa ser tratada.
Padrões de erro mais comuns e como corrigir
Erro 1 — Somar uma métrica do lado “1” depois de juntar com o lado “N”
Sintoma: receita do pedido inflada ao juntar pedidos com itens.
Correção: agregue primeiro no nível correto, ou calcule métricas no nível do item (não do pedido) e depois agregue.
Exemplo: você quer receita total por dia, mas tem orders.order_total e também order_items (para outras análises). Se você juntar e somar order_total, vai inflar. Solução: calcule a receita por dia apenas com orders, e junte itens apenas se necessário e de forma agregada.
-- Receita por dia no grão correto (pedido) SELECT order_date, SUM(order_total) AS receita FROM orders GROUP BY order_date;Se você precisa de alguma informação de itens (ex.: quantidade total de itens por pedido) para depois agrupar por dia, agregue itens por pedido antes e só então junte:
WITH itens_por_pedido AS ( SELECT order_id, SUM(quantity) AS qtd_itens, SUM(quantity * unit_price) AS valor_itens FROM order_items GROUP BY order_id ) SELECT o.order_date, SUM(o.order_total) AS receita_pedidos, SUM(i.qtd_itens) AS itens_vendidos, SUM(i.valor_itens) AS receita_itens FROM orders o LEFT JOIN itens_por_pedido i ON i.order_id = o.order_id GROUP BY o.order_date;Note que agora o JOIN é 1:1 (uma linha por pedido em itens_por_pedido), evitando multiplicação.
Erro 2 — JOIN com dimensão “histórica” sem filtrar a versão correta
Sintoma: ao juntar vendas com clientes, o número de linhas aumenta mais do que o esperado, e clientes aparecem repetidos.
Causa típica: tabela de clientes tem múltiplas linhas por customer_id (por exemplo, uma por período de vigência).
Correção: escolha uma regra para selecionar uma única linha por entidade (por exemplo, a mais recente) antes do JOIN.
WITH clientes_unicos AS ( SELECT * FROM ( SELECT c.*, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC) AS rn FROM customers c ) t WHERE rn = 1 ) SELECT o.order_id, o.customer_id, cu.customer_name, o.order_total FROM orders o LEFT JOIN clientes_unicos cu ON cu.customer_id = o.customer_id;Esse padrão (usar ROW_NUMBER()) é uma das formas mais seguras de “desduplicar” dimensões quando você precisa de uma única versão por chave.
Erro 3 — JOIN usando chave incompleta (especialmente em tabelas com chave composta)
Sintoma: itens ou preços se multiplicam de forma inesperada.
Causa típica: a tabela correta exige mais colunas para identificar unicamente o registro (ex.: preço por product_id e price_date, ou por store_id e product_id).
Correção: inclua todas as colunas necessárias na condição do JOIN e/ou pré-filtre a tabela para o recorte correto.
-- Errado: junta preço só por product_id (pode haver vários preços por data/loja) SELECT oi.order_id, oi.product_id, p.price FROM order_items oi JOIN product_prices p ON p.product_id = oi.product_id; -- Melhor: inclui a data (exemplo) SELECT oi.order_id, oi.product_id, p.price FROM order_items oi JOIN orders o ON o.order_id = oi.order_id JOIN product_prices p ON p.product_id = oi.product_id AND p.price_date = o.order_date;Se o preço depende também de loja, canal ou região, essas colunas também precisam entrar no JOIN.
Erro 4 — N:N acidental por causa de duplicidade em ambos os lados
Sintoma: a multiplicação é muito maior do que o esperado, e mesmo agregando um lado, ainda há inflação.
Causa típica: você tem duplicidade na chave em A e em B, então o JOIN vira N:N. Ex.: duas linhas de cliente com mesmo customer_id e três linhas de status com mesmo customer_id geram 6 combinações.
Correção: tornar pelo menos um lado único antes do JOIN (ou ambos), com regra explícita.
WITH a_unico AS ( SELECT * FROM ( SELECT a.*, ROW_NUMBER() OVER (PARTITION BY key_id ORDER BY updated_at DESC) rn FROM tabela_a a ) x WHERE rn = 1 ), b_unico AS ( SELECT * FROM ( SELECT b.*, ROW_NUMBER() OVER (PARTITION BY key_id ORDER BY updated_at DESC) rn FROM tabela_b b ) y WHERE rn = 1 ) SELECT a_unico.key_id, a_unico.col1, b_unico.col2 FROM a_unico JOIN b_unico ON b_unico.key_id = a_unico.key_id;Técnicas para evitar duplicidade e inflação (com quando usar)
1) Pré-agregar o lado “muitos” antes do JOIN
Quando usar: sempre que você precisa de métricas do lado “muitos” no grão do lado “um” (ex.: itens por pedido, pagamentos por pedido, tickets por cliente).
Vantagens: simplifica o JOIN para 1:1 e reduz volume.
WITH pagamentos_por_pedido AS ( SELECT order_id, SUM(amount) AS total_pago, COUNT(*) AS qtd_pagamentos FROM payments WHERE status = 'CONFIRMED' GROUP BY order_id ) SELECT o.order_id, o.order_total, p.total_pago, p.qtd_pagamentos FROM orders o LEFT JOIN pagamentos_por_pedido p ON p.order_id = o.order_id;2) Desduplicar com janela (ROW_NUMBER) e regra de escolha
Quando usar: dimensões com múltiplas linhas por chave (cadastro com versões, tabelas de eventos onde você quer “o último status”, etc.).
Ponto de atenção: a regra precisa ser defensável (mais recente, status prioritário, vigente na data do fato).
WITH ultimo_status AS ( SELECT * FROM ( SELECT s.*, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY status_datetime DESC) rn FROM order_status_history s ) t WHERE rn = 1 ) SELECT o.order_id, o.order_date, us.status FROM orders o LEFT JOIN ultimo_status us ON us.order_id = o.order_id;3) Usar EXISTS para filtrar sem multiplicar linhas
Quando usar: você só precisa saber se existe correspondência, não precisa trazer colunas do outro lado.
Benefício: evita multiplicação porque não materializa as combinações.
-- Pedidos que têm ao menos um item da categoria 'ELETRONICOS' SELECT o.order_id, o.order_date, o.order_total FROM orders o WHERE EXISTS ( SELECT 1 FROM order_items oi JOIN products pr ON pr.product_id = oi.product_id WHERE oi.order_id = o.order_id AND pr.category = 'ELETRONICOS' );4) Contar/somar com cuidado: DISTINCT como “curativo”, não como solução padrão
Quando usar: em casos específicos, para contagens de entidades únicas após JOIN (ex.: contar pedidos distintos), ou quando você tem certeza de que a duplicidade é apenas efeito do JOIN e não um problema de modelagem.
Risco: SUM(DISTINCT valor) quase nunca é uma boa ideia em métricas financeiras, porque valores iguais em pedidos diferentes seriam colapsados indevidamente.
-- Contar pedidos após juntar com itens: use DISTINCT no identificador SELECT COUNT(DISTINCT o.order_id) AS pedidos FROM orders o JOIN order_items oi ON oi.order_id = o.order_id;Se você precisa somar receita por pedido, o correto é somar no grão de pedido (ou garantir 1:1), não aplicar DISTINCT em valores.
5) Validar chaves com “testes” em SQL (rotina de qualidade)
Em análises recorrentes, vale criar consultas de validação que você roda sempre que atualizar dados ou mudar JOINs. Exemplos de testes úteis:
- Chave única em dimensões:
HAVING COUNT(*) > 1 - Chaves órfãs (fatos sem dimensão): pedidos com
customer_idinexistente - Cardinalidade inesperada: pedidos com mais de 1 envio quando deveria ser 1
-- Chaves órfãs: pedidos sem cliente correspondente SELECT o.customer_id, COUNT(*) AS qtd_pedidos FROM orders o LEFT JOIN customers c ON c.customer_id = o.customer_id WHERE c.customer_id IS NULL GROUP BY o.customer_id;-- Cardinalidade inesperada: mais de 1 envio por pedido SELECT order_id, COUNT(*) AS envios FROM shipments GROUP BY order_id HAVING COUNT(*) > 1;Diagnóstico guiado: como investigar um relatório que inflou
1) Reproduza o problema no menor recorte possível
Escolha uma chave específica (um pedido, um cliente, um dia) onde você sabe o valor correto e investigue apenas aquele recorte. Isso reduz ruído e acelera o diagnóstico.
-- Investigue um pedido específico SELECT o.order_id, o.order_total, oi.product_id, oi.quantity, oi.unit_price FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.order_id = 12345;2) Conte quantas linhas surgem por chave após o JOIN
Se o resultado final deveria ter 1 linha por pedido, descubra quantas linhas por pedido você está gerando.
SELECT o.order_id, COUNT(*) AS linhas_apos_join FROM orders o JOIN order_items oi ON oi.order_id = o.order_id GROUP BY o.order_id ORDER BY linhas_apos_join DESC;3) Identifique qual tabela está “multiplicando”
Se você tem múltiplos JOINs (ex.: pedidos + itens + pagamentos + cupons), a multiplicação pode vir de mais de um lado. Um padrão eficiente é ir juntando uma tabela por vez e medindo o crescimento.
-- Base SELECT COUNT(*) FROM orders; -- Após itens SELECT COUNT(*) FROM orders o JOIN order_items oi ON oi.order_id = o.order_id; -- Após itens + pagamentos SELECT COUNT(*) FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN payments p ON p.order_id = o.order_id;Se ao adicionar payments o número de linhas dispara, você provavelmente criou um N:N (itens x pagamentos por pedido). Nesse caso, pré-agregue itens por pedido e pagamentos por pedido antes de juntar.
Exemplo completo: evitando N:N ao combinar itens e pagamentos
Cenário: você quer um relatório por pedido com: total do pedido, quantidade de itens, total pago e número de pagamentos confirmados. Tabelas:
orders: 1 linha por pedidoorder_items: várias linhas por pedidopayments: várias linhas por pedido
Se você juntar as três diretamente, cada item combina com cada pagamento do mesmo pedido, criando N:N dentro do pedido. A solução é agregar order_items por pedido e payments por pedido, e então juntar tudo em 1:1.
WITH itens AS ( SELECT order_id, SUM(quantity) AS qtd_itens, SUM(quantity * unit_price) AS valor_itens FROM order_items GROUP BY order_id ), pagamentos AS ( SELECT order_id, SUM(amount) AS total_pago, COUNT(*) AS qtd_pagamentos FROM payments WHERE status = 'CONFIRMED' GROUP BY order_id ) SELECT o.order_id, o.order_date, o.order_total, i.qtd_itens, i.valor_itens, p.total_pago, p.qtd_pagamentos FROM orders o LEFT JOIN itens i ON i.order_id = o.order_id LEFT JOIN pagamentos p ON p.order_id = o.order_id;Agora você tem uma linha por pedido, sem multiplicação, e pode agregar por dia, por cliente, por canal etc. com segurança.
Regras de ouro para JOINs sem inflação
- Regra 1: saiba o grão do seu resultado e mantenha as tabelas alinhadas a esse grão (pré-agregue quando necessário).
- Regra 2: valide unicidade das chaves do lado “1” antes de confiar em 1:1 ou 1:N.
- Regra 3: se você juntar duas tabelas “muitas” pelo mesmo identificador (ex.: itens e pagamentos por pedido), você está a um passo de um N:N; agregue primeiro.
- Regra 4: use
ROW_NUMBER()para escolher uma linha por chave quando houver histórico/versões. - Regra 5: prefira
EXISTSquando só precisa de presença/ausência, não de colunas. - Regra 6: use
COUNT(DISTINCT id)para contagens de entidades após JOIN; evite “consertar” soma comDISTINCTem valores.