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#123and sort keys likeORDER#2026-04-05#456to 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
AWS and DynamoDB resources
→ AWS Solutions Architect Course (Udemy) — Full DynamoDB module — single-table design, GSIs, and capacity planning.
Sponsored links. We may earn a commission at no extra cost to you.
// 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#idformat 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_withon sort key for prefix queries - ✅ Use
ScanIndexForward: falsefor newest-first ordering - ❌ Never design tables like SQL schemas
- ❌ Never use
Scanin 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.
Recommended resources
- Designing Data-Intensive Applications (DDIA) — Kleppmann’s chapter on data models covers the key-value store mental model that makes single-table DynamoDB design click. The most important backend book of the decade.
- AWS Certified Solutions Architect Guide — The DynamoDB chapter covers capacity modes, GSI design, and cost optimization — essential context for production single-table deployments.
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.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: Design a Production Rate Limiter: Algorithms Seniors Actually Use in Interviews and at Work - CheatCoders
Pingback: AWS S3 Presigned URLs: The Security Mistakes 90% of Developers Make - CheatCoders