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 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 join não é por chave.
f2.departure_airport = f1.arrival_airporté uma igualdade, mas a janela de tempo é uma condição de intervalo. Self joins costumam misturar as duas coisas. - A janela de tempo é relativa ao primeiro trecho.
BETWEEN arrival + 1 hour AND arrival + 4 hoursimpede conexões impossíveis (sair antes de pousar) e absurdas (uma partida no dia seguinte). f2.arrival_airport <> f1.departure_airportelimina “conexões” que levam você direto de volta para casa.
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
- Esquecer a desigualdade.
a.city = b.citysozinho casa cada aeroporto com ele mesmo; o resultado tem mais linhas que a tabela e todo aeroporto parece dividir a cidade com outro. - Usar
<>quando queria<. Cada par aparece duas vezes, e as contagens dobram. - Agregar depois de um fan-out. Se uma linha pode casar com várias,
SUMeCOUNTsobre o self join contam essa linha várias vezes. - Juntar por colunas que podem ser
NULL.NULL = NULLnão é verdadeiro, então linhas com chaveNULLnunca encontram par. Foi por isso que as 110 linhas de categoria em aberto acima não entraram no join. Mais em NULL no SQL. - Nenhuma condição que reduza os candidatos numa tabela grande. Um self join com condição fraca é quase um cross join: um milhão de linhas contra um milhão de linhas.
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, 简体中文