Intermédiaire10 min de lecture

DynamoDB JOIN : comment joindre des tables (et pourquoi tu ne peux généralement pas)

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 le plus — PartiQL, la couche de requête à saveur 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 cas où tu as réellement besoin d'une vraie jointure — et comment en exécuter une.

DynamoDB peut-il faire des jointures ?

Non. DynamoDB ne peut pas joindre des tables — ni via l'API bas niveau (GetItem / Query / Scan / BatchGetItem), ni via PartiQL, ni via un quelconque planificateur de requêtes intégré, parce qu'il n'y en a pas. Chaque lecture mappe vers une seule 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 t'a renvoyé les items, jamais à l'intérieur.

  • DynamoDB n'a pas d'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 supported.
  • Le correctif recommandé par AWS est de ne pas avoir besoin d'une jointure : dénormalise, 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 cas véritablement inter-tables / ad hoc, tu joins en dehors de DynamoDB — dans ton app, ou avec un outil qui le fait pour toi.

Ce n'est pas un manque qu'AWS a oublié de combler. C'est une décision de conception délibérée, et le raisonnement vaut la peine d'être compris avant de te tourner vers un contournement.

Pourquoi DynamoDB n'a pas de jointures

Un JOIN SQL demande à la base de données de lire plusieurs tables et de les assembler au moment de la requête. Le propre guide d'AWS pour modéliser des données relationnelles en 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
  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 attente puis assemblées ». Ce travail est non borné — son coût dépend des données, pas de la requête — qui est exactement la propriété que DynamoDB refuse d'avoir.

Donc AWS a intégré la contrainte. DynamoDB est, selon leurs mots, « conçu pour minimiser ces deux contraintes [CPU et réseau] en éliminant les JOINs (et en encourageant la dénormalisation des données) et en optimisant l'architecture de la base de données pour répondre pleinement à une requête applicative avec une seule requête vers un item ». Ce sont les qualités qui achètent une latence de quelques millisecondes à 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 ni 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 SELECT officielle 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 deuxième table FROM, pas de JOIN, pas de sous-requête, pas de CTE. Pointe PartiQL vers deux tables et DynamoDB le rejette (rapporté sur AWS re:Post) :

ValidationException: Only Select from a Single Table or index supported

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

Les 4 contournements que les devs utilisent vraiment

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

Stocke les champs que tu joindrais autrement directement sur l'item. Une Order porte un snapshot 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 à l'écriture : quand la source change, tu mets à jour chaque copie (typiquement via un handler DynamoDB Streams). Tu échanges la complexité de lecture contre la complexité d'écriture — généralement un bon échange pour une app à forte lecture.

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

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

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (le client)
→ CUSTOMER#42 / ORDER#1001   (une commande)
→ CUSTOMER#42 / ORDER#1002   (une commande)

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

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

Lis dans la table A, prends les clés que tu as récupérées, lis dans la table B, et fusionne les deux jeux de résultats dans ton application. C'est la logique de jointure relationnelle — qui tourne juste dans ton code au lieu de la base de données :

// « Récupérer chaque commande avec le nom de son client » — la jointure manuelle.
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
}));

Correct pour un petit fan-out. Avec de nombreuses commandes, cela devient un problème N+1 — une lecture pour lister les commandes, puis une lecture par commande — qui est lent et brûle de la capacité de lecture. BatchGetItem (ci-après) replie cette deuxième vague en un seul aller-retour.

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

BatchGetItem est ce que l'API approche le plus 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 Mo par appel, selon ce qu'il atteint en premier. Il coupe les aller-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 quand même 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 de production. Là où ils échouent, c'est la requête ad hoc, exploratoire, analytique — celle que tu n'as pas modélisée :

  • « Quels clients dans l'UE ont passé une commande de plus de 500 $ le mois dernier ? » entre une table Orders et une table Customers.
  • Une vérification ponctuelle de qualité de données joignant deux types d'entités.
  • Le reporting et les 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 juste pas le servir nativement, et PartiQL non plus.

La réponse lourde habituelle est d' exporter vers S3 et interroger avec Athena, ou de canaliser vers un entrepôt. C'est correct pour de la vraie analytique à l'échelle, mais c'est beaucoup de plomberie pour une question à laquelle tu veux une réponse maintenant, contre ta table en direct.

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 fonctions d'agrégation — sur tes tables DynamoDB. Il lit les items à travers l'API DynamoDB normale, puis exécute les parties relationnelles de la requête côté client. Tu peux donc é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êtes qui n'a aucun mot-clé JOIN.

La mise en garde honnête — « dans les règles de mode 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) touche une partition key ou un GSI d'au moins un côté, pour que DynamoDB exécute un Query plutôt qu'un scan de table complète 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 SQL au lieu d'écrire l'assemblage à la main, et te dit ce qu'il fait en dessous.

C'est le seul « oui, tu peux joindre » qui soit réellement vrai : PartiQL et le propre NoSQL Workbench d'AWS — dont le constructeur d'opérations est limité aux opérations data-plane mono-table (Query / Scan / GetItem) — 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-tables renvoie ValidationException: Only Select from a Single Table or index 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 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'arrive 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 te joins ; c'est une vue par clé alternative des mêmes items. Tu fais Query soit sur la table soit sur 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 en premier lieu.

Peux-tu joindre entre deux comptes AWS (ou deux tables dans des comptes différents) ? Pas nativement, et pas avec BatchGetItem non plus — une seule requête ne peut pas s'étendre sur plusieurs identifiants, et il n'y a pas de primitive de jointure inter-comptes. Tu lirais chaque table avec les identifiants de son propre compte 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 cible de DynamoDB — des lectures prévisibles et à fort volume — oui. Tu déplaces le coût au moment de l'écriture (et acceptes une certaine duplication de données) en échange de lectures à requête unique qui montent en charge à plat. Le guide single-table design couvre les compromis.


Construire à la main les clés et conditions de ces lectures est fastidieux — 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.

Mis à jour