Coming from SQL, the instinct is one table per entity: customers, orders,
order_items. In DynamoDB that instinct is usually wrong. A single table that
stores every entity, distinguished by overloaded key prefixes, lets you fetch
a parent and its children in one Query — no joins, no N+1.
What is single-table design in DynamoDB?
Single-table design stores every entity — customers, orders, order items — in one
DynamoDB table, distinguished by overloaded partition key and
sort key prefixes. Because the keys are designed around your access patterns
rather than your entities, a parent and all its children live in one
item collection and come back in a single Query — no joins,
no N+1 reads.
The idea
Pick generic key names (PK, SK) and encode the entity type in the value:
| PK | SK | attributes |
|---|---|---|
| CUSTOMER#42 | PROFILE | name, email, plan |
| CUSTOMER#42 | ORDER#2026-001 | total, status |
| CUSTOMER#42 | ORDER#2026-002 | total, status |
Now one Query PK = "CUSTOMER#42" returns the profile and every order in a
single billed read. SK begins_with "ORDER#" narrows it to just the orders.
Visually, the overloaded items stack under one partition key as a single item collection:
One read of the partition hands back the customer and every order together.
Start from access patterns, not nouns
Single-table design begins with a numbered list of reads and writes your application
actually performs — "show customer profile", "list open orders for customer",
"lookup order by id globally" — not with an ER diagram of entity boxes. Each pattern
must compile to a Query or GetItem on the base table or on an overloaded GSI.
If a pattern needs a Scan, the keys are wrong; fix the model before shipping.
Write the list down explicitly. A three-pattern app might look like:
- Fetch customer + all orders for one customer id (high volume).
- List all open orders across customers, sorted by date (moderate volume).
- Fetch one order by its public order id (high volume).
Pattern 1 is a base-table Query on PK = CUSTOMER#<id>. Pattern 2 lands on
GSI1PK = STATUS#OPEN with GSI1SK as the date. Pattern 3 might use
PK = ORDER#<id>, SK = METADATA as a separate item collection from pattern 1's
CUSTOMER#<id> / ORDER#<id> rows — duplicate the order summary twice if both
access paths are hot, or accept a second Query when one path is rare.
The free Single-Table Design tool accepts
that pattern list and emits a PK/SK/GSI layout with example items, CreateTable JSON,
and per-pattern cost hints so you can compare alternatives before committing.
Overloaded GSIs
The same trick works on indexes. Put a generic GSI1PK/GSI1SK on items, and a
single GSI serves multiple access patterns depending on what each item writes
into those attributes:
| PK | SK | GSI1PK | GSI1SK |
|---|---|---|---|
| ORDER#001 | METADATA | STATUS#OPEN | 2026-01-04 |
| ORDER#002 | METADATA | STATUS#OPEN | 2026-01-05 |
Now Query GSI1 WHERE GSI1PK = "STATUS#OPEN" lists open orders by date — a
pattern the base table can't answer. A different entity can reuse GSI1 with its
own meaning (e.g. CATEGORY#books). One index, many queries.
Many-to-many: the adjacency list
For relationships (a user in many teams, a team with many users), write the edge
twice with the ids swapped: PK=USER#1, SK=TEAM#9 and PK=TEAM#9, SK=USER#1.
Querying either side lists the other — the DynamoDB stand-in for a join table.
Read cost: one Query vs many GetItems
The billing win is real when children live under the parent's partition key. Suppose
a customer profile (1 KB) and twelve order summaries (1 KB each) share
PK = CUSTOMER#42. One eventually-consistent Query on that partition reads
13 KB total → 2 read request units (13 KB rounds up to four 4 KB blocks at 0.5
RCU each). Fetching the same thirteen items with thirteen GetItem calls meters
the same per-item rounding individually → 7 units (thirteen × 0.5 RCU minimum
one block per item). Same data, more than triple the read units — before network
round trips.
| Access style | Round trips | Read units (13 × 1 KB items, EC) |
|---|---|---|
Query PK = CUSTOMER#42 |
1 | 2 |
13 × GetItem by full primary key |
13 | 7 |
Scan + filter on customer id |
1+ pages | whole table |
Numbers shift with item size — profile blobs at 6 KB each cross two 4 KB blocks
per item — which is why the
item-size calculator sits next to the design
tool. Writes obey a 1 KB rounding boundary (one WCU per kilobyte, doubled inside
transactions); fat order rows cost more to denormalize onto the parent partition.
Entity typing and sparse indexes
Most teams add a string type or entity attribute (CUSTOMER, ORDER, LINE)
so application code can branch when a Query returns mixed sort-key prefixes.
GSIs can be sparse: only items that populate GSI1PK/GSI1SK appear on the
index, so a customer profile row with those attributes empty does not consume index
storage or show up in unrelated queries.
When two entity types share an index, document what each prefix means (STATUS#,
CATEGORY#, EMAIL#) in the same place you document the base-table prefixes.
Future you should not have to reverse-engineer meaning from CloudWatch graphs alone.
When not to single-table
It isn't free. One overloaded table is harder to reason about, harder to evolve,
and analytics-hostile. If your access patterns are genuinely unknown or change
constantly, or the data is mostly analytical, separate tables (or a different
store) can be the saner call. Single-table wins when the patterns are known and
high-volume.
| Signal | Lean separate tables | Lean single-table |
|---|---|---|
| Access patterns documented and stable | ✓ | |
| Many unrelated domains with no shared reads | ✓ | |
| Team new to DynamoDB, needs obvious table names | ✓ | |
| Parent + children fetched together constantly | ✓ | |
| Heavy ad-hoc analytics across arbitrary joins | ✓ (or warehouse) | |
| Hot multi-entity dashboard on one partition key | ✓ |
Regulatory or organizational boundaries (different data owners, distinct backup
policies) can also justify multiple tables even when a single-table model fits
access patterns technically.
Cost of the wrong shape
Modelling as separate tables forces a Scan or client-side join to reassemble a
customer, and that is the Scan footgun. Model the access
patterns first, then design keys to make each one a Query. (For the ad-hoc
cross-entity question you never modeled for, DynoTable's SQL
Workbench runs the JOIN client-side — exploration
doesn't have to wait for a re-model.)
Evolving a live single-table layout usually means adding GSIs or new item
types, not renaming PK/SK semantics in place. Treat prefix contracts like API
versions: add ORDER#v2# rather than repurpose ORDER# mid-flight. Migrations
that rewrite every item's keys belong in a controlled backfill with dual-write
periods, not in a Friday-afternoon script.
Sketch the design itself with the free
Single-Table Design tool — it turns your
access-pattern list into a PK/SK/GSI plan with example items and cost hints.
Estimate what these items cost per read with the
item-size & capacity calculator, and
compose the resulting Query programs in the
query builder when you're ready to ship SDK code.
Read composite primary keys if partition +
sort key mechanics still feel fuzzy — single-table design is mostly disciplined
composite-key usage with shared attribute names.
Try DynoTable to browse a single-table schema and see the overloaded
collections side by side — partition inspector view lines up sort-key order with
the access patterns you sketched.

Top comments (0)