SQL para análise de dados · Guias · 30 de outubro de 2026 · 11 min de leitura

Self join SQL: como juntar uma tabela com ela mesma, com exemplos

Um mapa de rotas aéreas que se dobra sobre si mesmo, duas cópias da mesma tabela ligadas por setas entre aeroportos

Um self join (autojunção) é um join comum em que os dois lados são a mesma tabela. Você escreve a tabela duas vezes com dois aliases, por exemplo flights f1 JOIN flights f2, e a condição do join diz como uma linha da esquerda se relaciona com uma linha da direita: mesma cidade, próximo trecho de uma viagem, a versão seguinte de um histórico. Não existe a palavra-chave SELF JOIN; qualquer tipo de join serve (INNER, LEFT, até CROSS). O self join responde perguntas sobre relações entre linhas de uma mesma tabela: pares, sequências e conexões.

Todos os exemplos rodam no banco de dados da companhia aérea do curso de SQL da Nomto (104 aeroportos, 33.121 voos, 366.733 bilhetes), com sintaxe do PostgreSQL sobre o DuckDB, o motor da sala de SQL da Nomto. Cada tabela de resultado é a saída real da consulta.

Sintaxe do self join

SELECT a.col, b.col
FROM some_table a
JOIN some_table b
  ON <how row a relates to row b>;

Os aliases são obrigatórios. Sem eles, airports.city = airports.city fica ambíguo e não dá para dizer de qual cópia vem cada coluna. Quando a consulta passar de poucas linhas, escolha aliases que descrevam o papel de cada cópia (origin e dest, prev e next, out e back), e não só a e b.

Pares únicos: a < b em vez de a <> b

O self join mais simples junta linhas que têm algo em comum. O guia de JOIN usa esse padrão para achar cidades com vários aeroportos. Aqui vai uma consulta de pares sem chave em comum: os aeroportos de cidades diferentes mais próximos entre si, pela distância ortodrômica calculada a partir das coordenadas.

SELECT a1.airport_code, a1.city,
       a2.airport_code AS other, a2.city AS other_city,
       ROUND(2 * 6371 * ASIN(SQRT(
         POWER(SIN(RADIANS(a2.latitude - a1.latitude) / 2), 2) +
         COS(RADIANS(a1.latitude)) * COS(RADIANS(a2.latitude)) *
         POWER(SIN(RADIANS(a2.longitude - a1.longitude) / 2), 2)
       ))) AS km
FROM airports a1
JOIN airports a2 ON a1.airport_code < a2.airport_code
WHERE a1.city <> a2.city
ORDER BY km
LIMIT 6;
airport_code city other other_city km
NFG Nefteyugansk SGC Surgut 48
NJC Nizhnevartovsk SWT Strezhevoy 69
AAQ Anapa GDZ Gelendzhik 70
CSY Cheboksary JOK Yoshkar-Ola 76
IAR Yaroslavl IWA Ivanovo 84
NAL Nalchik OGZ Vladikavkaz 86

A condição no ON decide quantas linhas você recebe. Com 104 aeroportos:

condição do join linhas o que você recebe
ON TRUE (todas as combinações) 10816 104 × 104, incluindo cada aeroporto pareado com ele mesmo
ON a1.airport_code <> a2.airport_code 10712 cada par duas vezes: NFG–SGC e SGC–NFG
ON a1.airport_code < a2.airport_code 5356 cada par uma vez só: 104 × 103 / 2

O truque do < funciona em qualquer coluna com ordem fixa, como um ID, um código ou um nome. Ele elimina de uma vez a linha pareada consigo mesma e o par espelhado, e corta o trabalho pela metade. Use <> só quando a direção importa, por exemplo “A está mais perto de B do que de qualquer outro”, em que A e B têm papéis diferentes. A consulta compara cada aeroporto com todos os outros, o que vai bem com cem linhas, mas cresce com o quadrado do tamanho da tabela; em tabelas grandes, acrescente uma condição que reduza os candidatos (mesma região, uma faixa de latitude).

Voos de conexão: o fim de uma linha encontra o início de outra

Os self joins mais úteis encadeiam eventos: a chegada de um voo juntada a uma partida do mesmo aeroporto pouco depois. Conexões saindo de Kazan via Moscou-Domodedovo em 20 de agosto, com escala entre uma e quatro horas:

SELECT f1.flight_no AS leg1, f1.arrival_airport AS via,
       f1.scheduled_arrival::time AS lands,
       f2.flight_no AS leg2, f2.arrival_airport AS final_dest,
       f2.scheduled_departure::time AS departs,
       f2.scheduled_departure - f1.scheduled_arrival AS layover
