DynamoDB Single-Table Design: The Pattern That Scales to Billions of Items

DynamoDB Single-Table Design: The Pattern That Scales to Billions of Items
9 views

Every developer coming from relational databases makes the same DynamoDB mistake: they design tables like they design SQL schemas. One table per entity, foreign keys everywhere, normalize everything. Then they hit production and discover why DynamoDB charges per read, not per join — because there are no joins. Single-table design is the pattern that fixes this, and once you understand it, relational modeling in DynamoDB looks as wrong as it actually is.

⚡ TL;DR: In DynamoDB, put all your entities in one table. Use composite partition keys like USER#123 and sort keys like ORDER#2026-04-05#456 to model relationships. Design your access patterns first, then design your keys around them. Never the reverse.

Why Single-Table Design Exists

DynamoDB is a key-value store with optional range queries. Every operation retrieves items by primary key. There are no joins, no subqueries, no cross-table operations. If you need data from two “tables”, that’s two separate round-trips — two separate costs.

Single-table design collapses all your entities into one table and uses carefully constructed partition and sort keys so that related data lives in the same partition and can be fetched in a single query. One network round-trip. One cost unit.

The Key Overloading Technique

// Multi-table design (wrong for DynamoDB):
// users table: { userId, name, email }
// orders table: { orderId, userId, total, date }
// items table: { itemId, orderId, product, qty }
// To get a user + their orders: 2 separate queries

// Single-table design (correct):
// Everything in ONE table with overloaded PK/SK:

// User record:
{ PK: "USER#u123", SK: "PROFILE", name: "Alice", email: "alice@co.com" }

// User's orders:
{ PK: "USER#u123", SK: "ORDER#2026-04-05#o456", total: 99.99, status: "shipped" }
{ PK: "USER#u123", SK: "ORDER#2026-03-20#o789", total: 45.00, status: "delivered" }

// Order's items:
{ PK: "ORDER#o456", SK: "ITEM#prod-001", product: "Laptop Stand", qty: 1 }
{ PK: "ORDER#o456", SK: "ITEM#prod-002", product: "USB Hub", qty: 2 }

// Get user profile + all orders in ONE query:
// PK = "USER#u123", SK begins_with "ORDER#" → all orders, sorted by date

Access Patterns First — The Golden Rule

Before writing a single line of DynamoDB code, list every access pattern your application needs. Your key design will flow directly from this list. Change the access patterns later and your entire key structure may need to change.

// Example access patterns for an e-commerce system:
// 1. Get user profile by userId
// 2. Get all orders for a user (sorted newest first)
// 3. Get a specific order by orderId
// 4. Get all items in an order
// 5. Get all orders with status "pending"
// 6. Get user's most recent 10 orders

// Key design that satisfies all 6 patterns:
// Base table:
// PK SK EntityType ...attributes
// USER#u123 PROFILE USER name, email
// USER#u123 ORDER#2026-04-05 ORDER orderId, total, status
// ORDER#o456 ITEM#prod-001 ITEM product, qty
// ORDER#o456 METADATA ORDER userId, total, status

// GSI (Global Secondary Index) for pattern 5 (orders by status):
// GSI1PK GSI1SK
// STATUS#pending 2026-04-05 → all pending orders sortable by date

// Query pattern 1: GetItem PK=USER#u123, SK=PROFILE
// Query pattern 2: Query PK=USER#u123, SK begins_with ORDER#, ScanIndexForward=false
// Query pattern 5: Query on GSI1, GSI1PK=STATUS#pending

Global Secondary Indexes — DynamoDB’s Join Replacement

// AWS SDK v3 example — create and query with GSI
const { DynamoDBClient, QueryCommand, PutItemCommand } = require('@aws-sdk/client-dynamodb');
const { marshall, unmarshall } = require('@aws-sdk/util-dynamodb');

const client = new DynamoDBClient({ region: 'us-east-1' });

