Fortgeschritten9 Min. Lesezeit

DynamoDB JOIN: Tabellen joinen

Es gibt kein JOIN in DynamoDB. Die API hat keinen Join-Operator, das Datenmodell keine Foreign Keys, und — der Teil, der die meisten überrascht — , der SQL-angehauchte Query-Layer, fügt auch keines hinzu. Ein PartiQL-SELECT liest genau eine Tabelle.

Kommst du von einer relationalen Datenbank, ist das die erste Wand, gegen die du läufst. Dieser Guide behandelt, warum die Wand da ist, welche vier Dinge Devs stattdessen tun, den einen Fall, in dem du wirklich einen echten Join brauchst — und wie du einen fährst.

Kann DynamoDB Joins?

Nein. DynamoDB kann keine Tabellen joinen — nicht über die Low-Level-API (GetItem / Query / Scan / BatchGetItem), nicht über und auch nicht über einen eingebauten Query-Planner, denn es gibt keinen. Jeder Read zielt auf eine Tabelle oder einen ihrer Indexes; zwei Tabellen über einen passenden Key zu kombinieren, ist etwas, das du in deiner App machst, nachdem DynamoDB die Items zurückgegeben hat, nie darin.

  • DynamoDB hat keinen JOIN-Operator. Hatte es nie.
  • PartiQLs SELECT ist Single-Table only — die Grammatik lautet wörtlich SELECT … FROM {{table}}[.{{index}}], und richtest du es auf zwei Tabellen, bekommst du ValidationException: Only select from a single table or index is supported.
  • Die Lösung, die AWS empfiehlt, lautet: keinen Join brauchen oder Single-Table Design nutzen, damit verwandte Items in einer Partition liegen, die du mit einem einzigen Request holst.
  • Für den echten Cross-Table-/Ad-hoc-Fall joinst du außerhalb von DynamoDB — in deiner App oder mit einem Tool, das es für dich erledigt.

Warum DynamoDB keine Joins hat

Ein SQL-JOIN verlangt von der Datenbank, mehrere Tabellen zu lesen und sie zur Query-Zeit zusammenzusetzen. AWS' eigener Guide zum Modellieren relationaler Daten benennt die Kosten: Eine Query wie diese

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

ist flexibel, aber "each join in the query increases the runtime complexity of the query because the data for each table must stage and then be assembled." Diese Arbeit ist unbegrenzt — ihre Kosten hängen von den Daten ab, nicht von der Query —, und genau diese Eigenschaft will DynamoDB nicht haben.

Also hat AWS die Constraint eingebaut. DynamoDB ist, in ihren Worten, "built to minimize both [CPU and network] constraints by eliminating JOINs (and encouraging denormalization of data) and optimizing the database architecture to fully answer an application query with a single request to an item." Genau das sind die Eigenschaften, die einstellige Millisekunden-Latenz bei jeder Scale erkaufen: Die Laufzeitkosten eines DynamoDB-Reads sind konstant, unabhängig von der Tabellengröße. Es gibt keine Join-Engine und kein Foreign-Key-Konzept, gegen das sich planen ließe — mit Absicht.

„Aber PartiQL ist SQL, sicher joined es?“

Nein. PartiQL gibt dir SELECT- / INSERT- / UPDATE- / DELETE-Syntax über DynamoDB, aber es ist SQL-kompatibel, nicht SQL. Die offizielle SELECT-Grammatik lautet:

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

FROM nimmt eine Tabelle (optional einen ihrer Indexes). Es gibt keine zweite FROM-Tabelle, kein JOIN, keine Subquery, kein CTE. Wir haben alle drei gegen DynamoDB laufen lassen, um zu sehen, wie genau jede scheitert.

Ein explizites JOIN:

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.

Zwei Tabellen in FROM — dieselbe Ablehnung: die Engine verweigert also nicht das Keyword JOIN, sondern die zweite Tabelle:

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

Eine Subquery scheitert anders, was beim Debuggen hilfreich ist. PartiQL kommt gar nicht bis zur Multi-Table-Prüfung — es weist den IN-Operanden vorher zurück, sodass die Meldung Tabellen nie erwähnt:

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

Keine Formulierung bringt dir ein Join. Die ersten beiden sterben an der zweiten Tabelle, die dritte noch früher am Operanden.