FROM flights f1
JOIN flights f2
  ON  f2.departure_airport = f1.arrival_airport
  AND f2.scheduled_departure BETWEEN f1.scheduled_arrival + INTERVAL '1 hour'
                                 AND f1.scheduled_arrival + INTERVAL '4 hours'
  AND f2.arrival_airport <> f1.departure_airport
WHERE f1.departure_airport = 'KZN'
  AND f1.arrival_airport = 'DME'
  AND f1.scheduled_departure::date = '2026-08-20'
ORDER BY f2.scheduled_departure;
leg1 via lands leg2 final_dest departs layover
PG0203 DME 18:40:00 PG0118 EGO 19:55:00 01:15:00
PG0203 DME 18:40:00 PG0168 UUS 20:05:00 01:25:00
PG0203 DME 18:40:00 PG0208 KHV 20:40:00 02:00:00

Um voo noturno de Kazan conecta com Belgorod, Yuzhno-Sakhalinsk e Khabarovsk. Três detalhes deixam essa consulta correta:

O filtro de data no WHERE vale só para o primeiro trecho; o segundo pode sair no dia seguinte se o primeiro pousar tarde da noite. Tire o filtro f1.arrival_airport = 'DME' e a mesma consulta lista todas as 17 conexões saindo de Kazan naquele dia, via São Petersburgo, Rostov, Kirov e Domodedovo. Para encadear três trechos ou mais, repita o join ou passe para uma CTE recursiva.

Ida e volta: casar uma linha com o seu espelho

Um bilhete pode ter vários trechos de voo. Uma viagem de ida e volta é um bilhete em que um trecho vai de A para B e outro, depois, vai de B para A. Isso é um self join dos trechos do bilhete, com os aeroportos trocados na condição. Para simplificar, olhe só os bilhetes com exatamente dois trechos (183.338 deles):

WITH seg AS (
  SELECT tf.ticket_no, f.departure_airport, f.arrival_airport, f.scheduled_departure
  FROM ticket_flights tf
  JOIN flights f ON f.flight_id = tf.flight_id
  WHERE tf.ticket_no IN (
    SELECT ticket_no FROM ticket_flights GROUP BY ticket_no HAVING COUNT(*) = 2
  )
)
SELECT outbound.departure_airport AS home, outbound.arrival_airport AS destination,
       COUNT(*) AS round_trips,
       ROUND(AVG(back.scheduled_departure::date - outbound.scheduled_departure::date), 1) AS avg_days_away
FROM seg outbound
JOIN seg back
  ON  back.ticket_no = outbound.ticket_no
  AND back.departure_airport = outbound.arrival_airport
  AND back.arrival_airport = outbound.departure_airport
  AND back.scheduled_departure > outbound.scheduled_departure
GROUP BY home, destination
ORDER BY round_trips DESC
LIMIT 5;
home destination round_trips avg_days_away
SVO AER 6201 8.5
SVO OVB 5979 8.5
DME OVB 5730 8.4
SVO SVX 5350 8.7
DME KRR 4523 8.4

Entre todos os bilhetes de dois trechos, 177.847 são de ida e volta, e os viajantes passam em média 8,4 dias fora. A condição back.scheduled_departure > outbound.scheduled_departure faz o mesmo papel do a < b acima: sem ela, cada ida e volta também casaria “ao contrário”, com o voo de volta no papel de ida, e a contagem dobraria.

Por que só bilhetes de dois trechos? Bilhetes com conexão, digamos Moscou–Belgorod–Anapa e volta, têm quatro trechos, e a condição de espelho casa cada perna com a sua própria volta: dois pares por bilhete. Juntar uma tabela com ela mesma multiplica linhas sempre que uma linha tem várias correspondências, o mesmo fan-out de qualquer join. Antes de agregar o resultado de um self join, confira o que uma linha da saída representa.

Histórico: ligar cada versão à seguinte

Tabelas que guardam versões com valid_from e valid_to podem ser juntadas com elas mesmas para pôr cada versão ao lado da que a substituiu. Mudanças de categoria no programa de fidelidade, em que o fim da linha antiga é igual ao início da nova:

SELECT prev.tier AS from_tier, nxt.tier AS to_tier, COUNT(*) AS moves
FROM loyalty_tier_history prev
JOIN loyalty_tier_history nxt
  ON  nxt.member_id = prev.member_id
  AND nxt.valid_from = prev.valid_to
GROUP BY from_tier, to_tier
ORDER BY moves DESC;
from_tier to_tier moves
basic silver 4179
silver gold 643
gold platinum 142
silver basic 21
gold silver 4

