Intermédiaire12 min de lecture

JOIN DynamoDB : comment joindre des tables

Il n'y a pas de JOIN dans DynamoDB. L'API n'a pas d'opérateur de jointure, le modèle de données n'a pas de clés étrangères, et — la partie qui surprend la plupart des gens — , la couche de requête au parfum SQL, n'en ajoute pas non plus. Un SELECT PartiQL lit exactement une table.

Si tu viens d'une base de données relationnelle, c'est le premier mur que tu heurtes. Ce guide couvre pourquoi le mur est là, les quatre choses que les développeurs font à la place, le seul cas où tu as vraiment besoin d'une vraie jointure — et comment en exécuter une.

DynamoDB peut-il faire des jointures ?

Non. DynamoDB ne peut pas joindre de tables — ni via l'API bas niveau (GetItem / Query / Scan / BatchGetItem), ni via , ni via un quelconque planificateur de requêtes intégré, parce qu'il n'y en a pas. Chaque lecture correspond à une table ou à l'un de ses index ; combiner deux tables sur une clé correspondante est quelque chose que tu fais dans ton app après que DynamoDB a renvoyé les items, jamais à l'intérieur.

  • DynamoDB n'a aucun opérateur JOIN. Il n'en a jamais eu.
  • Le SELECT de PartiQL est mono-table uniquement — la grammaire est littéralement SELECT … FROM {{table}}[.{{index}}], et le pointer vers deux tables renvoie ValidationException: Only select from a single table or index is supported.
  • Le correctif recommandé par AWS est de ne pas avoir besoin d'une jointure : , ou utilise le single-table design pour que les items liés vivent dans une seule partition que tu récupères en une seule requête.
  • Pour le vrai cas cross-table / ad hoc, tu joins en dehors de DynamoDB — dans ton app, ou avec un outil qui le fait pour toi.

Pourquoi DynamoDB n'a pas de jointures

Un JOIN SQL demande à la base de lire plusieurs tables et de les assembler au moment de la requête. Le guide d'AWS sur la modélisation de données relationnelles détaille le coût : une requête comme

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 DESC

est flexible, mais « chaque jointure dans la requête augmente la complexité d'exécution de la requête parce que les données de chaque table doivent être mises en scène puis assemblées ». Ce travail est non borné — son coût dépend des données, pas de la requête — ce qui est exactement la propriété que DynamoDB refuse d'avoir.

Alors AWS a intégré la contrainte par conception. DynamoDB est, selon leurs mots, « conçu pour minimiser à la fois les contraintes [CPU et réseau] en éliminant les JOIN (et en encourageant la dénormalisation des données) et en optimisant l'architecture de la base pour répondre pleinement à une requête d'application avec une seule requête vers un item ». Ce sont les qualités qui achètent une latence en millisecondes à un chiffre à n'importe quelle échelle : le coût d'exécution d'une lecture DynamoDB est constant quelle que soit la taille de la table. Il n'y a pas de moteur de jointure et pas de concept de clé étrangère à planifier — par conception.

« Mais PartiQL c'est du SQL, il joint sûrement ? »

Non. PartiQL te donne la syntaxe SELECT / INSERT / UPDATE / DELETE sur DynamoDB, mais il est compatible SQL, pas SQL. La grammaire officielle du SELECT est :

SELECT  {{expression}}  [, ...]
FROM    {{table}}[.{{index}}]
[ WHERE {{condition}} ]
[ ORDER BY {{key}} [DESC|ASC], ... ]