Willst du die vollständige Begründung, warum PartiQL wie SQL aussieht, sich aber nicht so verhalten kann, siehe PartiQL vs SQL.

Die 4 Workarounds, die Devs wirklich nutzen

1. Denormalisieren (Daten rein kopieren)

Speichere die Felder, die du sonst joinen würdest, direkt am Item. Eine Order trägt einen Snapshot von customerName und shippingAddress statt einer customerId, die du später auflösen müsstest. Ein Read, kein Join.

Der Preis ist der Fan-out zur Write-Zeit. Ändert sich die Quelle, aktualisierst du jede Kopie (typischerweise über einen -Handler). Du tauschst Read-Komplexität gegen Write-Komplexität — für eine leselastige App meist ein guter Tausch.

2. Single-Table Design (Pre-Join in der Partition)

Leg verwandte Entities in eine Tabelle unter einen gemeinsamen Partition Key, sodass eine das gejointe Result ist. Ein Kunde und alle seine Orders teilen sich PK = "CUSTOMER#42"; eine Query liefert das Kunden-Item plus jedes Order-Item — der „Join“ ist schon zur Write-Zeit passiert.

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

Das ist die kanonische DynamoDB-Antwort auf One-to-Many-Beziehungen. Der vollständige Walkthrough steht in Single-Table Design.

3. Application-Side Join (zwei Reads, in Code stitchen)

Lies aus Tabelle A, nimm die zurückgegebenen Keys, lies aus Tabelle B und führe die beiden Result-Sets in deiner Anwendung zusammen. Das ist die relationale Join-Logik — sie läuft nur in deinem Code statt in der Datenbank:

// "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
}));

Fein bei kleinem Fan-out. Bei vielen Orders wird daraus ein N+1-Problem — ein Read, um die Orders zu listen, dann ein Read pro Order —, was langsam ist und Read Capacity verbrennt. BatchGetItem (gleich) kollabiert diese zweite Welle in einen Round-Trip.

4. BatchGetItem (ein Round-Trip, mehrere Tabellen)

BatchGetItem ist das, was in der API einem „zwei Tabellen auf einmal anfassen“ am nächsten kommt: Ein Request liefert "the attributes of one or more items from one or more tables," bis zu 100 Items oder 16 MB pro Aufruf, je nachdem, was zuerst erreicht wird. Das spart die Round-Trips eines App-seitigen Joins — aber es ist kein Join. AWS beschreibt es als "identify requested items by primary key"; es gibt keine ON-Condition und kein relationales Matching. Du musst die Keys weiterhin vorher kennen und die Responses selbst zusammenstitchen.

Wenn ein echtes JOIN unvermeidbar ist

Die vier Workarounds decken Production-Read-Pfade gut ab. Wo sie einbrechen, ist die ad-hoc, explorative, analytische Query — die, für die du nicht modelliert hast:

  • „Welche Kunden in der EU haben letzten Monat eine Order über $500 aufgegeben?“ über eine Orders-Tabelle und eine Customers-Tabelle.
  • Ein einmaliger Data-Quality-Check, der zwei Entity-Typen joint.
  • Reporting und Aggregate (GROUP BY, SUM, COUNT) — wofür DynamoDB überhaupt keinen Operator hat.

Genau diese Queries kannst du nicht in eine Partition vorbacken, weil du per Definition nicht wusstest, dass du sie stellen würdest. Der relationale Instinkt — ein JOIN schreiben — ist hier der richtige. DynamoDB kann ihn nur nicht nativ bedienen, und PartiQL ebenso wenig.

Die übliche Schwergewichts-Antwort lautet nach S3 exportieren und mit Athena abfragen (oder Athenas Federated-Query-Connector nutzen, um eine Live-Tabelle zu JOINen) oder in ein Warehouse pipen. Für echte Analytics at Scale ist das korrekt, aber es ist eine Menge Klempnerei für eine Frage, die du jetzt beantwortet haben willst, gegen deine Live-Tabelle.

Ein echtes JOIN mit DynoTables SQL Workbench ausführen

