Intermedio10 min de lectura

DynamoDB JOIN: cómo unir tablas (y por qué normalmente no puedes)

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 — PartiQL, la capa de consulta con sabor a SQL, tampoco añade uno. Un SELECT de PartiQL lee exactamente una tabla.

Si vienes de una base de datos relacional, este es el primer muro contra el que chocas. Esta guía cubre por qué está ahí el muro, las cuatro cosas que los desarrolladores hacen en su lugar, el único caso en el que realmente necesitas un join de verdad — y cómo ejecutar uno.

¿Puede DynamoDB hacer joins?

No. DynamoDB no puede unir tablas — ni a través de la API de bajo nivel (GetItem / Query / Scan / BatchGetItem), ni a través de PartiQL, ni a través de ningún planificador de consultas integrado, porque no hay ninguno. Cada lectura mapea a una sola tabla o a uno de sus índices; combinar dos tablas por una clave coincidente es algo que haces en tu aplicación después de que DynamoDB devuelva los items, nunca dentro de ella.

  • DynamoDB no tiene operador JOIN. Nunca lo ha tenido.
  • El SELECT de PartiQL es solo de una tabla — la gramática es literalmente SELECT … FROM {{table}}[.{{index}}], y apuntarlo a dos tablas devuelve ValidationException: Only Select from a Single Table or index supported.
  • La solución que AWS recomienda es no necesitar un join: desnormaliza, o usa diseño de tabla única para que los items relacionados vivan en una partición que recuperas en una sola petición.
  • Para el caso genuino entre tablas / ad-hoc, unes fuera de DynamoDB — en tu aplicación, o con una herramienta que lo haga por ti.

Por qué DynamoDB no tiene joins

Un JOIN de SQL pide a la base de datos leer múltiples tablas y ensamblarlas en tiempo de consulta. La propia guía de AWS para modelar datos relacionales deletrea el coste: una consulta 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
  ORDER BY Quantity_on_Hand DESC

es flexible, pero «cada join en la consulta aumenta la complejidad en tiempo de ejecución de la consulta porque los datos de cada tabla deben prepararse y luego ensamblarse». Ese trabajo es ilimitado — su coste depende de los datos, no de la consulta — que es exactamente la propiedad que DynamoDB se niega a tener.

Así que AWS diseñó la restricción dentro. DynamoDB está, en sus palabras, «construido para minimizar ambas restricciones [de CPU y red] eliminando los JOIN (y fomentando la desnormalización de datos) y optimizando la arquitectura de la base de datos para responder completamente a una consulta de aplicación con una sola petición a un item». Esas son las cualidades que compran latencia de un dígito de milisegundo a cualquier escala: el coste en tiempo de ejecución de una lectura de DynamoDB es constante independientemente del 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 une?»

No. PartiQL te da sintaxis SELECT / INSERT / UPDATE / DELETE sobre DynamoDB, pero es compatible con SQL, no SQL. La gramática oficial de 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 una segunda tabla FROM, ni JOIN, ni subconsulta, ni CTE. Apunta PartiQL a dos tablas y DynamoDB lo rechaza (reportado en AWS re:Post):

ValidationException: Only Select from a Single Table or index supported

Si quieres el razonamiento completo de por qué PartiQL parece SQL pero no puede comportarse como tal, mira PartiQL frente a SQL.

Las 4 soluciones que los devs realmente usan

1. Desnormalizar (copiar los datos dentro)

Almacena los campos que de otro modo unirías directamente en el item. Un Order lleva una instantánea 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 tiempo de escritura: cuando la fuente cambia actualizas cada copia (normalmente vía un manejador de DynamoDB Streams). Estás cambiando complejidad de lectura por complejidad de escritura — normalmente un buen trato para una aplicación con muchas lecturas.

2. Diseño de tabla única (pre-unir en la partición)

Pon entidades relacionadas en una tabla bajo una clave de partición compartida para que una colección de items sea el resultado unido. Un cliente y todos sus pedidos comparten PK = "CUSTOMER#42"; un Query devuelve el item del cliente más cada item de pedido — el «join» ya ocurrió en tiempo de escritura.

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (el cliente)
→ CUSTOMER#42 / ORDER#1001   (un pedido)
→ CUSTOMER#42 / ORDER#1002   (un pedido)

Esta es la respuesta canónica de DynamoDB a las relaciones uno-a-muchos. Recorrido completo en diseño de tabla única.

3. Join del lado de la aplicación (dos lecturas, cosido en código)

Lee de la tabla A, toma las claves que obtuviste, lee de la tabla B, y fusiona los dos conjuntos de resultados en tu aplicación. Es la lógica de join relacional — solo ejecutándose en tu código en lugar de en la base de datos:

// "Obtener cada pedido con el nombre de su cliente" — el join manual.
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});

const customers = await Promise.all(
  orders.map((o) => ddb.getItem({TableName: 'Customers', Key: {id: o.customerId}}))
);

const joined = orders.map((o, i) => ({
  ...o,
  customerName: customers[i].Item?.name
}));

