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

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
aircrafts: una fila por modelo de avión (aircraft_code,model,range)airports: una fila por aeropuerto (airport_code,airport_name,city)flights: una fila por salida;aircraft_code,departure_airportyarrival_airportapuntan a las dos tablas anterioresbookings: una fila por reserva, contotal_amounttickets: una fila por billete de pasajero; varios billetes pueden compartir un mismobook_refticket_flights: una fila por billete y tramo de vuelo, con el importe del tramo (amount)boarding_passes: una fila por tarjeta de embarque emitida para un tramo
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:
aircraftsestá a la izquierda, porque es la tabla cuyas filas tienen que sobrevivir todas.- La consulta cuenta
COUNT(f.flight_id), noCOUNT(*). Para el modelo sin pareja, el JOIN produce una fila conNULLen todas las columnas deflights;COUNT(*)contaría esa fila y daría 1.
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, 简体中文