SQL Joins — Guía Visual con Ejemplos
Guía visual de SQL joins: INNER, LEFT, RIGHT, FULL OUTER, CROSS y SELF joins con ejemplos prácticos, tips de rendimiento y errores comunes.
Visión General
Los joins en SQL combinan filas de dos o más tablas basándose en una columna relacionada. Son uno de los constructos más usados de las bases de datos relacionales y una de las fuentes más comunes de bugs y consultas lentas: un WHERE en el lugar equivocado convierte sin avisar un LEFT JOIN en INNER JOIN, un índice que falta convierte una búsqueda de 5 ms en un escaneo secuencial, y un join uno-a-muchos duplica filas en silencio e infla tus SUMs.
Aquí repasamos cada tipo de join que vas a cruzarte en producción, con un recorrido visual de lo que devuelve cada uno, más los algoritmos de join que el planificador realmente ejecuta, las variantes de sintaxis de las que nadie te avisa (USING, NATURAL, LATERAL) y los hábitos de rendimiento que mantienen los joins baratos a escala. Los ejemplos usan sintaxis de PostgreSQL; las diferencias con MySQL se señalan donde importan.
Cuándo Usar
- Combinar datos relacionados de dos o más tablas (órdenes + clientes, posts + autores)
- Filtrar filas según la presencia o ausencia de coincidencia en otra tabla
- Armar reportes que agregan datos de varias entidades
- Verificar integridad referencial o detectar registros huérfanos
Cuándo no usar un join: si solo necesitas saber si existe una fila que coincide, no sus columnas, un semi-join con EXISTS es más claro y suele ser más rápido. Si haces join de una tabla consigo misma una y otra vez para recorrer una jerarquía, un CTE recursivo suele ser la mejor herramienta. Para profundizar en la parte de optimización, consulta la guía completa de optimización de consultas SQL.
Cómo Ejecuta la Base de Datos un Join
JOIN es una operación lógica; lo que la base de datos ejecuta físicamente es uno de tres algoritmos. Saber cuál recibe tu consulta, y por qué, es la diferencia entre adivinar y afinar.
- Nested Loop — por cada fila de la entrada externa, busca coincidencias en la interna. Barato cuando el lado externo es pequeño y la clave de join interna está indexada; brutal cuando degenera en
filas_externas × filas_internascomparaciones. - Hash Join — construye una tabla hash sobre la entrada más pequeña y luego escanea la mayor sondeándola. Solo funciona con condiciones de igualdad; es el caballo de batalla para joins analíticos grandes.
- Merge Join — ambas entradas ordenadas por la clave de join, fusionadas como un cierre. Aparece cuando los datos ya están ordenados (escaneos de índice) o el resultado debe quedar ordenado.
No eliges el algoritmo directamente; lo hace el planificador a partir de las estadísticas de las tablas. Tu trabajo es darle buenas entradas: índices en las claves de join, estadísticas frescas y predicados que reduzcan las entradas temprano. Los hash joins además dependen de work_mem: cuando la tabla hash no entra en memoria vuelca a disco en lotes, que es la explicación habitual cuando un join grande se pone de golpe 10× más lento. La sección de EXPLAIN más abajo muestra cómo verificar qué eligió el planificador.
INNER JOIN — Solo Filas Coincidentes
Devuelve filas donde hay coincidencia en ambas tablas. JOIN a secas significa INNER JOIN; escribe la palabra completa igualmente; documenta la intención. Un INNER JOIN nunca puede introducir NULLs; cada columna de salida proviene de una coincidencia real, lo que lo convierte en el modelo mental más seguro por defecto.
SELECT o.order_id, c.name, o.total
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id;
Orders Customers Result
┌────┬──────────┐ ┌────┬────────┐ ┌────┬────────┬───────┐
│ id │ customer │ │ id │ name │ │ id │ name │ total │
├────┼──────────┤ ├────┼────────┤ ├────┼────────┼───────┤
│ 1 │ 101 │───────▶│101 │ Alice │──────▶│ 1 │ Alice │ 250 │
│ 2 │ 102 │──┐ │102 │ Bob │ │ 2 │ Bob │ 100 │
│ 3 │ 103 │ └────▶│103 │ Carol │──────▶│ 3 │ Carol │ 500 │
└────┴──────────┘ └────┴────────┘ └────┴────────┴───────┘
(sin fila para 104)
Ojo: en una relación uno-a-muchos el resultado tiene más filas que cualquiera de las entradas: una por cada par coincidente. Un cliente con 10 órdenes produce 10 filas. Esa multiplicación es correcta, pero también es la razón por la que un COUNT(*) ingenuo después de un join sorprende a tanta gente.
LEFT JOIN — Todas de la Izquierda, Coincidentes de la Derecha
Devuelve cada fila de la tabla izquierda, más las filas coincidentes de la derecha. Las columnas del lado derecho sin coincidencia vuelven como NULL.
SELECT c.name, o.order_id, o.total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id;
Customers Orders Result
┌────┬────────┐ ┌────┬──────────┐ ┌────┬────────┬───────┐
│ id │ name │ │ id │ customer │ │ id │ name │ total │
├────┼────────┤ ├────┼──────────┤ ├────┼────────┼───────┤
│101 │ Alice │─────────▶│ 1 │ 101 │─────▶│101 │ Alice │ 250 │
│102 │ Bob │─────────▶│ 2 │ 102 │─────▶│102 │ Bob │ 100 │
│103 │ Carol │─────────▶│ 3 │ 103 │─────▶│103 │ Carol │ 500 │
│104 │ Dave │───✕──────│ │ │─────▶│104 │ Dave │ NULL │
└────┴────────┘ └────┴──────────┘ └────┴────────┴───────┘
Las filas NULL son la función buscada: filtrar sobre ellas es la forma clásica de encontrar clientes sin órdenes:
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.order_id IS NULL;
Un detalle que le juega una mala pasada a todo el mundo alguna vez: un predicado sobre la tabla derecha va en ON, no en WHERE. ON c.id = o.customer_id AND o.total > 100 conserva a todos los clientes y solo anula las órdenes baratas; WHERE o.total > 100 descarta a Dave por completo; escribiste un INNER JOIN con pasos extra.
RIGHT JOIN — Todas de la Derecha, Coincidentes de la Izquierda
El espejo de LEFT JOIN: cada fila de la tabla derecha, coincidencias de la izquierda. Existe por completitud; en la práctica, intercambia el orden de las tablas y escribe LEFT JOIN. a LEFT JOIN b y b RIGHT JOIN a producen resultados idénticos, y la primera forma se lee de izquierda a derecha como se escanean las consultas.
FULL OUTER JOIN — Todas las Filas de Ambas Tablas
Devuelve cada fila de ambas tablas, coincidentes donde sea posible y rellenadas con NULL donde no. Es el join para trabajo de reconciliación: “muéstrame todo lo que existe en cualquiera de los lados”.
SELECT c.name, o.order_id, o.total
FROM customers c
FULL OUTER JOIN orders o ON c.id = o.customer_id;
Result
┌─────┬────────┬───────┐
│ id │ name │ total │
├─────┼────────┼───────┤
│ 101 │ Alice │ 250 │
│ 102 │ Bob │ 100 │
│ 103 │ Carol │ 500 │
│ 104 │ Dave │ NULL │ ◀── cliente sin orden
│ 5 │ NULL │ 75 │ ◀── orden huérfana (sin cliente)
└─────┴────────┴───────┘
MySQL no implementa FULL OUTER JOIN. Se emula con un UNION de un LEFT JOIN y un anti-join:
SELECT c.name, o.order_id, o.total
FROM customers c LEFT JOIN orders o ON c.id = o.customer_id
UNION
SELECT c.name, o.order_id, o.total
FROM customers c RIGHT JOIN orders o ON c.id = o.customer_id
WHERE c.id IS NULL;
CROSS JOIN — Producto Cartesiano
Cada fila de la primera tabla combinada con cada fila de la segunda. El tamaño del resultado es filas_a × filas_b, razón por la cual aparece por accidente mucho más seguido que a propósito.
-- 3 colores × 4 talles = 12 filas
SELECT c.color, s.size
FROM colors c
CROSS JOIN sizes s;
Usos legítimos: generar una grilla dimensional (todos los SKUs talle/color, todas las combinaciones fecha × tienda para el esqueleto de un reporte), o unir con un resultado de una sola fila. Uso accidental: la vieja sintaxis con coma FROM a, b es un CROSS JOIN si olvidas el WHERE, una razón por la que ganó la sintaxis explícita JOIN ... ON.
SELF JOIN — Unir una Tabla Consigo Misma
Una tabla puede aparecer dos veces en FROM con alias distintos. El uso canónico es una jerarquía guardada como referencia al padre: empleados y managers:
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
Employees
┌────┬────────┬───────────┐
│ id │ name │ manager_id│
├────┼────────┼───────────┤
│ 1 │ Alice │ NULL │ ◀── CEO
│ 2 │ Bob │ 1 │
│ 3 │ Carol │ 1 │
│ 4 │ Dave │ 2 │
└────┴────────┴───────────┘
Result
┌─────────┬─────────┐
│employee │ manager │
├─────────┼─────────┤
│ Alice │ NULL │
│ Bob │ Alice │
│ Carol │ Alice │
│ Dave │ Bob │
└─────────┴─────────┘
LEFT (no INNER) para que el CEO, que no tiene manager, siga apareciendo. Los self joins también sirven para comparaciones del tipo “filas del mismo grupo”, como encontrar clientes que hicieron pedidos el mismo día.
Semi-Joins y Anti-Joins — EXISTS vs JOIN
Dos preguntas suenan a joins pero en realidad son verificaciones de existencia, y SQL tiene constructos dedicados para ellas.
Un semi-join pregunta “¿existe una coincidencia?”; devuelve cada fila izquierda como máximo una vez y deja de buscar al primer acierto. EXISTS es la forma legible de escribirlo:
-- Clientes que tienen al menos una orden (sin duplicados, sin importar cuántas órdenes)
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
El equivalente con INNER JOIN necesita DISTINCT para deduplicar, lo que cuesta un ordenamiento o agregación hash. Los planificadores de PostgreSQL y MySQL suelen reescribir EXISTS como semi-join internamente; mira EXPLAIN si te da curiosidad.
Un anti-join pregunta “¿no existe ninguna coincidencia?”: registros huérfanos, clientes que se fueron, productos sin vender. Dos formas correctas de escribirlo:
-- Preferida: NOT EXISTS (segura con NULLs)
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- Equivalente: LEFT JOIN + IS NULL
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;
La forma a evitar es NOT IN sobre una columna anulable. NOT IN (subconsulta) usa lógica de tres valores: si la subconsulta devuelve aunque sea un solo NULL, toda comparación queda como UNKNOWN y la consulta devuelve cero filas, en silencio. NOT EXISTS no tiene esa trampa, y por eso la mayoría de los equipos lo adopta como estándar.
Variantes de Sintaxis de JOIN — USING, NATURAL, Multi-Columna, LATERAL
Más allá de ON, SQL ofrece algunas formas abreviadas y especializadas que conviene conocer, y una que conviene evitar.
USING (columna) — cuando ambos lados comparten el nombre de la columna, USING elimina la repetición y colapsa las dos columnas de join en una sola en la salida:
SELECT o.order_id, c.name, o.total
FROM orders o
JOIN customers c USING (customer_id);
NATURAL JOIN — une por todas las columnas con el mismo nombre en ambas tablas, incluidas las que no pretendías (created_at, updated_at, status). Agregar una columna con nombre repetido después cambia el significado de la consulta en silencio. Queda lindo en tutoriales; en esquemas de producción es un peligro de mantenimiento; escribe las claves de join explícitamente.
Joins multi-columna — las claves compuestas simplemente usan AND:
SELECT *
FROM orders o
JOIN order_items oi
ON o.order_id = oi.order_id
AND o.store_id = oi.store_id;
LATERAL (PostgreSQL) — permite que el lado derecho de un join referencie columnas del izquierdo, convirtiendo una subconsulta en FROM en un cálculo correlacionado por fila. El uso clásico es “top N por grupo”: las 3 órdenes más recientes por cliente.
SELECT c.name, recent.order_id, recent.total
FROM customers c
LEFT JOIN LATERAL (
SELECT o.order_id, o.total
FROM orders o
WHERE o.customer_id = c.id
ORDER BY o.created_at DESC
LIMIT 3
) AS recent ON true;
Sin LATERAL, ese patrón necesita una función de ventana sobre toda la tabla y un filtro, también un enfoque válido (ver funciones de ventana en SQL) pero que escanea cada fila de órdenes antes de rankear.
Unir Más de Dos Tablas
Los joins se encadenan de izquierda a derecha, y cada JOIN ... ON adjunta una tabla más al resultado en marcha:
SELECT c.name, o.order_id, p.name AS product
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id;
Algunas cosas que conviene saber cuando las consultas pasan de dos tablas:
- El planificador reordena los joins libremente. No los ejecuta en el orden en que los escribiste; les asigna costo a cada orden plausible y elige el más barato. Escríbelos en el orden que mejor se lea para un humano; el optimizador se ocupa del resto. En PostgreSQL,
join_collapse_limit(por defecto 8) limita hasta dónde explora; las consultas que lo superan pueden conservar el orden escrito, una razón más para escribir un orden sensato de todos modos. - Mezclar INNER y LEFT tiene dirección. En cuanto las columnas del lado derecho de un
LEFT JOINalimentan unINNER JOIN, las filas NULL se descartan y la garantía “left” desaparece. Si todo lo que sigue aordersdebe seguir siendo opcional, todos los joins posteriores también tienen que serLEFT. - El conteo de filas intermedias es el costo real. Una consulta que arranca desde la entrada filtrada más pequeña suele ganar. Filtrar temprano (
WHEREsobre la tabla conductora) achica cada paso posterior.
JOINs con Agregación — GROUP BY y HAVING
Los joins alimentan agregaciones; las agregaciones exponen los bugs de los joins. Un reporte tipo “gasto total por cliente” une y agrupa en una sola pasada:
SELECT
c.name,
COUNT(o.order_id) AS total_orders,
COALESCE(SUM(o.total), 0) AS total_spent,
MAX(o.created_at) AS last_order
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
ORDER BY total_spent DESC;
Tres detalles importan aquí:
LEFT JOINconserva a los clientes con cero órdenes;COALESCEconvierte suSUMNULLen0.COUNT(o.order_id)cuenta valores no NULL, así que los clientes sin órdenes reciben correctamente0.COUNT(*)les daría1, el clásico off-by-one.HAVINGfiltra después de agrupar,WHEREfiltra antes. Para encontrar clientes con más de 3 órdenes:
SELECT c.name, COUNT(o.order_id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
HAVING COUNT(o.order_id) > 3
ORDER BY order_count DESC;
Y una advertencia que merece su propia oración: cuando unes dos tablas uno-a-muchos (por ejemplo, órdenes y envíos por cliente), los conteos de filas se multiplican entre sí y todo SUM queda inflado. Agrega cada tabla de hechos primero y une los agregados después, o usa COUNT(DISTINCT ...) y SUM en subconsultas separadas. Es el bug de números incorrectos más común en reportes de producción.
Optimización de Rendimiento
Los joins son donde vive o muere el rendimiento de una consulta. Estas son las palancas, aproximadamente en orden de impacto.
Indexa las Columnas de Join
La clave de join del lado “muchos” necesita un índice, o cada join se convierte en un escaneo. Las claves primarias ya están indexadas; las foráneas normalmente no; PostgreSQL no las indexa automáticamente:
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
Si una consulta solo necesita columnas cubiertas por un índice, un covering index permite a la base de datos saltarse la tabla por completo (index-only scan):
CREATE INDEX idx_orders_customer_total ON orders(customer_id, total);
Para la estrategia de indexación más amplia, consulta estrategias de índices.
No Hagas Join sobre Valores Calculados
Envolver una columna de join en una función anula el índice:
-- Lento: UPPER impide el uso de índices en ambos lados
SELECT * FROM orders o
JOIN customers c ON UPPER(o.customer_email) = UPPER(c.email);
-- Rápido: join directo sobre la columna indexada
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id;
Si debes unir por un valor normalizado, o lo guardas normalizado (columna generada con su propio índice) o creas un índice por expresión, pero arreglar el esquema suele ser la mejor respuesta.
Filtra Antes de Unir — y Sabe Dónde Va el Filtro
Entradas más chicas significan joins más baratos. Con un INNER JOIN, filtrar en ON o en WHERE es equivalente: el planificador los trata igual. Con un LEFT JOIN no es equivalente: las condiciones en ON deciden qué cuenta como coincidencia, las de WHERE filtran el resultado final (y un WHERE sobre la tabla derecha deshace el LEFT en silencio). Pon la lógica de join en ON y el filtrado de resultados en WHERE, y la consulta dice lo que hace.
-- Clientes, solo con sus órdenes grandes adjuntas (Dave sobrevive con NULLs)
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id AND o.total > 100;
-- Clientes que tienen al menos una orden grande (Dave desapareció)
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.total > 100;
Mantén las Estadísticas Frescas
El planificador elige nested loop vs. hash join a partir de estimaciones de filas. Después de una carga masiva o un borrado grande, las estadísticas viejas causan el clásico plan “eligió nested loop sobre 2 millones de filas”. Ejecuta ANALYZE (autovacuum normalmente lo cubre, pero las operaciones masivas lo superan):
ANALYZE orders;
Evita OR entre Tablas Unidas
Un OR entre condiciones de tablas distintas anula la mayoría de las estrategias de join. Dividir en UNION permite que cada rama use sus propios índices:
SELECT c.name FROM customers c
JOIN orders o ON c.id = o.customer_id WHERE o.total > 500
UNION
SELECT c.name FROM customers c
JOIN returns r ON c.id = r.customer_id WHERE r.amount > 500;
Verifica con EXPLAIN
No adivines; lee el plan. EXPLAIN ANALYZE muestra qué algoritmo de join corrió y qué tan lejos quedaron las estimaciones de filas:
EXPLAIN ANALYZE
SELECT c.name, o.total
FROM customers c
JOIN orders o ON c.id = o.customer_id;
Qué buscar: Nested Loop sobre una estimación de filas enorme (falta el índice o las estadísticas están viejas), Seq Scan alimentando un Hash Join donde debería existir un índice, y filas estimadas muy distintas de las reales (ejecuta ANALYZE).
Escenario Real: Reportes de E-commerce
Un reporte multi-tabla realista une todo lo anterior. Cinco tablas: customers, orders, order_items, products, categories — y cuatro preguntas que el negocio efectivamente hace:
-- Query 1: Top 10 clientes por gasto total
SELECT c.id, c.email, SUM(oi.line_total) AS total_spent,
COUNT(DISTINCT o.id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
INNER JOIN order_items oi ON o.id = oi.order_id
WHERE o.status = 'completed'
AND o.created_at >= '2026-01-01'
GROUP BY c.id, c.email
ORDER BY total_spent DESC
LIMIT 10;
-- Query 2: Ventas por categoría (mes actual)
SELECT cat.name AS category,
SUM(oi.line_total) AS revenue,
SUM(oi.quantity) AS units_sold,
COUNT(DISTINCT o.id) AS order_count
FROM categories cat
INNER JOIN products p ON p.category_id = cat.id
INNER JOIN order_items oi ON oi.product_id = p.id
INNER JOIN orders o ON o.id = oi.order_id
WHERE o.status = 'completed'
AND o.created_at >= DATE_TRUNC('month', NOW())
GROUP BY cat.name
ORDER BY revenue DESC;
-- Query 3: Clientes sin órdenes en 90 días (candidatos a churn)
SELECT c.id, c.email, MAX(o.created_at) AS last_order
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.email
HAVING MAX(o.created_at) < NOW() - INTERVAL '90 days'
OR MAX(o.created_at) IS NULL
ORDER BY last_order DESC NULLS LAST;
-- Query 4: Productos nunca comprados (anti-join)
SELECT p.sku, p.name, p.price
FROM products p
LEFT JOIN order_items oi ON p.id = oi.product_id
WHERE oi.id IS NULL
AND p.is_active = true
ORDER BY p.created_at DESC;
-- Índices que hacen baratas estas consultas:
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_status_date ON orders(status, created_at);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);
CREATE INDEX idx_products_category ON products(category_id);
Nota el patrón en las cuatro: join sobre claves indexadas, filtrar por estado y fecha antes de que el planificador tenga que arrastrar esas filas por el join, y usar LEFT JOIN ... IS NULL (o NOT EXISTS) para las preguntas de “nunca pasó” en lugar de NOT IN.
Buenas Prácticas
- Escribe
INNER JOINexplícitamente.JOINa secas funciona, pero la palabra completa elimina ambigüedad para quien lea la consulta después. - Alias en cada tabla, califica cada columna.
o.customer_id, nocustomer_id; las consultas multi-tabla se rompen en cuanto dos tablas comparten un nombre de columna, y tarde o temprano siempre pasa. - Indexa las claves foráneas vos mismo. PostgreSQL no lo hace por vos; MySQL solo cuando se declara el constraint. Cada FK sin índice es un nested loop esperando a escalar.
- Predicados de join en
ON, predicados de resultado enWHERE. Sobre todo conLEFT JOIN, donde mover una línea cambia el resultado. - Usa
EXISTS/NOT EXISTSpara verificaciones de existencia. Más claro queJOIN+DISTINCT, y seguro dondeNOT INno lo es. - Agrega primero, une después. Cuando dos tablas de hechos hacen fan-out, une los resultados de sus
GROUP BY, no sus filas crudas. - Di que no a
NATURAL JOIN. Las claves explícitas sobreviven cambios de esquema; las implícitas no.
Errores Comunes
- Joins implícitos con coma.
FROM orders, customerssinWHEREes unCROSS JOIN. ConWHEREes unINNER JOINdisfrazado; de cualquier forma, escribe la sintaxis explícita. - Filtrar la tabla derecha de un LEFT JOIN en
WHERE.WHERE o.col ...descarta las filas NULL que elLEFT JOINexiste para conservar. Mueve el predicado aONo acepta que queríasINNER JOIN. NOT INcontra una columna anulable. UnNULLen el resultado de la subconsulta y toda la consulta devuelve cero filas, sin error, sin aviso. UsaNOT EXISTS.- Confiar en
COUNT(*)ySUMdespués de un join con fan-out. Los joins uno-a-muchos multiplican filas; agrega antes de unir o cuenta claves distintas. - Consultas N+1 desde el código de aplicación. Traer clientes y luego consultar sus órdenes en un bucle es un nested loop distribuido con latencia de red por iteración. Un solo join, o eager loading en el ORM, gana siempre.
- Unir por columnas sin índice o de tipos distintos.
bigintunido avarchar, o columnas con collations distintas, puede deshabilitar el uso de índices en silencio. Iguala tipos y collations, e indexa las claves. NATURAL JOINyUSINGa ciegas en esquemas inestables. Todo lo que infiere claves por nombre se rompe el día que aparece una columna con el mismo nombre.
Solución de Problemas
- La consulta devuelve más filas de las que tiene la tabla izquierda. Esperado en un join uno-a-muchos: cuenta los pares, no las filas de la izquierda. Si el conteo también está mal, probablemente estás uniendo dos tablas del lado “muchos” a la vez; agrega cada lado primero.
NOT INdevuelve cero filas sin razón visible. Hay unNULLen el resultado de la subconsulta. Reescribe comoNOT EXISTS(o agregaWHERE col IS NOT NULLdentro de la subconsulta) y las filas vuelven.- El join era rápido en desarrollo y lento en producción. En desarrollo había 100 filas; en producción hay millones y un índice de FK faltante.
EXPLAINmostrará un nested loop o escaneo secuencial; indexa la clave de join. - El plan usa nested loops donde debería ir un hash join. Las estimaciones de filas están desviadas. Ejecuta
ANALYZEen ambas tablas; si persiste, el predicado está sesgando las estimaciones; un objetoCREATE STATISTICSo un predicado reescrito suele arreglarlo. - El
LEFT JOINse comporta comoINNER JOIN. En algún punto posterior (WHERE, un segundo join, un requisitoNOT NULL) las filas NULL se están descartando. Revisa columnas de la tabla derecha referenciadas enWHERE. - Deadlocks con escrituras concurrentes que tocan tablas unidas. Las transacciones deben bloquear las tablas en un orden consistente; el join en sí no es el problema, sino el patrón de escritura alrededor.
Lectura Adicional
- PostgreSQL Tutorial — Joins — recorrido oficial por los tipos de join
- PostgreSQL Queries — Table Expressions — semántica de
JOIN,USING,LATERAL - MySQL 8.4 — JOIN Clause — incluida la ausencia de
FULL OUTER JOIN - PostgreSQL — EXPLAIN — cómo leer planes de join
- En este sitio: guía de CTE en SQL para jerarquías recursivas, y normalización de bases de datos para el lado del esquema en diseños con muchos joins.
Preguntas frecuentes
¿Cuál es la diferencia entre JOIN e INNER JOIN?
Ninguna — JOIN es la forma corta de INNER JOIN. Producen planes idénticos. Escribir INNER simplemente queda más claro para quien lee.
¿Cuándo debería usar LEFT JOIN en vez de INNER JOIN?
Usa LEFT JOIN cuando las filas de la izquierda importan aunque no tengan coincidencia: todos los clientes, con órdenes si las hay. Si una coincidencia ausente debería eliminar la fila, INNER JOIN es la opción honesta, y suele ser la más barata para el planificador.
¿Cómo encuentro filas de una tabla que no tienen coincidencia en otra?
NOT EXISTS o LEFT JOIN ... WHERE right.id IS NULL: ambos son anti-joins y seguros con NULL. Evita NOT IN sobre columnas anulables: un solo NULL en la subconsulta hace que no devuelva nada.
¿Por qué mi join devuelve más filas que la tabla de origen?
Los joins devuelven pares. Un cliente con 10 órdenes produce 10 filas en un join uno-a-muchos. Si los conteos o sumas parecen inflados, agrega cada tabla por separado antes de unir; es el problema de fan-out, no un bug.
¿Cómo evito el problema N+1 en los ORMs?
Usa eager loading para que el ORM emita un solo join en lugar de una consulta por fila. En Prisma: include: { orderItems: true }. En TypeORM: relations: ['orderItems']. En SQLAlchemy: joinedload(Order.items). En Django: prefetch_related('items') (o select_related para relaciones de valor único, que es un join real). Observa las consultas que el ORM realmente emite; Django Debug Toolbar o el query logging de Prisma lo hacen visible.
¿Cuál es la diferencia entre un hash join y un nested loop join?
Un nested loop busca cada fila externa en la entrada interna; barato cuando el lado externo es pequeño y la clave de join interna está indexada. Un hash join construye una tabla hash sobre la entrada menor y la sondea con la mayor; mejor para dos entradas grandes con condición de igualdad. El planificador elige según estimaciones de filas; mantén las estadísticas frescas para que elija bien.
Recursos Relacionados
Funciones de Ventana SQL — Referencia Detallada
Guía práctica de funciones de ventana SQL: ROW_NUMBER, RANK, DENSE_RANK, LEAD, LAG, SUM, AVG sobre particiones y casos de uso de analítica real.
GuideCTEs en SQL: Expresiones de Tablas Comunes Explicadas
Guía práctica de CTEs en SQL: expresiones no recursivas y recursivas, legibilidad, rendimiento y cuándo usarlas sobre subconsultas.
GuideEstrategias de Indexación — Desde B-Trees hasta BRIN
Guía práctica de índices de bases de datos: B-Trees, Hash, GIN, GiST, BRIN e índices parciales. Aprende cuándo usar cada uno y cómo evitar errores comunes de indexación.
GuideNormalización de Bases de Datos — 1NF a 5NF Explicado
Guía visual de normalización de bases de datos: aprende 1NF a 5NF con ejemplos prácticos, cuándo aplicar cada forma y cómo balancear normalización con rendimiento.
RecipeConectar a PostgreSQL
Cómo conectar a bases de datos PostgreSQL en Python, JavaScript y Java.
RecipeEjecutar SQL Crudo
Cómo ejecutar consultas SQL crudas de forma segura con sentencias parametrizadas.