DynoTable ist ein Desktop-DynamoDB-Client, dessen SQL Workbench echtes SQL über deinen DynamoDB-Tabellen ausführt — inklusive JOIN, GROUP BY und Aggregatfunktionen. Er liest die Items über die normale DynamoDB-API und führt die relationalen Teile der Query dann im Client aus. Du kannst also schreiben:

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

— und bekommst ein Result-Set, gegen Tabellen ohne definierte Beziehung und eine Query-Engine ohne JOIN-Keyword.

Der ehrliche Vorbehalt — „innerhalb von DynamoDBs Access-Pattern-Regeln“: Der Workbench liest weiterhin durch DynamoDB, ein ungebundener Join ist also ein ungebundener Read. Die schnellsten Queries sind die, bei denen die WHERE-Klausel (oder das ON-Attribut des Joins) auf mindestens einer Seite einen Partition Key oder einen GSI trifft, damit DynamoDB eine Query statt eines vollen Scans fährt, bevor der Join läuft. Der Workbench hebt die Constraints aus diesem Guide nicht auf — er lässt dich nur die SQL-Frage stellen, statt das Stitching von Hand zu schreiben, und sagt dir, was er darunter tut.

Unter GUI-Clients ist es das einzige „ja, du kannst joinen“, das tatsächlich stimmt: PartiQL und AWS' eigene NoSQL Workbench — deren Operation Builder Single-Table-Operationen und PartiQL-Statements ausführt (kein JOIN, kein Multi-Table-SELECT) — stoppen beide an der Single-Table-Wand, wie die meisten anderen GUI-Clients auch. Wie DynoTable als DynamoDB GUI abschneidet, liest du hier.

FAQ

Unterstützt PartiQL JOIN? Nein. PartiQLs SELECT liest eine einzige Tabelle (oder einen ihrer Indexes). Eine Multi-Table-Query liefert ValidationException: Only select from a single table or index is supported. Dieselbe Wand wie im Rest der API.

Kann man zwei DynamoDB-Tabellen in einer Query joinen? Nicht nativ. Die DynamoDB-API hat kein Statement, das zwei Tabellen liest und sie über einen Key matcht. BatchGetItem kann Items aus mehreren Tabellen in einem Request lesen, hat aber keine ON-Condition — es gibt die Items zurück, die du per Primary Key benannt hast, und überlässt dir das Matching. Ein echtes JOIN … ON … passiert nur außerhalb von DynamoDB: in deiner App oder in DynoTables SQL Workbench.

Kann man eine Tabelle mit ihrem GSI joinen? Nein — ein Global Secondary Index ist keine separate Tabelle, an die du joinst; er ist eine alternative Key-Sicht auf dieselben Items. Du fragst in einem gegebenen SELECT entweder die Tabelle oder den Index per Query ab, nicht beide gejoint. Ein GSI lässt dich Items über einen anderen Key erreichen, was den Bedarf für einen Join oft von vornherein erledigt.

Kann man über zwei AWS-Accounts hinweg joinen (oder zwei Tabellen in verschiedenen Accounts)? Nicht nativ — es gibt kein Cross-Account-Join-Primitiv. BatchGetItem kann die Tabelle eines anderen Accounts erreichen, wenn deren Resource-based Policy deinem Caller Zugriff gewährt (seit März 2024), hat aber weiterhin keine ON-Condition — es bleibt also ein Multi-Table-Read, kein Join. Du liest jede Seite und joinst die Ergebnisse in deiner Anwendung oder in einem Tool wie DynoTables Workbench.

Ist Denormalisierung wirklich besser als ein Join? Für DynamoDBs Ziel-Workload — vorhersagbare, hochvolumige Reads — ja. Du verschiebst die Kosten auf die Write-Zeit (und nimmst etwas Datenduplikation in Kauf) im Tausch gegen Single-Request-Reads, die flach skalieren. Der Guide Single-Table Design behandelt die Trade-offs.


Die Keys und Conditions für diese Reads von Hand zu bauen ist fummelig — der Expression Builder generiert dir die KeyConditionExpression- / FilterExpression-Syntax, und DynoTable fährt das echte SQL, wenn ein Workaround nicht reicht.

PartiQL-Rejection-Messages reproduziert am 2026-08-11 gegen DynamoDB Local (us-east-1, @aws-sdk/client-dynamodb v3.1096.0), wörtlich aus ValidationException.message zitiert.

Aktualisiert