SQL · Guías · 30 de octubre de 2026 · 13 min de lectura

JOIN en SQL: INNER, LEFT, RIGHT, FULL, self join y anti join con ejemplos

Dos círculos superpuestos, uno lleno de aviones y otro de billetes, con la intersección resaltada

Un JOIN en SQL combina filas de dos tablas que cumplen una condición, casi siempre que la clave de una tabla sea igual a la clave de la otra. El tipo de JOIN decide qué pasa con las filas que no encuentran pareja: INNER JOIN las descarta de ambos lados, LEFT JOIN conserva todas las filas de la tabla izquierda y rellena la derecha con NULL, RIGHT JOIN hace lo mismo con la tabla derecha y FULL JOIN conserva las filas sin pareja de los dos lados. Y lo que casi todas las explicaciones se saltan: cuando una fila coincide con varias filas, se repite una vez por cada coincidencia. Esa repetición (en inglés, fan-out) está detrás de la mayoría de los totales equivocados en analítica.

Todos los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto (104 aeropuertos, 9 modelos de avión, 33.121 vuelos, 262.788 reservas; valores en inglés, dinero en dólares) y las tablas de resultados son la salida real. Puedes pegar cualquier consulta en la sala de SQL de Nomto y comprobarla.

Las tablas de los ejemplos

Saber la granularidad de cada tabla (qué representa una fila) es lo que te permite predecir el resultado de un JOIN antes de ejecutarlo.

INNER JOIN: solo las filas que coinciden

Qué modelo de avión operó cada salida desde Sochi el 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 a secas significa INNER JOIN. Cada vuelo tiene exactamente un modelo de avión, así que el JOIN añade una columna sin cambiar el número de filas. Esa es la dirección segura: de muchos a uno.

Ahora contemos vuelos 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

Ocho filas, pero la flota tiene nueve modelos. El INNER JOIN ha eliminado en silencio el único modelo que no tiene vuelos. A veces es lo que quieres; si estás montando un informe «para todos los modelos», es un error.

LEFT JOIN: conservar todas las filas de la izquierda

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

El Airbus A320-200 vuelve a aparecer, con cero vuelos. Dos detalles hacen que funcione:

La trampa del WHERE: un LEFT JOIN que se convierte en INNER JOIN

Tarea: para cada modelo, ¿cuántas salidas desde Kazán (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

Solo cinco modelos. El WHERE se aplica después del JOIN; para los modelos sin vuelos desde Kazán, f.departure_airport es NULL, y NULL = 'KZN' no es verdadero, así que esas filas desaparecen. El LEFT no ha servido de nada.

Lleva la condición sobre la tabla derecha al 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;

Ahora salen los nueve modelos: los mismos cinco conteos, más 320, 733, 763 y 773 con 0. Regla práctica: en un LEFT JOIN, las condiciones sobre la tabla derecha van en ON; las condiciones sobre la tabla izquierda van en WHERE.

RIGHT JOIN: lo mismo, mirado desde el otro lado

RIGHT JOIN conserva todas las filas de la tabla derecha. Es un LEFT JOIN con las tablas cambiadas de sitio, y esta consulta devuelve exactamente lo mismo que la del apartado anterior:

SELECT a.aircraft_code, a.model, COUNT(f.flight_id) AS flights
FROM flights f
RIGHT JOIN aircrafts a ON a.aircraft_code = f.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

Fíjate en que la consulta selecciona a.aircraft_code y no f.aircraft_code: en la fila del A320 todas las columnas de flights son NULL. En la práctica, casi todo el mundo escribe LEFT JOIN y coloca primero la tabla que debe conservarse; las consultas con varios JOIN se leen mejor cuando todas las uniones van en la misma dirección.

Anti join: filas sin pareja

«¿Qué modelos no vuelan nunca?», «¿qué vuelos no vendieron ningún billete?» son preguntas de anti join. Hay dos formas habituales de escribirlas.

LEFT JOIN y quedarse con las filas en las que falta el lado derecho:

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
);

Las dos devuelven una fila:

aircraft_code model
320 Airbus A320-200

NOT EXISTS se parece más a la pregunta y nunca multiplica filas, por eso muchos analistas lo prefieren. En la versión con LEFT JOIN, comprueba una columna que no pueda ser NULL en una coincidencia real, como la clave de la tabla derecha.

Un ejemplo más grande: ¿cuántos vuelos aterrizaron sin un solo billete 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

Evita NOT IN (SELECT ...) para los anti joins: si la subconsulta devuelve aunque sea un NULL, NOT IN no devuelve ninguna fila.

FULL OUTER JOIN: cuadrar dos conjuntos

Un FULL JOIN conserva las filas sin pareja de los dos lados. Es la herramienta para comparar dos listas: ¿a qué destinos se vuela desde Sheremétievo (SVO), a cuáles desde Púlkovo (LED) y a cuáles desde los dos?

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

Ni un LEFT ni un RIGHT JOIN podrían dar las tres categorías de una sola vez. Si listas las filas, usa COALESCE(svo.dest, led.dest) para tener una clave no vacía en cada una.

