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

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

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

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#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.

Recommended resources

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.

2 Comments

Leave a Reply