FROM prend une table (optionnellement l'un de ses index). Il n'y a pas de seconde table FROM, pas de JOIN, pas de sous-requête, pas de CTE. Nous avons exécuté les trois contre DynamoDB pour voir exactement comment chacun échoue.

Un JOIN explicite :

SELECT o.pk FROM "Orders" o JOIN "Customers" c ON o.customerId = c.pk
ValidationException: Only select from a single table or index is supported.

Deux tables dans FROM — même rejet : le moteur ne refuse donc pas le mot-clé JOIN, il refuse la seconde table :

SELECT * FROM "Orders", "Customers"
ValidationException: Only select from a single table or index is supported.

Une sous-requête échoue différemment, ce qui est bon à savoir si tu en débogues une. PartiQL n'atteint même pas la vérification multi-table — il rejette l'opérande de IN avant cela, donc tu obtiens un message qui ne mentionne jamais les tables :

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 member

La conséquence pratique : aucune formulation ne te donne une jointure. Les deux premières meurent sur la seconde table, et la troisième meurt encore plus tôt, sur l'opérande.

Si tu veux tout le raisonnement sur pourquoi PartiQL ressemble à du SQL mais ne peut pas se comporter comme lui, voir PartiQL vs SQL.

Les 4 contournements que les devs utilisent vraiment

1. Dénormaliser (copier les données)

Stocke les champs que tu joindrais autrement directement sur l'item. Une Order porte un instantané du customerName et de la shippingAddress au lieu d'un customerId que tu résoudrais plus tard. Une lecture, pas de jointure.

Le coût est le fan-out au moment de l'écriture : quand la source change, tu mets à jour chaque copie (typiquement via un handler ). Tu échanges la complexité de lecture contre la complexité d'écriture — généralement un bon échange pour une app à dominante lecture.

2. Single-table design (pré-joindre dans la partition)

Mets les entités liées dans une seule table sous une clé de partition partagée pour qu'une soit le résultat joint. Un client et toutes ses commandes partagent PK = "CUSTOMER#42" ; un seul Query renvoie l'item client plus chaque item de commande — la « jointure » a déjà eu lieu au moment de l'écriture.

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (the customer)
→ CUSTOMER#42 / ORDER#1001   (an order)
→ CUSTOMER#42 / ORDER#1002   (an order)

C'est la réponse canonique de DynamoDB aux relations un-à-plusieurs. Explication complète dans single-table design.

3. Jointure côté application (deux lectures, assemblage dans le code)

Lis depuis la table A, prends les clés que tu as récupérées, lis depuis la table B, et fusionne les deux jeux de résultats dans ton application. C'est la logique de jointure relationnelle — tournant juste dans ton code au lieu de 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 pour un petit fan-out. Avec de nombreuses commandes, ça devient un problème N+1 — une lecture pour lister les commandes, puis une lecture par commande — ce qui est lent et brûle de la capacité de lecture. BatchGetItem (ci-après) réduit cette seconde vague en un seul aller-retour.

4. BatchGetItem (un aller-retour, plusieurs tables)

BatchGetItem est ce que l'API a de plus proche de « toucher deux tables à la fois » : une requête renvoie « les attributs d'un ou plusieurs items d'une ou plusieurs tables », jusqu'à 100 items ou 16 MB par appel, selon ce qu'il atteint en premier. Ça coupe les allers-retours d'une jointure côté app — mais ce n'est pas une jointure. Tu « identifies les items demandés par clé primaire » ; il n'y a pas de condition ON ni de correspondance relationnelle. Tu dois toujours connaître les clés à l'avance et assembler les réponses toi-même.

Quand un vrai JOIN est inévitable

Les quatre contournements couvrent bien les chemins de lecture en production. Là où ils s'effondrent, c'est la requête ad hoc, exploratoire, analytique — celle pour laquelle tu n'as pas modélisé :

  • « Quels clients de l'UE ont passé une commande de plus de 500 $ le mois dernier ? » à travers une table Orders et une table Customers.
  • Un contrôle de qualité de données ponctuel joignant deux types d'entités.
  • Reporting et agrégats (GROUP BY, SUM, COUNT) — pour lesquels DynamoDB n'a aucun opérateur du tout.

Ce sont exactement les requêtes que tu ne peux pas pré-cuire dans une partition, parce que par définition tu ne savais pas que tu les poserais. L'instinct relationnel — écrire un JOIN — est le bon ici. DynamoDB ne peut simplement pas le servir nativement, et PartiQL non plus.

La réponse habituelle et lourde est d' exporter vers S3 et interroger avec Athena (ou d'utiliser le connecteur de requête fédérée d'Athena pour JOIN une table live), ou de canaliser vers un entrepôt. C'est correct pour de la vraie analytique à grande échelle, mais c'est beaucoup de plomberie pour une question à laquelle tu veux une réponse maintenant, contre ta table live.

Exécuter un vrai JOIN avec le SQL Workbench de DynoTable

DynoTable est un client DynamoDB de bureau dont le SQL Workbench exécute du vrai SQL — y compris JOIN, GROUP BY et les fonctions d'agrégation — sur tes tables DynamoDB. Il lit les items via l'API DynamoDB normale, puis exécute les parties relationnelles de la requête dans le client. Ainsi tu peux écrire :

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

— et obtenir un jeu de résultats, contre des tables qui n'ont aucune relation définie et un moteur de requête qui n'a aucun mot-clé JOIN.

La mise en garde honnête — « dans les règles de patterns d'accès de DynamoDB » : le Workbench lit toujours à travers DynamoDB, donc une jointure non bornée est une lecture non bornée. Les requêtes les plus rapides sont celles où la clause WHERE (ou l'attribut ON de la jointure) frappe une clé de partition ou un GSI sur au moins un côté, pour que DynamoDB exécute un Query plutôt qu'un scan de table complet avant que la jointure ne s'exécute. Le Workbench n'abroge pas les contraintes de ce guide — il te laisse juste poser la question en SQL au lieu d'écrire l'assemblage à la main, et il te dit ce qu'il fait en dessous.

