SQL para análise de dados · Guias · 30 de outubro de 2026 · 12 min de leitura
JOIN no SQL: INNER, LEFT, FULL, self join e anti join com exemplos

Um JOIN no SQL combina linhas de duas tabelas por uma condição, normalmente uma chave de uma tabela igual a uma chave da outra. O tipo de JOIN decide o que acontece com as linhas que não encontram par: o INNER JOIN as descarta dos dois lados, o LEFT JOIN mantém todas as linhas da tabela da esquerda e preenche o lado direito com NULL, o FULL JOIN mantém as linhas sem par dos dois lados. E o ponto que a maioria das explicações pula: quando uma linha encontra várias correspondências, ela é repetida uma vez para cada uma. Essa repetição, chamada de fan-out (multiplicação de linhas), está por trás da maioria dos totais errados em análise de dados.
Todos os exemplos rodam no banco de dados de uma companhia aérea do curso de SQL da Nomto (104 aeroportos, 9 modelos de avião, 33.121 voos, 262.788 reservas, valores em dólares americanos, textos dos dados em inglês), e as tabelas de resultado são a saída real. Cole qualquer consulta na sala de SQL da Nomto para conferir.
As tabelas dos exemplos
aircrafts: uma linha por modelo de avião (aircraft_code,model,range)airports: uma linha por aeroporto (airport_code,airport_name,city)flights: uma linha por partida;aircraft_code,departure_airportearrival_airportapontam para as duas tabelas acimabookings: uma linha por reserva, comtotal_amounttickets: uma linha por bilhete de passageiro; vários bilhetes podem ter o mesmobook_refticket_flights: uma linha por bilhete por trecho de voo, com oamountdo trechoboarding_passes: uma linha por cartão de embarque emitido para um trecho
Saber o grão de cada tabela (o que significa uma linha) é o que permite prever o resultado de um JOIN antes de executá-lo.
INNER JOIN: só as linhas com correspondência
Mostre qual modelo de avião operou cada partida de Sochi em 1º de agosto:
SELECT f.flight_no, f.scheduled_departure, f.arrival_airport, a.model
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
WHERE f.departure_airport = 'AER'
AND f.scheduled_departure >= '2026-08-01'
AND f.scheduled_departure < '2026-08-02'
ORDER BY f.scheduled_departure
LIMIT 5;
| flight_no | scheduled_departure | arrival_airport | model |
|---|---|---|---|
| PG0248 | 2026-08-01 09:05:00 | GOJ | Bombardier CRJ-200 |
| PG0322 | 2026-08-01 10:30:00 | NOZ | Boeing 737-300 |
| PG0602 | 2026-08-01 11:50:00 | PEZ | Cessna 208 Caravan |
| PG0522 | 2026-08-01 14:10:00 | KUF | Boeing 737-300 |
| PG0326 | 2026-08-01 14:20:00 | KGP | Boeing 737-300 |
JOIN sozinho significa INNER JOIN. Cada voo tem exatamente um modelo de avião, então o JOIN acrescenta uma coluna sem mudar o número de linhas. Essa é a direção segura: muitos para um.
Agora conte os voos por modelo:
SELECT a.model, COUNT(*) AS flights
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY flights DESC;
| model | flights |
|---|---|
| Cessna 208 Caravan | 9273 |
| Bombardier CRJ-200 | 9048 |
| Sukhoi Superjet-100 | 8504 |
| Airbus A321-200 | 1952 |
| Boeing 737-300 | 1274 |
| Airbus A319-100 | 1239 |
| Boeing 767-300 | 1221 |
| Boeing 777-300 | 610 |
Oito linhas, mas a frota tem nove modelos. O INNER JOIN descartou em silêncio o único modelo que não tem voos. Às vezes é isso que você quer; num relatório “para todos os modelos”, é um bug.
LEFT JOIN: mantenha todas as linhas da esquerda
SELECT a.aircraft_code, a.model, COUNT(f.flight_id) AS flights
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
GROUP BY a.aircraft_code, a.model
ORDER BY flights
LIMIT 3;
| aircraft_code | model | flights |
|---|---|---|
| 320 | Airbus A320-200 | 0 |
| 773 | Boeing 777-300 | 610 |
| 763 | Boeing 767-300 | 1221 |
O Airbus A320-200 voltou, com zero voos. Dois detalhes fazem isso funcionar:
aircraftsfica à esquerda, porque é a tabela cujas linhas precisam sobreviver todas.- A consulta conta
COUNT(f.flight_id), e nãoCOUNT(*). Para o modelo sem par, oJOINproduz uma linha comNULLem todas as colunas deflights;COUNT(*)contaria essa linha e mostraria 1.
A armadilha do WHERE: um LEFT JOIN que vira INNER JOIN
Tarefa: para cada modelo, quantas partidas de Kazan (KZN)?
SELECT a.aircraft_code, COUNT(f.flight_id) AS kzn_departures
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.departure_airport = 'KZN'
GROUP BY a.aircraft_code
ORDER BY a.aircraft_code;
| aircraft_code | kzn_departures |
|---|---|
| 319 | 61 |
| 321 | 61 |
| CN1 | 122 |
| CR2 | 105 |
| SU9 | 122 |
Só cinco modelos. O WHERE roda depois do JOIN; para os modelos sem voos saindo de Kazan, f.departure_airport é NULL, e NULL = 'KZN' não é verdadeiro, então essas linhas são removidas. O LEFT não serviu para nada.
Leve a condição sobre a tabela da direita para o ON:
SELECT a.aircraft_code, COUNT(f.flight_id) AS kzn_departures
FROM aircrafts a
LEFT JOIN flights f
ON f.aircraft_code = a.aircraft_code
AND f.departure_airport = 'KZN'
GROUP BY a.aircraft_code
ORDER BY a.aircraft_code;
| aircraft_code | kzn_departures |
|---|---|
| 319 | 61 |
| 320 | 0 |
| 321 | 61 |
| 733 | 0 |
| 763 | 0 |
| 773 | 0 |
| CN1 | 122 |
| CR2 | 105 |
| SU9 | 122 |
Agora os nove modelos aparecem: as mesmas cinco contagens, mais 320, 733, 763 e 773 com 0. Regra prática: num LEFT JOIN, condições sobre a tabela da direita vão no ON; condições sobre a tabela da esquerda vão no WHERE.
Anti join: linhas sem correspondência
“Quais modelos nunca voam?”, “quais voos não venderam nenhum bilhete?” são perguntas de anti join. Há duas formas padrão de escrevê-las.
LEFT JOIN, ficando com as linhas em que o lado direito está ausente:
SELECT a.aircraft_code, a.model
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.flight_id IS NULL;
NOT EXISTS:
SELECT a.aircraft_code, a.model
FROM aircrafts a
WHERE NOT EXISTS (
SELECT 1 FROM flights f WHERE f.aircraft_code = a.aircraft_code
);
As duas devolvem uma linha:
| aircraft_code | model |
|---|---|
| 320 | Airbus A320-200 |
O NOT EXISTS fica mais próximo da pergunta e nunca multiplica linhas, por isso muitos analistas o preferem. Na versão com LEFT JOIN, teste uma coluna que não pode ser NULL numa correspondência real, como a chave da tabela da direita.
Um exemplo maior: quantos voos pousaram sem nenhum bilhete vendido?
SELECT COUNT(*) AS arrived_empty
FROM flights f
WHERE f.status = 'Arrived'
AND NOT EXISTS (
SELECT 1 FROM ticket_flights tf WHERE tf.flight_id = f.flight_id
);
| arrived_empty |
|---|
| 5269 |
Evite NOT IN (SELECT ...) para anti joins: se a subconsulta devolver um único NULL, o NOT IN não devolve linha nenhuma.
FULL OUTER JOIN: concilie dois conjuntos
Um FULL JOIN mantém as linhas sem par dos dois lados. É a ferramenta para comparar duas listas: quais destinos são atendidos a partir de Sheremetyevo (SVO), quais a partir de Pulkovo (LED) e quais a partir dos dois?
WITH svo AS (
SELECT DISTINCT arrival_airport AS dest FROM flights WHERE departure_airport = 'SVO'
), led AS (
SELECT DISTINCT arrival_airport AS dest FROM flights WHERE departure_airport = 'LED'
)
SELECT CASE WHEN svo.dest IS NULL THEN 'only LED'
WHEN led.dest IS NULL THEN 'only SVO'
ELSE 'both' END AS served_from,
COUNT(*) AS destinations
FROM svo
FULL JOIN led ON led.dest = svo.dest
GROUP BY 1
ORDER BY 1;
| served_from | destinations |
|---|---|
| both | 8 |
| only LED | 16 |
| only SVO | 40 |
Nem um LEFT nem um RIGHT JOIN conseguiriam produzir as três categorias numa passada só. Quando for listar as linhas, use COALESCE(svo.dest, led.dest) para ter uma chave não vazia por linha.
Repare no DISTINCT dentro de cada CTE: ele reduz cada lado a uma linha por destino antes do JOIN. Sem ele, cada voo encontraria todos os voos para o mesmo aeroporto do outro lado, e voltaríamos ao fan-out.
Self join: uma tabela juntada com ela mesma
Dê dois aliases à mesma tabela e você pode comparar as linhas dela entre si. Quais cidades têm mais de um aeroporto?
SELECT a1.city, a1.airport_code, a1.airport_name,
a2.airport_code AS other_code, a2.airport_name AS other_name
FROM airports a1
JOIN airports a2
ON a2.city = a1.city
AND a1.airport_code < a2.airport_code
ORDER BY a1.city, a1.airport_code, a2.airport_code;
| city | airport_code | airport_name | other_code | other_name |
|---|---|---|---|---|
| Moscow | DME | Domodedovo | SVO | Sheremetyevo |
| Moscow | DME | Domodedovo | VKO | Vnukovo |
| Moscow | SVO | Sheremetyevo | VKO | Vnukovo |
| Ulyanovsk | ULV | Barataevka | ULY | Ulyanovsk East |
A condição a1.airport_code < a2.airport_code faz dois trabalhos: impede que um aeroporto se junte consigo mesmo e mantém cada par uma vez só, em vez de duas (DME–SVO, mas não SVO–DME). O self join também serve para achar conexões: a chegada de um voo juntada com uma partida do mesmo aeroporto poucas horas depois.
JOIN em cadeia
Os JOIN se encadeiam naturalmente. Passageiros que chegaram, por cidade, seguem o caminho trecho → voo → aeroporto:
SELECT a.city, COUNT(*) AS passengers
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
JOIN airports a ON a.airport_code = f.arrival_airport
WHERE f.status = 'Arrived'
GROUP BY a.city
ORDER BY passengers DESC
LIMIT 5;
| city | passengers |
|---|---|
| Moscow | 181311 |
| St. Petersburg | 35559 |
| Novosibirsk | 27477 |
| Yekaterinburg | 20587 |
| Sochi | 18859 |
Todos os passos aqui são de muitos para um (muitos trechos por voo, muitos voos por aeroporto), então a contagem fica no grão do trecho e está correta. O problema começa quando o JOIN vai na outra direção.
Fan-out: por que o JOIN duplica linhas
Quando uma linha do lado “um” encontra várias linhas do lado “muitos”, ela é copiada uma vez para cada correspondência. A reserva 65A6EA tem cinco bilhetes:
SELECT b.book_ref, b.total_amount, t.ticket_no, t.passenger_name
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
WHERE b.book_ref = '65A6EA'
ORDER BY t.ticket_no;
| book_ref | total_amount | ticket_no | passenger_name |
|---|---|---|---|
| 65A6EA | 10656.00 | 0005433415446 | MIKHAIL NAZAROV |
| 65A6EA | 10656.00 | 0005433415447 | MAKSIM GORBUNOV |
| 65A6EA | 10656.00 | 0005433415448 | ALEKSANDR ABRAMOV |
| 65A6EA | 10656.00 | 0005433415449 | VASILIY IVANOV |
| 65A6EA | 10656.00 | 0005433415450 | GEVORG ABRAMOV |
Os US$ 10.656 da reserva agora aparecem cinco vezes. Não há nada errado com essas linhas; o erro acontece quando você as soma.
Contagem dupla na prática
Receita das reservas feitas em 2 de agosto, direto de bookings:
SELECT COUNT(*) AS bookings, SUM(total_amount) AS revenue
FROM bookings
WHERE book_date >= '2026-08-02' AND book_date < '2026-08-03';
| bookings | revenue |
|---|---|
| 5761 | 4563873.00 |
A mesma soma depois de juntar os bilhetes, por exemplo porque alguém também queria a contagem de bilhetes:
SELECT COUNT(*) AS rows_after_join,
COUNT(DISTINCT b.book_ref) AS bookings,
SUM(b.total_amount) AS revenue
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
WHERE b.book_date >= '2026-08-02' AND b.book_date < '2026-08-03';
| rows_after_join | bookings | revenue |
|---|---|---|
| 8016 | 5761 | 7569035.00 |
A receita sobe 66% sem nenhuma mensagem de erro. O COUNT(DISTINCT book_ref) continua dizendo 5.761, o que faz a soma inflada parecer confiável.
A correção tentadora, SUM(DISTINCT b.total_amount), é pior: devolve 1.750.097,00, porque reservas diferentes que por acaso têm o mesmo valor viram uma só.
Dois fan-outs ao mesmo tempo
Juntar duas tabelas “muitos” independentes ao mesmo pai multiplica uma pela outra. O voo 4729 tem 161 trechos vendidos e 161 cartões de embarque:
SELECT f.flight_id,
COUNT(tf.ticket_no) AS segments,
COUNT(bp.ticket_no) AS boarding_passes
FROM flights f
LEFT JOIN ticket_flights tf ON tf.flight_id = f.flight_id
LEFT JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.flight_id = 4729
GROUP BY f.flight_id;
| flight_id | segments | boarding_passes |
|---|---|---|
| 4729 | 25921 | 25921 |
161 × 161 = 25.921. Cada linha de trecho foi pareada com cada linha de cartão de embarque do voo.
Como evitar a contagem dupla
1. Agregue antes do JOIN. Leve cada tabela “muitos” ao grão do pai primeiro e depois junte de um para um:
WITH t AS (
SELECT book_ref, COUNT(*) AS tickets
FROM tickets
GROUP BY book_ref
)
SELECT COUNT(*) AS bookings, SUM(t.tickets) AS tickets, SUM(b.total_amount) AS revenue
FROM bookings b
JOIN t ON t.book_ref = b.book_ref
WHERE b.book_date >= '2026-08-02' AND b.book_date < '2026-08-03';
| bookings | tickets | revenue |
|---|---|---|
| 5761 | 8016 | 4563873.00 |
Receita correta e contagem de bilhetes numa mesma consulta.
2. Some a medida no seu próprio grão. Se o dinheiro está no lado “muitos”, some lá. Os valores dos trechos dessas mesmas reservas batem exatamente com os totais das reservas:
SELECT SUM(tf.amount) AS revenue
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE b.book_date >= '2026-08-02' AND b.book_date < '2026-08-03';
| revenue |
|---|
| 4563873.00 |
Aqui o fan-out é inofensivo, porque cada amount pertence a exatamente uma linha de trecho.
3. Use subconsultas escalares para contagens independentes. Para o caso dos dois fan-outs:
SELECT f.flight_id,
(SELECT COUNT(*) FROM ticket_flights tf WHERE tf.flight_id = f.flight_id) AS segments,
(SELECT COUNT(*) FROM boarding_passes bp WHERE bp.flight_id = f.flight_id) AS boarding_passes
FROM flights f
WHERE f.flight_id = 4729;
| flight_id | segments | boarding_passes |
|---|---|---|
| 4729 | 161 | 161 |
4. Confira a contagem de linhas. Antes de confiar num total, compare o COUNT(*) antes e depois do JOIN, ou concilie o total com a tabela de origem, como acima. Se o número de linhas cresceu e você não esperava, alguma chave não é única onde você achava que era.
Para filtrar resultados agrupados depois de um JOIN, veja GROUP BY e HAVING; perguntas sobre JOIN também são boa parte das perguntas de entrevista SQL.
Perguntas frequentes
Qual a diferença entre INNER JOIN e LEFT JOIN?
O INNER JOIN devolve só as linhas que têm correspondência dos dois lados. O LEFT JOIN devolve todas as linhas da tabela da esquerda; onde não há correspondência, as colunas da tabela da direita ficam NULL. Nos exemplos acima, o INNER JOIN perdeu o modelo A320 sem voos e o LEFT JOIN o manteve.
JOIN é o mesmo que INNER JOIN?
Sim. JOIN sem palavra-chave é um INNER JOIN. Da mesma forma, LEFT JOIN e LEFT OUTER JOIN são a mesma coisa.
Por que meu JOIN devolve mais linhas do que a tabela tem?
Porque uma chave do outro lado não é única: cada linha é repetida uma vez para cada correspondência. Isso é esperado em JOIN de um para muitos. Só vira bug quando você soma depois uma coluna que pertence ao lado “um”.
O que é anti join?
Uma consulta que devolve as linhas de uma tabela que não têm correspondência em outra. Em SQL, você escreve com NOT EXISTS ou com LEFT JOIN ... WHERE chave_da_direita IS NULL. Não existe a palavra-chave ANTI JOIN no PostgreSQL.
Quando preciso de um FULL OUTER JOIN?
Quando você compara dois conjuntos e quer ver, num só resultado, o que está só no primeiro, só no segundo e nos dois: para conciliar dois sistemas, dois períodos ou as listas de rotas de dois aeroportos.
Outros idiomas: Español, Tiếng Việt, English, 简体中文