Fíjate en el DISTINCT dentro de cada CTE: reduce cada lado a una fila por destino antes del JOIN. Sin él, cada vuelo coincidiría con todos los vuelos al mismo aeropuerto del otro lado, y volveríamos a tener filas duplicadas.

Self join: una tabla unida consigo misma

Dale dos alias a la misma tabla y podrás comparar sus filas entre sí. ¿Qué ciudades tienen más de un aeropuerto?

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

La condición a1.airport_code < a2.airport_code hace dos cosas: impide que un aeropuerto coincida consigo mismo y deja cada pareja una sola vez en lugar de dos (DME–SVO, pero no SVO–DME). Con self joins también se buscan conexiones: la llegada de un vuelo unida a una salida desde el mismo aeropuerto unas horas después.

Cadenas de JOIN

Los JOIN se encadenan con naturalidad. Pasajeros que llegaron por ciudad: tramo → vuelo → aeropuerto.

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

Cada paso es de muchos a uno (muchos tramos por vuelo, muchos vuelos por aeropuerto), así que el conteo se mantiene en la granularidad del tramo y es correcto. Los problemas empiezan cuando un JOIN va en la otra dirección.

Por qué un JOIN duplica filas

Cuando una fila del lado «uno» se encuentra con varias filas del lado «muchos», se copia una vez por coincidencia. La reserva 65A6EA tiene cinco billetes:

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

Los 10.656 dólares de la reserva aparecen ahora cinco veces. Las filas no tienen nada de malo; el error llega cuando las sumas.

El doble conteo en la práctica

Ingresos de las reservas hechas el 2 de agosto, directamente desde 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

La misma suma después de unir los billetes, por ejemplo porque alguien quería también el número de billetes:

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

Los ingresos suben un 66 % sin ningún mensaje de error. COUNT(DISTINCT book_ref) sigue diciendo 5.761, lo que hace que la suma inflada parezca fiable.

El arreglo tentador, SUM(DISTINCT b.total_amount), es peor: devuelve 1.750.097,00, porque las reservas distintas que casualmente tienen el mismo importe se funden en una.

Dos multiplicaciones a la vez

Unir dos tablas «muchos» independientes al mismo padre las multiplica entre sí. El vuelo 4729 tiene 161 tramos vendidos y 161 tarjetas 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 fila de tramo se emparejó con todas las tarjetas de embarque del vuelo.

Cómo evitar el doble conteo

1. Agrega antes de unir. Lleva primero cada tabla «muchos» a la granularidad del padre y luego une uno a uno:

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

Ingresos correctos y número de billetes en una sola consulta.

2. Suma la medida en su propia granularidad. Si el dinero está en el lado «muchos», súmalo ahí. Los importes de los tramos de esas mismas reservas suman exactamente los totales de las 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';

Devuelve 4563873.00. Aquí la multiplicación de filas es inofensiva, porque cada amount pertenece a una sola fila de tramo.

3. Usa subconsultas escalares para conteos independientes. Para el caso de las dos multiplicaciones:

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. Comprueba el número de filas. Antes de fiarte de un total, compara COUNT(*) antes y después de cada JOIN, o cuadra el total con la tabla de origen como arriba. Si las filas crecieron y no lo esperabas, una clave no es única donde creías.

Para filtrar resultados agrupados después de un JOIN, mira GROUP BY y HAVING. Los JOIN también son buena parte de las preguntas de entrevista SQL, y en los ejercicios SQL resueltos tienes más casos para practicar.

Preguntas frecuentes

¿Qué diferencia hay entre INNER JOIN y LEFT JOIN?

INNER JOIN devuelve solo las filas que coinciden en los dos lados. LEFT JOIN devuelve todas las filas de la tabla izquierda; donde no hay pareja, las columnas de la tabla derecha son NULL. En los ejemplos de arriba, el INNER JOIN perdió el A320 sin vuelos y el LEFT JOIN lo conservó.

¿JOIN es lo mismo que INNER JOIN?

Sí. JOIN sin más es un INNER JOIN. Del mismo modo, LEFT JOIN y LEFT OUTER JOIN son lo mismo.

¿Para qué sirve RIGHT JOIN si existe LEFT JOIN?

Para nada que no pueda hacer un LEFT JOIN con las tablas en el orden contrario. Existe por simetría del estándar; la mayoría de los equipos lo evita para que todas las uniones se lean igual.

¿Por qué mi JOIN devuelve más filas que la tabla?

Porque una clave del otro lado no es única: cada fila se repite una vez por cada fila que coincide. En un JOIN de uno a muchos es lo esperado. Solo se convierte en un error cuando luego sumas una columna que pertenece al lado «uno».

¿Qué es un anti join?

Una consulta que devuelve las filas de una tabla que no tienen pareja en otra. En SQL se escribe con NOT EXISTS o con LEFT JOIN ... WHERE clave_derecha IS NULL. No existe una palabra clave ANTI JOIN en PostgreSQL.

¿Cuándo necesito un FULL OUTER JOIN?

Cuando comparas dos conjuntos y quieres ver en un solo resultado lo que está solo en el primero, solo en el segundo y en ambos: cuadrar dos sistemas, dos periodos o las rutas de dos aeropuertos.

También en Português (Brasil), Tiếng Việt, English, 简体中文