// Write an order with GSI attributes
await client.send(new PutItemCommand({
 TableName: 'CheatCodersStore',
 Item: marshall({
 PK: 'USER#u123',
 SK: 'ORDER#2026-04-05#o456',
 GSI1PK: 'STATUS#pending', // For orders-by-status pattern
 GSI1SK: '2026-04-05T10:30:00Z', // Sortable timestamp
 orderId: 'o456',
 userId: 'u123',
 total: 99.99,
 status: 'pending',
 EntityType: 'ORDER'
 })
}));

// Query all pending orders (GSI1)
const pendingOrders = await client.send(new QueryCommand({
 TableName: 'CheatCodersStore',
 IndexName: 'GSI1',
 KeyConditionExpression: 'GSI1PK = :status',
 ExpressionAttributeValues: marshall({ ':status': 'STATUS#pending' }),
 ScanIndexForward: false // Newest first
}));

const orders = pendingOrders.Items.map(unmarshall);
console.log(orders);

The Hierarchical Data Pattern

// Model deeply nested data: Organisation → Team → Member → Task

// All in one table:
{ PK: "ORG#acme", SK: "METADATA", name: "Acme Corp" }
{ PK: "ORG#acme", SK: "TEAM#engineering", teamName: "Engineering" }
{ PK: "ORG#acme", SK: "TEAM#engineering#MEMBER#u1", name: "Alice", role: "Lead" }
{ PK: "MEMBER#u1", SK: "TASK#2026-04-05#t001", title: "Fix bug", done: false }

// Get entire org structure (departments + members) in ONE query:
// PK = "ORG#acme", SK begins_with "" → returns everything

// Get just teams:
// PK = "ORG#acme", SK begins_with "TEAM#"

// Get a specific team's members:
// PK = "ORG#acme", SK begins_with "TEAM#engineering#MEMBER#"

// This is what makes DynamoDB magical for hierarchical data —
// the sort key encodes the hierarchy, enabling partial queries at any level

Common Mistakes and How to Avoid Them

// ❌ MISTAKE 1: Using sequential IDs as partition keys
// This creates "hot partitions" — all traffic hits one server
{ PK: "1", SK: "ORDER" } // IDs 1,2,3 all likely on same partition
// ✅ FIX: Use random UUIDs or ULIDs (time-sortable random IDs)
{ PK: "USER#01HVZK2X...", SK: "ORDER#..." } // Distributed across partitions

// ❌ MISTAKE 2: Putting filter conditions in FilterExpression instead of keys
// FilterExpression reads ALL items then filters — you still pay for reads
const wrong = await client.send(new QueryCommand({
 FilterExpression: 'userId = :uid', // Scans everything!
 ...
}));
// ✅ FIX: Design keys so filters are on PK/SK (KeyConditionExpression)

// ❌ MISTAKE 3: Scan operations in production
// Scan reads EVERY item in the table — avoid completely
// ✅ FIX: Every access pattern should use Query on base table or GSI

Single-Table Design Cheat Sheet

  • ✅ List ALL access patterns before designing any keys
  • ✅ Use ENTITY#id format for partition keys (e.g., USER#123)
  • ✅ Use composite sort keys to encode relationships (ORDER#date#id)
  • ✅ Add GSI attributes to items that need alternate access patterns
  • ✅ Use begins_with on sort key for prefix queries
  • ✅ Use ScanIndexForward: false for newest-first ordering
  • ❌ Never design tables like SQL schemas
  • ❌ Never use Scan in production
  • ❌ Never put sequential IDs as partition keys

For the infrastructure side of DynamoDB deployments, the AWS Lambda cold start guide covers DynamoDB SDK initialization — a common source of Lambda cold start latency. For system design context, this single-table pattern is one of the core techniques in designing scalable data layers. See AWS DynamoDB best practices for the official guidance.

Disclosure: This post contains affiliate links. If you purchase through these links, CheatCoders earns a small commission at no extra cost to you. We only recommend tools and books we genuinely find valuable.

Related: How to Design a Global Media Delivery and Personalization Platform on AWS with CloudFront, S3, API Gateway, and DynamoDB Global Tables · AWS DynamoDB: Advanced Patterns for Production at Scale · REST API Design Best Practices: The Patterns That Make APIs a Joy to Use

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

2 Comments

Leave a comment

No account needed. Name and email are optional.