Bien para fan-out pequeño. Con muchos pedidos se convierte en un problema N+1 — una lectura para listar pedidos, luego una lectura por pedido — que es lento y quema capacidad de lectura. BatchGetItem (siguiente) colapsa esa segunda oleada en un solo viaje de ida y vuelta.

4. BatchGetItem (un viaje de ida y vuelta, múltiples tablas)

BatchGetItem es lo más cerca que llega 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. Recorta los viajes de ida y vuelta de un join del lado de la aplicación — pero no es un join. Tú «identificas los items solicitados por clave primaria»; no hay condición ON ni emparejamiento relacional. Aún tienes que conocer las claves por adelantado y coser las respuestas tú mismo.

Cuándo un JOIN real es inevitable

Las cuatro soluciones cubren bien las rutas de lectura de producción. Donde fallan es en la consulta ad-hoc, exploratoria, analítica — la que no modelaste:

  • «¿Qué clientes de la UE hicieron un pedido de más de 500 $ el mes pasado?» entre una tabla Orders y una tabla Customers.
  • Una comprobación puntual de calidad de datos uniendo dos tipos de entidad.
  • Informes y agregados (GROUP BY, SUM, COUNT) — para los que DynamoDB no tiene operador en absoluto.

Estas son exactamente las consultas que no puedes precocinar en una partición, porque por definición no sabías que las harías. El instinto relacional — escribir un JOIN — es el correcto aquí. DynamoDB simplemente no puede servirlo de forma nativa, y PartiQL tampoco.

La respuesta pesada habitual es exportar a S3 y consultar con Athena, o canalizar a un almacén. Eso es correcto para análisis reales a escala, pero es mucha fontanería para una pregunta que quieres respondida ahora, contra tu tabla en vivo.

Ejecutar un JOIN real con el SQL Workbench de DynoTable

DynoTable es un cliente de escritorio de DynamoDB cuyo SQL Workbench ejecuta SQL real — incluido JOIN, GROUP BY y funciones de agregación — sobre tus tablas de DynamoDB. Lee los items a través de la API normal de DynamoDB, y luego ejecuta las partes relacionales de la consulta en el cliente. Así que 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 conjunto de resultados, contra tablas que no tienen relación definida y un motor de consulta que no tiene palabra clave JOIN.

El aviso honesto — «dentro de las reglas de patrones de acceso de DynamoDB»: el Workbench aún lee a través de DynamoDB, así que un join ilimitado es una lectura ilimitada. Las consultas más rápidas son aquellas en las que 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 se ejecute el join. El Workbench no deroga las restricciones de esta guía — solo te deja hacer la pregunta SQL en lugar de escribir el cosido a mano tú mismo, y te dice qué está haciendo por debajo.

Es el único «sí, puedes unir» que es realmente cierto: PartiQL y el propio NoSQL Workbench de AWS — cuyo constructor de operaciones está limitado a operaciones del plano de datos de una sola tabla (Query / Scan / GetItem) — se detienen ambos en el muro de una tabla, como hace la mayoría de otros clientes con GUI. Mira cómo se compara DynoTable como GUI para DynamoDB.

Preguntas frecuentes

¿Soporta PartiQL JOIN? No. El SELECT de PartiQL lee una sola tabla (o uno de sus índices). Una consulta multi-tabla devuelve ValidationException: Only Select from a Single Table or index supported. El mismo muro que el resto de la API.

¿Puedes unir dos tablas de DynamoDB en una consulta? No de forma nativa. La API de DynamoDB no tiene sentencia que lea dos tablas y las empareje por una clave. BatchGetItem puede leer items de múltiples tablas en una petición, pero no tiene condición ON — devuelve los items que nombraste por clave primaria y deja el emparejamiento a ti. Un JOIN … ON … real solo ocurre fuera de DynamoDB: en tu aplicación, o en el SQL Workbench de DynoTable.

¿Puedes unir una tabla con su GSI? No — un Índice secundario global no es una tabla separada a la que te unas; es una vista de clave alternativa de los mismos items. Consultas o bien la tabla o bien el índice en un SELECT dado, no ambos unidos. Un GSI te deja alcanzar items por una clave diferente, lo que a menudo elimina la necesidad de un join en primer lugar.

¿Puedes unir entre dos cuentas de AWS (o dos tablas en cuentas diferentes)? No de forma nativa, y tampoco con BatchGetItem — una sola petición no puede abarcar credenciales, y no hay primitiva de join entre cuentas. Leerías cada tabla con las credenciales de su propia cuenta y unirías los resultados en tu aplicación o en una herramienta como el Workbench de DynoTable.

¿Es la desnormalización realmente mejor que un join? Para la carga de trabajo objetivo de DynamoDB — lecturas predecibles de alto volumen — sí. Mueves el coste al tiempo de escritura (y aceptas algo de duplicación de datos) a cambio de lecturas de una sola petición que escalan de forma plana. La guía de diseño de tabla única cubre los compromisos.


Construir las claves y condiciones para estas lecturas a mano es engorroso — el constructor de expresiones genera la sintaxis de KeyConditionExpression / FilterExpression por ti, y DynoTable ejecuta el SQL real cuando una solución alternativa no basta.

Actualizado