← DynamoDB patterns

01 - Intro & setup

2026-10-20

Before any of the patterns make sense, we need a shared vocabulary: what a table, an item and a key are, how DynamoDB stores data on partitions, what each read and write operation costs, and what the request language looks like. This page covers all of that, then walks through the everyday API (lesson 00) and how to develop against DynamoDB without an AWS account (lesson 01).

Every later page assumes the material here, so it's worth reading even if you've used DynamoDB before.

# Section You'll learn
What DynamoDB is The one-paragraph pitch, and the price of it
The data model Tables, items, attributes, keys, item collections, types
How data is stored: partitions Hashing, per-partition limits, why key design matters
Reading data / Writing data The operations and what they cost
Expressions The small string language every request uses
Secondary indexes GSIs vs LSIs
Capacity and cost Read and write units, on-demand vs provisioned
Built-in features / Limits What comes for free, and the numbers to remember
00 Basics Put, get, update, query, conditional put, delete, scan
01 Local development Docker setup, endpoint config, per-test tables, Local vs LocalStack vs moto

What DynamoDB is

Amazon DynamoDB is a fully managed, serverless key-value and document database. We create a table and read and write items over an HTTPS API. AWS handles the servers, storage, replication, patching and scaling for us.

Its promise is consistent single-digit-millisecond latency at any scale, from ten requests a day to millions a second. The price of that promise is that we design the table around the queries we will run. DynamoDB is very fast at fetching data by key, and it deliberately offers nothing slower.

It grew out of Amazon's internal Dynamo system (described in the 2007 paper) and launched as an AWS service in 2012.

The data model

A handful of terms come up on every page of this series:

Term Meaning
Table A collection of items. The only thing we declare up front is the primary key (and any indexes).
Item One record, up to 400 KB. Think of it as a JSON document.
Attribute A named value on an item. Items in the same table can have completely different attributes.
Primary key Uniquely identifies an item. Either a partition key alone, or partition key + sort key.
Partition key (PK, "hash key") Hashed to decide which physical partition stores the item.
Sort key (SK, "range key") Orders items that share a partition key. Enables range queries.
Item collection All items that share a partition key value. One Query can read all or part of it.

The item collection is the idea to hold on to. Most of the modelling patterns are about deciding which items should live together in one.

Data types

Type Code Example
String S "USER#alice"
Number N 42, 3.14. Sent as a string on the wire, up to 38 digits of precision.
Binary B gzip bytes, images
Boolean BOOL true
Null NULL null
Map M nested object {"city": "London"}
List L ["a", 1, true]
String / Number / Binary set SS / NS / BS {"red", "blue"}, unique and unordered

Key attributes must be S, N or B. In Python, boto3's resource API (boto3.resource("dynamodb")) converts our values to and from these types for us. The one surprise is that numbers come back as Decimal.

How data is stored: partitions

             hash(PK)
 item ──────────────────▶  partition 1  [ PK=A: SK1, SK2, SK3 ]  [ PK=D: ... ]
                           partition 2  [ PK=B: ... ]
                           partition 3  [ PK=C: ... ]

When we write an item, DynamoDB hashes its partition key to pick a partition. Inside a partition, items with the same PK are stored sorted by SK, which is what makes range queries on the sort key cheap.

A partition holds about 10 GB and serves up to 3,000 read units/s and 1,000 write units/s. DynamoDB splits partitions automatically as data or traffic grows, and every write is replicated to three Availability Zones before it is acknowledged.

This is why key design matters so much. Traffic spread across many PK values scales without limit. Traffic concentrated on one PK value (a hot partition) hits the per-partition ceiling, however much capacity the table has. We come back to this in performance & scale.

Reading data

There are four ways to read:

Operation What it does Cost profile
GetItem Fetch one item by its full primary key Cheapest, constant time
BatchGetItem Up to 100 GetItems in one call (any tables) Same as the individual gets
Query Items from one partition key, optionally narrowed by a sort-key condition (=, <, >, between, begins_with), in SK order, forwards or backwards Pay only for items read
Scan Read every item in the table or index Pays for the whole table. Batch jobs only.

A few rules apply to all of them:

Writing data

