JOIN en DynamoDB: cómo unir tablas
No hay JOIN en DynamoDB. La API no tiene operador de join, el modelo de datos
no tiene claves foráneas, y — la parte que sorprende a la mayoría — ,
la capa de query con sabor SQL, tampoco añade uno. Un SELECT de PartiQL lee
exactamente una tabla.
Si vienes de una base relacional, este es el primer muro que te encuentras. Esta guía cubre por qué está el muro, las cuatro cosas que hacen los desarrolladores en su lugar, el único caso en el que de verdad necesitas un join real — y cómo ejecutar uno.
¿Puede DynamoDB hacer joins?
No. DynamoDB no puede unir tablas — ni vía la API de bajo nivel (GetItem /
Query / Scan / BatchGetItem), ni vía , ni vía ningún
planificador de queries integrado, porque no hay ninguno. Cada lectura mapea a
una tabla o a uno de sus índices; combinar dos tablas sobre una clave coincidente
es algo que haces en tu app después de que DynamoDB devuelve los items, nunca
dentro.
- DynamoDB no tiene ningún operador
JOIN. Nunca lo ha tenido. - El
SELECTde PartiQL es solo de una tabla — la gramática es literalmenteSELECT … FROM {{table}}[.{{index}}], y apuntarlo a dos tablas devuelveValidationException: Only select from a single table or index is supported. - El arreglo que recomienda AWS es no necesitar un join: , o usa single-table design para que los items relacionados vivan en una sola partición que traes en una sola petición.
- Para el caso genuino cross-table / ad hoc, haces join fuera de DynamoDB — en tu app, o con una herramienta que lo haga por ti.
Por qué DynamoDB no tiene joins
Un JOIN SQL pide a la base leer varias tablas y ensamblarlas en el momento de la
query. La
guía de AWS sobre modelado de datos relacionales
detalla el coste: una query como
SELECT * FROM Orders
INNER JOIN Order_Items ON Orders.Order_ID = Order_Items.Order_ID
INNER JOIN Products ON Products.Product_ID = Order_Items.Product_ID
INNER JOIN Inventories ON Products.Product_ID = Inventories.Product_ID
ORDER BY Quantity_on_Hand DESCes flexible, pero «cada join en la query aumenta la complejidad de runtime de la query porque los datos de cada tabla deben stagearse y luego ensamblarse». Ese trabajo es sin cota — su coste depende de los datos, no de la query — exactamente la propiedad que DynamoDB se niega a tener.
Así que AWS diseñó la restricción de entrada. DynamoDB está, en sus palabras,
«construido para minimizar ambas restricciones [CPU y red] eliminando los
JOIN (y fomentando la desnormalización de datos) y optimizando la arquitectura
de la base para responder del todo a una query de aplicación con una sola petición
a un item». Esas son las cualidades que compran latencia de un solo dígito de
milisegundo a cualquier escala: el coste de runtime de una lectura de DynamoDB es
constante da igual el tamaño de la tabla. No hay motor de join ni concepto de
clave foránea contra el que planificar — por diseño.
«Pero PartiQL es SQL, seguro que hace join?»
No. PartiQL te da la sintaxis SELECT / INSERT / UPDATE / DELETE sobre
DynamoDB, pero es compatible con SQL, no SQL. La
gramática oficial del SELECT
es:
SELECT {{expression}} [, ...]
FROM {{table}}[.{{index}}]
[ WHERE {{condition}} ]
[ ORDER BY {{key}} [DESC|ASC], ... ]FROM toma una tabla (opcionalmente uno de sus índices). No hay segunda
tabla FROM, no hay JOIN, no hay subquery, no hay CTE. Lanzamos las tres
contra DynamoDB para ver exactamente cómo falla cada una.
Un JOIN explícito:
SELECT o.pk FROM "Orders" o JOIN "Customers" c ON o.customerId = c.pkValidationException: Only select from a single table or index is supported.Dos tablas en FROM — el mismo rechazo, así que el motor no está rechazando el
keyword JOIN, está rechazando la segunda tabla:
SELECT * FROM "Orders", "Customers"ValidationException: Only select from a single table or index is supported.Una subquery falla de forma distinta, lo cual conviene saber si depuras una.
PartiQL ni siquiera llega al check multi-tabla — rechaza el operando de IN
antes, así que obtienes un mensaje que nunca menciona las tablas:
SELECT * FROM "Orders" WHERE customerId IN (SELECT pk FROM "Customers")ValidationException: IN operator must have a left hand argument of type Variable
Reference and right hand argument of type Seq with at least one memberLa consecuencia práctica: ninguna formulación te da un join. Las dos primeras mueren en la segunda tabla, y la tercera muere aún antes, en el operando.
Si quieres todo el razonamiento sobre por qué PartiQL parece SQL pero no puede comportarse como él, ver PartiQL vs SQL.
Los 4 workarounds que los devs usan de verdad
1. Desnormalizar (copiar los datos)
Guarda los campos que de otro modo joinearías directamente en el item. Una
Order lleva un snapshot del customerName y la shippingAddress en lugar de
un customerId que resolverías después. Una lectura, sin join.
El coste es el fan-out en el momento de escritura: cuando cambia la fuente, actualizas cada copia (típicamente vía un handler de ). Cambias complejidad de lectura por complejidad de escritura — normalmente un buen cambio para una app read-heavy.
2. Single-table design (pre-join en la partición)
Pon las entidades relacionadas en una sola tabla bajo una clave de partición
compartida para que una sea el resultado
joined. Un customer y todos sus pedidos comparten PK = "CUSTOMER#42"; un solo
Query devuelve el item del customer más cada item de pedido — el «join» ya
pasó en el momento de escritura.
Query PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE (the customer)
→ CUSTOMER#42 / ORDER#1001 (an order)
→ CUSTOMER#42 / ORDER#1002 (an order)
Esta es la respuesta canónica de DynamoDB a las relaciones one-to-many. Explicación completa en single-table design.
3. Join en la aplicación (dos lecturas, ensamblaje en el código)
Lee de la tabla A, toma las claves que trajiste, lee de la tabla B, y fusiona los dos result sets en tu aplicación. Es la lógica de join relacional — solo que corre en tu código en lugar de en la base:
// "Get each order with its customer name" — the manual join.
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});
const customers = await Promise.all(
orders.map((o) => ddb.get({TableName: 'Customers', Key: {id: o.customerId}}))
);
const joined = orders.map((o, i) => ({
...o,
customerName: customers[i].Item?.name
}));Bien para un fan-out pequeño. Con muchos pedidos, se convierte en un problema
N+1 — una lectura para listar pedidos, luego una lectura por pedido — lo cual
es lento y quema capacidad de lectura. BatchGetItem (abajo) reduce esa segunda
ola a un solo round-trip.
4. BatchGetItem (un round-trip, varias tablas)
BatchGetItem
es lo más cerca que tiene la API de «tocar dos tablas a la vez»: una petición
devuelve «los atributos de uno o más items de una o más tablas», hasta
100 items o 16 MB por llamada, lo que golpee primero. Corta los round-trips
de un join en la app — pero no es un join. «Identificas los items pedidos
por primary key»; no hay condición ON ni matching relacional. Sigues teniendo
que conocer las claves de antemano y ensamblar las respuestas tú mismo.
Cuándo un JOIN de verdad es inevitable
Los cuatro workarounds cubren bien los paths de lectura de producción. Donde se caen es la query ad hoc, exploratoria, analítica — aquella para la que no modelaste:
- «¿Qué customers de la UE hicieron un pedido de más de 500 $ el mes pasado?»
a través de una tabla
Ordersy una tablaCustomers. - Un check one-off de calidad de datos joineando dos tipos de entidad.
- Reporting y agregados (
GROUP BY,SUM,COUNT) — para los que DynamoDB no tiene ningún operador.
Esas son exactamente las queries que no puedes pre-hornear en una partición,
porque por definición no sabías que las ibas a hacer. El instinto relacional —
escribir un JOIN — es el correcto aquí. DynamoDB simplemente no puede
servirlo de forma nativa, y PartiQL tampoco.
La respuesta habitual y pesada es
exportar a S3 y consultar con Athena
(o usar el conector de query federada de Athena para hacer JOIN a una tabla
live), o canalizar a un warehouse. Correcto para analytics reales a gran escala,
pero mucha fontanería para una pregunta a la que quieres respuesta ahora,
contra tu tabla live.
Ejecutar un JOIN de verdad con el SQL Workbench de DynoTable
DynoTable es un cliente DynamoDB de escritorio cuyo SQL Workbench
ejecuta SQL de verdad — incluyendo JOIN, GROUP BY y funciones de agregación —
sobre tus tablas DynamoDB. Lee los items vía la API normal de DynamoDB, y luego
ejecuta las partes relacionales de la query en el cliente. Así puedes escribir:
SELECT c.name, SUM(o.total) AS spend
FROM Customers c
JOIN Orders o ON o.customerId = c.id
WHERE c.region = 'EU'
GROUP BY c.name
HAVING SUM(o.total) > 500— y obtener un result set, contra tablas que no tienen ninguna relación definida
y un motor de query que no tiene ningún keyword JOIN.
La salvedad honesta — «dentro de las reglas de access patterns de DynamoDB»:
el Workbench sigue leyendo a través de DynamoDB, así que un join sin cota es una
lectura sin cota. Las queries más rápidas son aquellas donde la cláusula WHERE
(o el atributo ON del join) golpea una clave de partición o un
GSI en al menos un lado, para que DynamoDB ejecute
un Query en lugar de un scan de tabla completa
antes de que corra el join. El Workbench no abroga las restricciones de esta
guía — solo te deja hacer la pregunta en SQL en lugar de escribir el ensamblaje
a mano, y te dice lo que hace por debajo.
Entre los clientes GUI, este es el único «sí, puedes hacer join» que es de verdad cierto: PartiQL y el propio NoSQL Workbench de AWS — cuyo operation builder ejecuta operaciones de una sola tabla e instrucciones PartiQL (sin JOIN, sin SELECT multi-tabla) — se paran ambos en el muro de una sola tabla, como la mayoría de los otros clientes GUI. Ver cómo se compara DynoTable como GUI de DynamoDB.
FAQ
¿Soporta PartiQL JOIN?
No. El SELECT de PartiQL lee una sola tabla (o uno de sus índices). Una query
multi-tabla devuelve ValidationException: Only select from a single table or index is supported. El mismo muro que el resto de la API.
¿Puedes unir dos tablas DynamoDB en una sola query?
No de forma nativa. La API de DynamoDB no tiene ninguna instrucción que lea dos
tablas y las matchee sobre una clave. BatchGetItem puede leer items de varias
tablas en una sola petición, pero no tiene condición ON — devuelve los items
que nombraste por primary key y te deja el matching. Un JOIN … ON … de verdad
solo ocurre fuera de DynamoDB: en tu app, o en el SQL Workbench de DynoTable.
¿Puedes unir una tabla a su GSI?
No — un global secondary index no es una tabla
separada a la que haces join; es una vista por clave alternativa de los mismos
items. Haces Query o a la tabla o al índice en un SELECT dado, no a ambos
joined. Un GSI te deja llegar a los items por una clave distinta, lo cual a
menudo elimina la necesidad de un join de entrada.
¿Puedes unir a través de dos cuentas AWS (o dos tablas en cuentas distintas)?
No de forma nativa — no hay primitiva de join cross-cuenta. BatchGetItem puede
alcanzar la tabla de otra cuenta si la política basada en recursos de esa tabla
concede acceso a tu caller (desde marzo de 2024), pero sigue sin tener condición
ON, así que es una lectura multi-tabla, no un join. Leerías cada lado y
joinearías los resultados en tu aplicación o en una herramienta como el Workbench
de DynoTable.
¿Es de verdad mejor la desnormalización que un join? Para la carga de trabajo objetivo de DynamoDB — lecturas previsibles de alto volumen — sí. Mueves el coste al momento de escritura (y aceptas cierta duplicación de datos) a cambio de lecturas de una sola petición que escalan de forma plana. La guía de single-table design cubre los trade-offs.
Construir a mano las claves y condiciones de estas lecturas es engorroso — el
Expression Builder genera la sintaxis
KeyConditionExpression / FilterExpression por ti, y
DynoTable ejecuta el SQL de verdad cuando un workaround no basta.
Mensajes de rechazo de PartiQL reproducidos el 11 de agosto de 2026 en DynamoDB Local (us-east-1,
@aws-sdk/client-dynamodb v3.1096.0), citados palabra por palabra desde
ValidationException.message.