Parmi les clients GUI, c'est le seul « oui, tu peux joindre » qui soit réellement vrai : PartiQL et le NoSQL Workbench d'AWS lui-même — dont le constructeur d'opérations exécute des opérations mono-table et des instructions PartiQL (pas de JOIN, pas de SELECT multi-table) — s'arrêtent tous deux au mur mono-table, comme la plupart des autres clients GUI. Vois comment DynoTable se compare en tant que GUI DynamoDB.

FAQ

PartiQL supporte-t-il JOIN ? Non. Le SELECT de PartiQL lit une seule table (ou l'un de ses index). Une requête multi-table renvoie ValidationException: Only select from a single table or index is supported. Le même mur que le reste de l'API.

Peux-tu joindre deux tables DynamoDB en une seule requête ? Pas nativement. L'API DynamoDB n'a aucune instruction qui lit deux tables et les fait correspondre sur une clé. BatchGetItem peut lire des items de plusieurs tables en une seule requête, mais il n'a pas de condition ON — il renvoie les items que tu as nommés par clé primaire et te laisse la correspondance. Un vrai JOIN … ON … n'a lieu qu'en dehors de DynamoDB : dans ton app, ou dans le SQL Workbench de DynoTable.

Peux-tu joindre une table à son GSI ? Non — un index secondaire global n'est pas une table séparée à laquelle tu joins ; c'est une vue par clé alternative des mêmes items. Tu Query soit la table soit l'index dans un SELECT donné, pas les deux joints ensemble. Un GSI te laisse atteindre les items par une clé différente, ce qui supprime souvent le besoin d'une jointure dès le départ.

Peux-tu joindre à travers deux comptes AWS (ou deux tables dans des comptes différents) ? Pas nativement — il n'y a pas de primitive de jointure cross-compte. BatchGetItem peut atteindre la table d'un autre compte si la politique basée sur les ressources de cette table accorde l'accès à ton appelant (depuis mars 2024), mais il n'a toujours pas de condition ON, c'est donc une lecture multi-table, pas une jointure. Tu lirais chaque côté et joindrais les résultats dans ton application ou dans un outil comme le Workbench de DynoTable.

La dénormalisation est-elle vraiment meilleure qu'une jointure ? Pour la charge de travail cible de DynamoDB — des lectures prévisibles à haut volume — oui. Tu déplaces le coût vers le moment de l'écriture (et acceptes une certaine duplication de données) en échange de lectures en une seule requête qui scalent à plat. Le guide single-table design couvre les arbitrages.


Construire les clés et conditions de ces lectures à la main est délicat — l' Expression Builder génère la syntaxe KeyConditionExpression / FilterExpression pour toi, et DynoTable exécute le vrai SQL quand un contournement ne suffit pas.

Messages de rejet PartiQL reproduits le 11 août 2026 sur DynamoDB Local (us-east-1, @aws-sdk/client-dynamodb v3.1096.0), cités mot pour mot depuis ValidationException.message.

Mis à jour