Operation What it does
PutItem Create or fully replace an item
UpdateItem Change specific attributes (SET, REMOVE, ADD, DELETE). Creates the item if it's missing.
DeleteItem Remove an item
BatchWriteItem Up to 25 puts or deletes in one call. Not atomic: some may come back as UnprocessedItems.
TransactWriteItems Up to 100 actions across tables, all or nothing (costs 2× units)

Any single-item write can carry a condition expression, such as "only if it doesn't exist" or "only if version = 3". If the condition fails, the write is rejected and nothing changes. Conditions are the foundation of most of the concurrency patterns in consistency & concurrency.

Expressions

The request language is a set of small string expressions with placeholders. Here's an update that renames a user and bumps a counter, but only if the user exists:

Python
table.update_item(
    Key={"PK": "USER#alice", "SK": "PROFILE"},
    UpdateExpression="SET #n = :name ADD loginCount :one",
    ConditionExpression="attribute_exists(PK)",
    ExpressionAttributeNames={"#n": "name"},          # "name" is a reserved word
    ExpressionAttributeValues={":name": "Alice", ":one": 1},
)

There are five kinds of expression:

Expression Used for
KeyConditionExpression Which partition (and SK range) a Query reads
FilterExpression Drop items after reading (Query/Scan)
ConditionExpression Guard a write
UpdateExpression What an UpdateItem changes
ProjectionExpression Which attributes to return

boto3's Key("PK").eq(...) and Attr("status").eq(...) helpers build these for us. The demos in the repo print the final expression for every request, with the names and values filled in, so we can always see exactly what was sent.

Secondary indexes

An index is a second copy of (some of) our data, keyed differently so we can query it another way. DynamoDB keeps it in sync automatically. There are two kinds:

Global secondary index (GSI) Local secondary index (LSI)
Keys Any partition key + optional sort key Same partition key, different sort key
When created Any time Only at table creation
Consistency Eventually consistent only Strong or eventual
Capacity Its own Shares the table's
Limits 20 per table (default) 5 per table. Caps each item collection at 10 GB.

In practice, prefer GSIs. One property turns out to be very useful: items that don't have the index key attributes are left out of the index entirely. That's a sparse index, and it's a powerful tool. Indexes come up throughout modelling and querying.

Capacity and cost

We pay for reads, writes and storage:

Built-in features

DynamoDB comes with a set of features that several patterns build on:

Feature What it gives us Pattern
TTL Automatic, free deletion of items after a timestamp 30
Streams An ordered, 24-hour log of every item change, for triggers and replication 31
Transactions ACID across up to 100 items 25
PITR & backups Restore to any second in the last 35 days 39
Global tables Multi-region, active-active replication 40
Export to S3 Full-table export for analytics, without using table capacity 41
DAX In-memory, microsecond read cache with the same API 36
PartiQL SQL-like syntax (SELECT * FROM t WHERE PK = ?). It still only runs efficient key lookups if we write it that way. n/a

Limits worth memorising

Limit Value
Item size 400 KB (names + values)
Query / Scan page 1 MB before filtering
Per partition 3,000 RCU/s, 1,000 WCU/s, ~10 GB
BatchWriteItem / BatchGetItem 25 / 100 items
Transaction 100 items, 4 MB
GSIs / LSIs per table 20 (soft) / 5
LSI item collection 10 GB
Stream retention 24 hours
Nested attribute depth 32 levels

The mental model to carry forward

If we remember five things from this page, they should be these:

  1. Start from the access patterns. Write down every question the app asks, then design keys so each one is a GetItem or a Query.
  2. Store data the way it is read. Items fetched together share a partition key. Data needed on another item gets copied there.
  3. Spread the load. Use high-cardinality partition keys so traffic and storage spread across partitions.
  4. Keep items small, and avoid Scan in request paths.
  5. Use conditions for correctness. Atomic updates and conditional writes replace locks.

The rest of the series takes each of these and turns it into concrete patterns:

Page What it covers
02 - Modelling Designing keys, single-table design, indexes, relationships
03 - Querying Reading efficiently: Scan vs Query, filters, pagination, projections, batches
04 - Item size Living with the 400 KB limit and per-KB pricing
05 - Consistency Correct concurrent writes: conditions, locking, transactions, idempotency
06 - Lifecycle & events Expiry, change streams, archival
07 - Performance & scale Hot partitions, sharding, caching, time-series
08 - Operations Capacity, backups, multi-region, analytics
09 - What not to do What goes wrong and how to spot it