É o clássico “funcionário e gestor” aplicado ao tempo: uma linha aponta para outra linha da mesma tabela, aqui por datas em vez de uma coluna manager_id. Num organograma você escreveria JOIN employees mgr ON mgr.employee_id = emp.manager_id, e um LEFT JOIN manteria o CEO, que não tem gestor. Esse exemplo de funcionário e gestor é, aliás, o self join que mais cai em entrevista para vaga de dados.

Self join ou função de janela

A tabela de categorias tem 58.194 linhas para 53.000 membros, então deveria haver 5.194 transições. O self join encontrou 4.989. Faça a mesma pergunta com LAG, que simplesmente pega a linha anterior pela data:

SELECT prev_tier AS from_tier, tier AS to_tier, COUNT(*) AS moves
FROM (
  SELECT tier,
         LAG(tier) OVER (PARTITION BY member_id ORDER BY valid_from) AS prev_tier
  FROM loyalty_tier_history
) t
WHERE prev_tier IS NOT NULL
GROUP BY from_tier, to_tier
ORDER BY moves DESC;
from_tier to_tier moves
basic silver 4364
silver gold 659
gold platinum 145
silver basic 22
gold silver 4

São 5.194 transições. As 205 que o self join perdeu são linhas cujo valid_to não bate com o valid_from seguinte: 110 versões antigas nunca foram fechadas e 95 foram fechadas alguns dias depois, sobrepondo a seguinte. Um join por igualdade de datas descarta essas ligações em silêncio; o LAG nem olha para o valid_to. O guia de SCD tipo 2 (em inglês) mostra como achar e consertar essas linhas.

Nenhuma das duas respostas é “a” certa. O self join confia nas ligações declaradas, a função de janela confia na ordenação. A própria diferença entre elas já é uma checagem de qualidade dos dados. Como regra prática:

pergunta melhor ferramenta
linha anterior ou seguinte numa sequência ordenada LAG / LEAD
ranking, total acumulado, número da linha dentro de um grupo funções de janela
pares de linhas que atendem a uma condição (mesma cidade, a menos de 100 km) self join
linhas relacionadas por um intervalo, e não pela posição (uma partida 1 a 4 horas depois de uma chegada) self join
uma linha que aponta para outra por ID (gestor, categoria-pai) self join, ou CTE recursiva para muitos níveis

Funções de janela leem a tabela uma vez e a ordenam; um self join a lê duas vezes e pode gerar muitas correspondências por linha. Quando as duas resolvem a pergunta, a função de janela costuma ser mais rápida e não tem como multiplicar linhas. Veja LAG e LEAD no SQL e o guia de funções de janela.

LEFT self join: manter as linhas sem par

Um self join INNER descarta as linhas sem correspondência. Para mantê-las, use LEFT JOIN e teste NULL no lado direito. As linhas sem antecessora, ou seja, a primeira linha de cada histórico:

SELECT COUNT(*) AS rows_without_predecessor
FROM loyalty_tier_history cur
LEFT JOIN loyalty_tier_history prev
  ON  prev.member_id = cur.member_id
  AND prev.valid_to = cur.valid_from
WHERE prev.member_id IS NULL;
rows_without_predecessor
53205

53.000 delas são as primeiras linhas dos membros. As outras 205 são as ligações quebradas da seção anterior, encontradas desta vez sem função de janela. Esse “anti self join” é o jeito de achar órfãos: categorias cujo pai sumiu, voos sem volta, versões sem antecessora.

Erros comuns

Perguntas frequentes

O que é self join no SQL?

É o join de uma tabela com ela mesma. Você referencia a tabela duas vezes com aliases diferentes e junta as cópias por uma condição, por exemplo FROM employees e JOIN employees m ON m.employee_id = e.manager_id. Serve para comparar linhas da mesma tabela entre si.

SELF JOIN é uma palavra-chave?

Não. Um self join se escreve com a sintaxe normal de JOIN, LEFT JOIN ou CROSS JOIN; o que o torna um self join é que os dois lados são a mesma tabela.

Como evitar pares duplicados num self join?

Junte por a.id < b.id em vez de a.id <> b.id. Isso remove as linhas pareadas consigo mesmas e mantém só um de cada par espelhado (A–B, mas não B–A).

Quando usar self join em vez de LAG ou LEAD?

Use LAG/LEAD para a linha anterior ou seguinte numa ordem fixa. Use self join quando a relação é definida por uma condição, e não pela posição: pares dentro de uma distância, conexões dentro de uma janela de tempo, uma linha que aponta para outra por ID.

Um self join pode ser LEFT JOIN?

Pode. Um LEFT self join mantém as linhas sem par, com NULLs no lado direito. Acrescentar WHERE lado_direito.chave IS NULL o transforma num anti join que encontra linhas sem correspondência, como registros cujo pai não existe.

Outros idiomas: English, Español, Tiếng Việt, 简体中文