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

Dois círculos sobrepostos, um cheio de aviões e outro de bilhetes, com a interseção em destaque

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

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:

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, 简体中文