First, though, let's see the API in action.

00 · Basics

Code: 00-basics.py · run with just run 00

Problem

Before the patterns make sense, we need to be comfortable with the everyday API: how to write an item, read it back, change it, query a partition and delete it.

Pattern

We use one table with a composite key (PK partition key, SK sort key) holding animals:

PK SK attributes
ANIMAL#dog Bella age
ANIMAL#dog Rex age, breed, tags (string set)
ANIMAL#cat Tom age, owner (map)

The script walks through each core operation in turn:

Operation Call Notes
Create / replace put_item(Item=...) Replaces the whole item if the key exists
Read one get_item(Key=...) Needs the full key. Missing items return no Item, not an error.
Change some attributes update_item(UpdateExpression="SET ... REMOVE ...") ReturnValues="ALL_NEW" returns the result
Read a partition query(KeyConditionExpression=Key("PK").eq(...)) Sorted by SK. ScanIndexForward=False reverses.
Narrow by sort key & Key("SK").begins_with("R") Also between, <, >, =
Guarded write ConditionExpression="attribute_not_exists(PK)" Raises ConditionalCheckFailedException
Delete delete_item(Key=..., ReturnValues="ALL_OLD") Returns the deleted item
Read everything scan() Reads the whole table. Avoid outside batch jobs.

In practice

Here's the heart of the script, from the update onwards:

Python
# UpdateItem: change some attributes, leave the rest. ReturnValues shows the result.
resp = table.update_item(
    Key={"PK": "ANIMAL#dog", "SK": "Rex"},
    UpdateExpression="SET age = age + :one, last_vet = :d REMOVE breed",
    ExpressionAttributeValues={":one": 1, ":d": "2026-10-20"},
    ReturnValues="ALL_NEW",
)

# Query with a sort key condition, descending SK.
resp = table.query(
    KeyConditionExpression=Key("PK").eq("ANIMAL#dog") & Key("SK").begins_with("R"),
    ScanIndexForward=False,
)

# Conditional write: only create if it doesn't already exist.
try:
    table.put_item(Item={"PK": "ANIMAL#dog", "SK": "Rex", "age": 0}, ConditionExpression="attribute_not_exists(PK)")
except table.meta.client.exceptions.ConditionalCheckFailedException:
    show("Conditional put rejected", "Rex already exists, nothing was overwritten")

And here's what it sends and gets back:

>>> PutItem p00-basics
    item:       {PK: 'ANIMAL#dog', SK: 'Rex', age: 4, breed: 'labrador', tags: {'good', 'loud'}}

>>> GetItem p00-basics
    key:        {PK: 'ANIMAL#dog', SK: 'Nope'}

=== GetItem missing key ===
false

>>> UpdateItem p00-basics
    key:        {PK: 'ANIMAL#dog', SK: 'Rex'}
    update:     SET age = age + 1, last_vet = '2026-10-20' REMOVE breed
    ReturnValues: ALL_NEW

=== UpdateItem Rex (age+1, set last_vet, remove breed) ===
{
  "SK": "Rex",
  "PK": "ANIMAL#dog",
  "last_vet": "2026-10-20",
  "age": 5,
  "tags": [
    "good",
    "loud"
  ]
}

>>> Query p00-basics
    key:        PK = 'ANIMAL#dog'

=== Query all dogs ===
[
  {
    "SK": "Bella",
    "age": 2,
    "PK": "ANIMAL#dog"
  },
  {
    "SK": "Rex",
    ...
  }
]

>>> Query p00-basics
    key:        (PK = 'ANIMAL#dog' AND begins_with(SK, 'R'))
    ScanIndexForward: False

>>> PutItem p00-basics
    item:       {PK: 'ANIMAL#dog', SK: 'Rex', age: 0}
    condition:  attribute_not_exists(PK)

=== Conditional put rejected ===
"Rex already exists, nothing was overwritten"

>>> DeleteItem p00-basics
    key:        {PK: 'ANIMAL#cat', SK: 'Tom'}
    ReturnValues: ALL_OLD

=== DeleteItem Tom (old value) ===
{
  "SK": "Tom",
  "owner": {
    "name": "Sam",
    "city": "Leeds"
  },
  "PK": "ANIMAL#cat",
  "age": 7
}

