Skip to content
All articles

DynamoDB Single-Table Design: Modeling Around Access Patterns

Dean Jain

Dean Jain

Senior Staff Software Engineer · Enterprise AI, Data & Cloud Architect

· 6 min read

DynamoDBAWSData ModelingNoSQL
---
config:
  theme: dark
  fontSize: 17
  themeVariables:
    fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TB
    subgraph REL["🗃️ Relational normalize, join at read"]
        direction LR
        U1["Users"]:::neutral -->|"JOIN"| O1["Orders"]:::neutral
        O1 -->|"JOIN"| I1["Items"]:::neutral
    end
    subgraph DDB["⚡ DynamoDB model around access patterns"]
        direction LR
        T["One table<br/>pre-joined item collection"]:::server -->|"1 query"| R["All of a user's orders"]:::good
    end
    classDef neutral fill:#ECECEC,stroke:#8A8A8A,stroke-width:2px,color:#0F172A
    classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
    classDef good fill:#BFEFC8,stroke:#3FA34D,stroke-width:2px,color:#0F172A

Figure 1: The mindset flip relational normalizes and joins at read time; DynamoDB pre-shapes data around the query.

DynamoDB punishes you for modeling it like a relational database. There are no joins at scale, and the query patterns you didn’t plan for are the ones that hurt. The whole discipline inverts what you learned with SQL: instead of normalizing entities and joining them later, you start from your access patterns and shape the data to serve them often collapsing everything into a single table. Here’s how that works, and why it’s faster (and cheaper) when you do it right.

Summary

  • Model around access patterns, not entities. In SQL you design the schema then write queries. In DynamoDB you list the queries first, then design keys to serve them.
  • The partition key + sort key are everything. The partition key spreads data; the sort key orders and groups related items so one query returns a whole “item collection.”
  • Single-table design gives you materialized joins. Storing related items together under shared keys lets one query fetch what a relational DB would join at lower cost and operational burden.
  • Two index types, two rulebooks. LSIs share the partition key and must exist at table creation; GSIs can use a new partition key, be added anytime, and act as sparse indexes.
  • It’s not for everything. Complex, deeply nested, unpredictable-query workloads may fit a document store better. DynamoDB rewards known, high-scale access patterns.

1. The mindset flip: queries first

In the relational world you normalize split data into clean tables, then reassemble it with joins at read time. That’s flexible: you can ask questions you didn’t anticipate. DynamoDB makes the opposite trade. It’s a key-value store built for high-speed, low-latency access at any scale, and it gets there by refusing to do joins across a table at query time.

So the design process flips. You don’t start with entities; you start with a list of every access pattern your application needs “get a user’s profile,” “list a user’s orders newest-first,” “find all orders in a status.” Then you design keys so each of those is a single, fast lookup. The schema is a consequence of the queries, not the other way around.

This feels backwards if SQL is your home, but it’s the entire source of DynamoDB’s predictable performance: every supported query is pre-planned, so there are no surprise table scans or expensive runtime joins. The cost of that speed is rigidity patterns you didn’t design for are genuinely hard to add later. That’s the bargain.

2. Partition key + sort key: the whole game

Every item lives in a table as a bag of attributes (max 400 KB per item), addressed by a primary key. That key is where all the design leverage is:

  • Simple key just a partition key. Good for pure key-value lookups.
  • Composite key a partition key + sort key. This is where DynamoDB gets powerful.

The partition key decides which physical partition an item lands on DynamoDB hashes it to spread data and load evenly (so a well-chosen partition key avoids “hot partitions”). The sort key orders items within a partition and lets you group related items into an item collection: everything sharing a partition key, sorted, fetchable in one query.

---
config:
  theme: dark
  fontSize: 17
  themeVariables:
    fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart LR
    PK["🔑 Partition key<br/>USER#42"]:::gate --> COL["📦 Item collection (sorted by SK)"]:::server
    COL --> A["SK: PROFILE"]:::obs
    COL --> B["SK: ORDER#2024-08-01"]:::obs
    COL --> C["SK: ORDER#2024-08-07"]:::obs
    classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
    classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
    classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A

Figure 2: One partition key gathers a sorted item collection a profile and all its orders, fetched in a single query.