>>> Scan p00-basics

A few things stand out. The GetItem on a missing key quietly returns no item rather than raising. The update expression is shown with the real values substituted in, and REMOVE breed really does drop the attribute from the returned item. The query for all dogs comes back sorted by sort key (Bella before Rex). And the conditional put is rejected without touching the existing Rex.

Why

Every pattern in this series is a combination of these calls with carefully chosen keys. It's worth watching the printed >>> requests to see the expressions boto3 actually sends, because that's what DynamoDB evaluates and bills us for.

Gotchas

01 · Local development

Code: 01-local-development.py · run with just run 01 (needs just up)

Problem

Developing straight against AWS is slow, costs money, needs credentials, and lets tests collide in shared tables. Mocking boto3 by hand isn't much better: it misses real behaviour like condition failures, key validation and GSI queries.

Pattern

We run DynamoDB Local, AWS's own emulator, in Docker. It speaks the same API, so the same boto3 code works locally and in AWS. The only switch is the endpoint:

YAML
# docker-compose.yml (this repo)
services:
  dynamodb-local:
    image: amazon/dynamodb-local:latest
    command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
    ports: ["8000:8000"]
    volumes: ["./docker/dynamodb:/home/dynamodblocal/data"]
Python
endpoint = os.environ.get("DYNAMODB_ENDPOINT")  # unset in prod
boto3.resource("dynamodb", endpoint_url=endpoint, ...)

Everything in the repo reads DYNAMODB_ENDPOINT. In production we leave it unset, and the normal AWS credential chain takes over.

For tests, we give each test its own uniquely named table and always delete it afterwards. The script does this with a temp_table() context manager; a pytest fixture works just as well. Each lesson in the repo follows the same idea by dropping and recreating its own pNN-* table, so runs never interfere. The tables should be built from the same definition as production, ideally exported from our IaC (CDK, Terraform, CloudFormation).

DynamoDB Local isn't the only option:

Tool Good for Notes
DynamoDB Local realistic API behaviour, streams, PartiQL, transactions official; Java/Docker
LocalStack DynamoDB plus S3, Lambda, SQS, etc. wraps DynamoDB Local for DynamoDB
moto fast in-process unit tests, no Docker @mock_aws; less faithful

In practice

The script fails fast if the container isn't running, then runs two tests, each against a throwaway table:

Python
@contextmanager
def temp_table(prefix="p01-test"):
    name = f"{prefix}-{uuid.uuid4().hex[:8]}"
    table = ddb.create_table(TableName=name, ...)  # same definition as prod
    table.wait_until_exists()
    try:
        yield table
    finally:
        table.delete()


for test in (test_create_user, test_duplicate_user_rejected):
    with temp_table() as t:
        test(t)
        print(f"  PASS {test.__name__}  (table {t.name})")
=== Connected to http://localhost:8000 ===
{
  "existing tables": 50
}

=== Running tests, each against its own throwaway table ===

>>> PutItem p01-test-c067a46e
    item:       {PK: 'USER#1', SK: 'PROFILE', name: 'Ada'}

>>> GetItem p01-test-c067a46e
    key:        {PK: 'USER#1', SK: 'PROFILE'}
  PASS test_create_user  (table p01-test-c067a46e)

>>> PutItem p01-test-4e1a63bc
    item:       {PK: 'USER#1', SK: 'PROFILE'}

>>> PutItem p01-test-4e1a63bc
    item:       {PK: 'USER#1', SK: 'PROFILE'}
    condition:  attribute_not_exists(PK)
  PASS test_duplicate_user_rejected  (table p01-test-4e1a63bc)

=== Test tables left behind ===
[]

Each test gets its own randomly named table, and the duplicate-user test passes because DynamoDB Local really does reject the conditional put. That's exactly the kind of behaviour a hand-written mock would miss. At the end, no test tables are left behind.

Why

Trade-offs

DynamoDB Local covers tables, indexes, streams, transactions and PartiQL. What it doesn't do matters just as much, and the patterns that need these features simulate them instead:

Because of this, keep a small integration suite that runs against a real AWS account in CI.

Gotchas

With the vocabulary, the API and a local database in place, we're ready for the heart of DynamoDB: modelling.


← DynamoDB patterns | 02 - Modelling →