This is the trick that replaces joins: by giving a user’s profile and all their orders the same partition key and distinct sort keys, “get a user and their recent orders” becomes one query over a contiguous, sorted range no join, no second round-trip.

3. Indexes: LSI vs GSI

What about queries that don’t fit your primary key? That’s what secondary indexes are for and the two kinds follow different rules:

Local Secondary Index (LSI)Global Secondary Index (GSI)
Partition keySame as the tableCan be a different attribute
When createdOnly at table creationAnytime, after the fact
LimitUp to 5Up to 20
Sparse?NoYes only items with the indexed attribute appear

Figure 3: LSIs reshape sort order under the same partition key; GSIs unlock entirely new access patterns.

The practical read: reach for a GSI most of the time it’s flexible, addable later, and its sparse behavior is a feature (index only the items that have the attribute, e.g. only orders currently OPEN). Use an LSI when you need an alternate sort order under the same partition key and you know that need up front because you can’t add one after the table exists.

4. Why “single-table”?

Putting multiple entity types users, orders, items into one table sounds wrong to a relational eye. But it buys three concrete things:

  • Materialized joins. Related entities sharing a partition key are physically co-located, so the “join” already happened at write time. One query, no runtime assembly.
  • Lower cost on large items. Fewer requests and less data movement to satisfy a pattern, which directly lowers your read/write capacity bill.
  • Less operational burden. One table to provision, monitor, scale, back up, and secure instead of many.

The mechanics: design a generic partition key and sort key (often literally named PK and SK), then encode entity type into the key values (USER#42 / PROFILE, USER#42 / ORDER#...). GSIs overlay the other access patterns on top. It’s denormalized and duplicated on purpose storage is cheap, and predictable single-digit-millisecond reads are the prize.

5. Two companions: DAX and Streams

Two features round out a production setup:

  • DAX (DynamoDB Accelerator) a fully managed, in-memory cache that drops read latency from milliseconds to microseconds, even at millions of requests/sec. It’s read-through/write-through and API-compatible, so you bolt it on without changing application logic when a read-heavy workload needs it.
  • DynamoDB Streams an ordered, near-real-time log of every item create/update/delete (24-hour retention). It’s your change-data-capture hook: trigger Lambdas, replicate, or fan out events on writes, with no performance impact on the table.

6. When not to reach for it

DynamoDB is not a default. It shines when your access patterns are known, high-scale, and latency-sensitive predictable, concurrent, spiky traffic that needs consistent response times. It’s a poor fit when you have complex, deeply nested structures or unpredictable, ad-hoc query needs that you can’t enumerate up front. For schema-flexible, query-flexible workloads (rich user profiles you’ll slice many ways), a document database like DocumentDB or MongoDB is the better tool.

---
config:
  theme: dark
  fontSize: 17
  themeVariables:
    fontFamily: "Comic Sans MS, Comic Neue, Chalkboard SE, cursive"
---
flowchart TD
    Q1{"Access patterns<br/>known up front?"}:::gate -->|"no"| DOC["📄 Document DB<br/>(DocumentDB / MongoDB)"]:::obs
    Q1 -->|"yes"| Q2{"Deeply nested /<br/>ad-hoc queries?"}:::gate
    Q2 -->|"yes"| DOC
    Q2 -->|"no"| Q3{"High scale +<br/>low latency?"}:::gate
    Q3 -->|"yes"| DDB["⚡ DynamoDB<br/>single-table"]:::server
    Q3 -->|"no"| SQL["🗃️ Relational"]:::neutral
    classDef gate fill:#D7C3F2,stroke:#8E5BD0,stroke-width:2px,color:#0F172A
    classDef obs fill:#AED6F1,stroke:#2E86C1,stroke-width:2px,color:#0F172A
    classDef server fill:#A8E6D0,stroke:#2FA37C,stroke-width:2px,color:#0F172A
    classDef neutral fill:#ECECEC,stroke:#8A8A8A,stroke-width:2px,color:#0F172A

Figure 4: DynamoDB rewards known, high-scale access patterns; reach for a document store when queries are flexible or unknown.

Why it matters: the single biggest DynamoDB mistake is bringing relational instincts to it normalizing entities and hoping to join later. Flip the order: enumerate your access patterns first, then design keys and indexes to make each one a single fast query. Do that and you get predictable performance at any scale; skip it and you’ll fight the database forever.

References