DynamoDB Single-Table Design — The Art of NoSQL Modeling for Large-Scale Systems
Posted on: 4/18/2026 11:10:58 AM
Table of contents
- 1. DynamoDB and the Large-Scale NoSQL Problem
- 2. What Is Single-Table Design?
- 3. Access-Pattern-First: Inverse Design Thinking
- 4. Advanced Design Patterns
- 5. When to Use Single-Table vs Multi-Table?
- 6. Serverless Integration on .NET
- 7. Cost Optimization with DynamoDB
- 8. Anti-patterns to Avoid
- 9. DynamoDB Free Tier and Getting Started
- 10. Conclusion
1. DynamoDB and the Large-Scale NoSQL Problem
Amazon DynamoDB is AWS's fully-managed NoSQL service, famous for near-unlimited scalability and stable sub-10ms latency regardless of data volume or traffic. Yet DynamoDB's greatest strength is also its biggest challenge: you have to model data based on access patterns — the opposite of traditional relational thinking.
With a relational database (SQL Server, PostgreSQL...), you normalize first, then use JOINs to query flexibly afterward. With DynamoDB, you must know exactly how the application will read/write data before designing the schema. This is why many teams fail — they bring a relational mindset to NoSQL.
💡 The Golden Rule
"Items that are accessed together should be stored together." This is the foundational philosophy behind every design decision in DynamoDB.
DynamoDB's Foundational Architecture
To understand Single-Table Design, you must first grasp two core mechanisms:
graph TD
A["Client Request"] --> B["DynamoDB Router"]
B --> C["Partition 1
PK hash → slot"]
B --> D["Partition 2
PK hash → slot"]
B --> E["Partition N
PK hash → slot"]
C --> F["B-Tree
Sort Key ordering"]
D --> G["B-Tree
Sort Key ordering"]
E --> H["B-Tree
Sort Key ordering"]
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style F fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style G fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style H fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
DynamoDB shards data across partitions via the Partition Key and orders within each partition by the Sort Key
Partitioning: Data is sharded into partitions of up to 10 GB each. Each item is routed to a partition based on the hash of its Partition Key (PK). This mechanism lets DynamoDB scale horizontally to petabytes.
B-Tree inside each Partition: Items in the same partition are ordered by their Sort Key (SK) inside a B-Tree structure. This enables extremely fast range queries with O(log n) complexity.
2. What Is Single-Table Design?
Single-Table Design (STD) is the technique of storing multiple entity types in a single DynamoDB table. Instead of creating separate tables for Users, Orders, Products... you design the Partition Key and Sort Key so all related data lives in the same partition — retrievable with a single Query.
Example: E-Commerce System
Rather than 3 separate tables (Customers, Orders, OrderItems), Single-Table Design consolidates everything into one table:
| PK | SK | Attributes |
|---|---|---|
| CUSTOMER#C001 | PROFILE | Name: "Nguyen Van A", Email: "a@mail.com" |
| CUSTOMER#C001 | ORDER#2026-04-18#O100 | Total: 2500000, Status: "processing" |
| CUSTOMER#C001 | ORDER#2026-04-18#O100#ITEM#1 | Product: "Laptop", Qty: 1, Price: 2500000 |
| CUSTOMER#C001 | ORDER#2026-04-15#O099 | Total: 350000, Status: "delivered" |
| CUSTOMER#C001 | ORDER#2026-04-15#O099#ITEM#1 | Product: "Book", Qty: 2, Price: 175000 |
| CUSTOMER#C002 | PROFILE | Name: "Tran Thi B", Email: "b@mail.com" |
✅ The power of this design
With just one Query PK = "CUSTOMER#C001" you retrieve the profile, all orders, AND all order items of a customer. No JOINs, no multiple round-trips. With begins_with(SK, "ORDER#2026-04") you filter to just orders in April 2026.
3. Access-Pattern-First: Inverse Design Thinking
This is the most important step and where most developers stumble. You must list every access pattern before drawing any schema.
graph LR
A["Step 1
List Access Patterns"] --> B["Step 2
Group entities
by query relationships"]
B --> C["Step 3
Design PK/SK"]
C --> D["Step 4
Add GSIs
for secondary patterns"]
D --> E["Step 5
Validate
with sample data"]
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style C fill:#e94560,stroke:#fff,color:#fff
style D fill:#2c3e50,stroke:#fff,color:#fff
style E fill:#e94560,stroke:#fff,color:#fff
DynamoDB Single-Table design workflow: always start from access patterns
For an e-commerce system, common access patterns include:
| # | Access Pattern | Operation | Key Design |
|---|---|---|---|
| 1 | Fetch customer profile | GetItem | PK=CUSTOMER#id, SK=PROFILE |
| 2 | Fetch all orders of a customer | Query | PK=CUSTOMER#id, SK begins_with ORDER# |
| 3 | Fetch orders in a date range | Query | PK=CUSTOMER#id, SK BETWEEN ORDER#date1 AND ORDER#date2 |
| 4 | Fetch an order + its items | Query | PK=CUSTOMER#id, SK begins_with ORDER#date#orderId |
| 5 | Find orders by status | Query GSI | GSI1PK=STATUS#processing, GSI1SK=date |
| 6 | Fetch an order by orderId | Query GSI | GSI2PK=ORDER#orderId |
4. Advanced Design Patterns
4.1 GSI Overloading — One GSI, Many Access Patterns
DynamoDB allows up to 20 GSIs per table. Instead of creating a GSI per pattern, GSI Overloading reuses the same GSI for multiple entity types by using generic attribute names (GSI1PK, GSI1SK).
// Entity: Customer
{
PK: "CUSTOMER#C001",
SK: "PROFILE",
GSI1PK: "EMAIL#a@mail.com", // Find customer by email
GSI1SK: "CUSTOMER#C001",
Name: "Nguyen Van A"
}
// Entity: Order
{
PK: "CUSTOMER#C001",
SK: "ORDER#2026-04-18#O100",
GSI1PK: "STATUS#processing", // Find orders by status
GSI1SK: "2026-04-18",
Total: 2500000
}
// Entity: Product
{
PK: "PRODUCT#P001",
SK: "METADATA",
GSI1PK: "CATEGORY#electronics", // Find products by category
GSI1SK: "PRODUCT#P001",
Name: "Laptop Pro 2026"
}
The same GSI1 now serves 3 completely different access patterns: find customer by email, filter orders by status, and browse products by category.
4.2 Hierarchical Data — Layered Sort Keys
Hierarchical data (Organization → Department → Team → Member) is modeled with Sort Keys using delimiters:
PK: "ORG#FPT"
SK: "DEPT#engineering" → Department
SK: "DEPT#engineering#TEAM#platform" → Team
SK: "DEPT#engineering#TEAM#platform#MEM#tu001" → Member
SK: "DEPT#engineering#TEAM#backend" → Team
SK: "DEPT#engineering#TEAM#backend#MEM#an002" → Member
// Query everything in the Engineering department:
// PK = "ORG#FPT" AND begins_with(SK, "DEPT#engineering")
// Query only the Platform team:
// PK = "ORG#FPT" AND begins_with(SK, "DEPT#engineering#TEAM#platform")
💡 Hierarchical Sort Key tips
Order the levels from general → specific in the Sort Key. That lets begins_with() filter at any level of the hierarchy. Use # as a delimiter because it's rare in real data.
4.3 Adjacency List — Many-to-Many Relationships
Many-to-many relationships (Students ↔ Courses, Users ↔ Groups) are NoSQL's biggest challenge. The Adjacency List pattern solves it by storing both directions of the relationship:
graph LR
subgraph "DynamoDB Table"
A["PK: STUDENT#S01
SK: COURSE#C01
Grade: A"]
B["PK: STUDENT#S01
SK: COURSE#C02
Grade: B+"]
C["PK: COURSE#C01
SK: STUDENT#S01
Enrolled: 2026-01"]
D["PK: COURSE#C01
SK: STUDENT#S02
Enrolled: 2026-02"]
end
style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style C fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
style D fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
Adjacency List: store the relationship in both directions to query efficiently from either entity
| Access Pattern | Query |
|---|---|
| Fetch all courses of Student S01 | PK = "STUDENT#S01", SK begins_with "COURSE#" |
| Fetch all students in Course C01 | PK = "COURSE#C01", SK begins_with "STUDENT#" |
4.4 Sparse Index — Efficient GSI Filtering
DynamoDB only indexes items that have the attribute used as the GSI key. This creates a "sparse index" — a GSI containing only a small subset of the main table, which is incredibly useful for filtering.
// Only unpaid orders get the "UnpaidGSIPK" attribute
{
PK: "CUSTOMER#C001",
SK: "ORDER#2026-04-18#O100",
UnpaidGSIPK: "UNPAID", // ← Only set if unpaid
UnpaidGSISK: "2026-04-18",
Total: 2500000,
Status: "pending_payment"
}
// A paid order → NO UnpaidGSIPK attribute
{
PK: "CUSTOMER#C001",
SK: "ORDER#2026-04-15#O099",
// No UnpaidGSIPK → not present in the GSI
Total: 350000,
Status: "delivered"
}
// Query the GSI: fetch ALL unpaid orders in the system
// GSI: UnpaidGSIPK = "UNPAID"
// → Scans just a handful of items instead of millions
4.5 Write Sharding — Distributing Hot Partitions
When one Partition Key receives too many writes (e.g., a global counter or leaderboard), that partition becomes a bottleneck. Write Sharding solves this by adding a random suffix:
// Instead of: PK = "GLOBAL_COUNTER" (hot partition!)
// Use: PK = "GLOBAL_COUNTER#" + random(0, 9)
PK: "GLOBAL_COUNTER#0", SK: "COUNT" → Value: 1523
PK: "GLOBAL_COUNTER#1", SK: "COUNT" → Value: 1487
...
PK: "GLOBAL_COUNTER#9", SK: "COUNT" → Value: 1501
// Total = Sum of all shards = 1523 + 1487 + ... + 1501
// 10 partitions share the write load → 10× throughput
⚠️ Write Sharding trade-offs
Reading the aggregate requires querying all shards and aggregating on the application side. This is a good fit for write-heavy cases like counters, votes, and real-time analytics. Avoid it for data that requires strong read-after-write consistency.
5. When to Use Single-Table vs Multi-Table?
Single-Table Design isn't always optimal. AWS officially recommends considering both approaches based on context:
| Criterion | Single-Table ✅ | Multi-Table ✅ |
|---|---|---|
| Cross-entity queries | Need to fetch multiple entity types together (materialized joins) | Each entity queried independently |
| DynamoDB Streams | Max 2 consumers — usually enough | Need >2 stream consumers across entities |
| Analytics/OLAP | Pure OLTP workloads | Need to export each entity to Redshift/S3 |
| Team size | One team owns the entire service | Multiple teams own separate entities |
| Monitoring | One table is easier to monitor | Need per-entity metrics |
| Cost | Optimized RCU/WCU via fewer round-trips | Can cost more due to many requests |
| Complexity | Complex schema — requires deep DynamoDB expertise | Simpler, easier to onboard new developers |
💡 Practical rule
Microservices: Each service should own its own DynamoDB table (just as each service owns its own database). Single-Table Design applies within a single service, not across all services in the system.
6. Serverless Integration on .NET
DynamoDB combined with AWS Lambda forms a powerful serverless architecture. With .NET, AWS provides an official SDK and an Object Persistence Model that makes Single-Table Design much easier:
graph LR
A["API Gateway"] --> B["Lambda .NET"]
B --> C["DynamoDB
Single Table"]
C --> D["DynamoDB Streams"]
D --> E["Lambda
Event Handler"]
E --> F["SQS / SNS
EventBridge"]
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style C fill:#e94560,stroke:#fff,color:#fff
style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E fill:#2c3e50,stroke:#fff,color:#fff
style F fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Typical serverless architecture: API Gateway → Lambda → DynamoDB Single Table → Streams → Event Processing
// .NET — DynamoDB Low-Level API with Single-Table Design
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
public class OrderRepository
{
private readonly IAmazonDynamoDB _client;
private const string TableName = "ECommerceTable";
// Fetch customer profile + all orders in a single Query
public async Task<(CustomerProfile, List<Order>)> GetCustomerWithOrders(string customerId)
{
var response = await _client.QueryAsync(new QueryRequest
{
TableName = TableName,
KeyConditionExpression = "PK = :pk",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
{ ":pk", new AttributeValue($"CUSTOMER#{customerId}") }
}
});
CustomerProfile profile = null;
var orders = new List<Order>();
foreach (var item in response.Items)
{
var sk = item["SK"].S;
if (sk == "PROFILE")
profile = MapToCustomer(item);
else if (sk.StartsWith("ORDER#"))
orders.Add(MapToOrder(item));
}
return (profile, orders);
}
// Fetch orders in a date range
public async Task<List<Order>> GetOrdersByDateRange(
string customerId, DateTime from, DateTime to)
{
var response = await _client.QueryAsync(new QueryRequest
{
TableName = TableName,
KeyConditionExpression = "PK = :pk AND SK BETWEEN :start AND :end",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
{ ":pk", new AttributeValue($"CUSTOMER#{customerId}") },
{ ":start", new AttributeValue($"ORDER#{from:yyyy-MM-dd}") },
{ ":end", new AttributeValue($"ORDER#{to:yyyy-MM-dd}~") }
}
});
return response.Items
.Where(i => i["SK"].S.StartsWith("ORDER#"))
.Select(MapToOrder)
.ToList();
}
}
7. Cost Optimization with DynamoDB
DynamoDB bills by Read Capacity Units (RCU) and Write Capacity Units (WCU). Single-Table Design saves significant cost by reducing the number of requests:
Cost optimization strategies
1. Separate hot/cold attributes: Frequently changing attributes (view count, last_login) should live in separate items to avoid rewriting large items on every update.
2. GSI projection: Only project the attributes you need into GSIs instead of ALL. GSIs incur separate storage and write costs.
3. On-Demand vs Provisioned: On-Demand suits unpredictable workloads. Provisioned + Auto Scaling is 5-7× cheaper for steady workloads.
4. Automatic TTL: Use Time-To-Live to auto-delete expired data (sessions, logs, temp data). DynamoDB doesn't charge for TTL deletes.
8. Anti-patterns to Avoid
Scan instead of Query reads the entire table — extremely expensive and slow. If you need a Scan, it's a sign your keys are designed wrong. Every access pattern should be served by Query or GetItem.9. DynamoDB Free Tier and Getting Started
AWS provides a permanent DynamoDB Free Tier (not limited to the first 12 months):
| Resource | Free Tier / month | Enough for |
|---|---|---|
| Read Capacity | 25 RCU provisioned | ~200 million reads/month (eventually consistent) |
| Write Capacity | 25 WCU provisioned | ~66 million writes/month |
| Storage | 25 GB | Plenty for side projects and MVPs |
| DynamoDB Streams | 2.5 million read requests | Small event-driven architectures |
| Global Tables | Not free | Multi-region replication is paid |
✅ Getting started
NoSQL Workbench: AWS provides a free tool to design and visualize DynamoDB models offline. You can create tables, define access patterns, and test queries before deploying to AWS. Download at aws.amazon.com/dynamodb/nosql-workbench.
DynamoDB Local: Run DynamoDB on your local machine for development without an AWS account. Docker-friendly: docker run -p 8000:8000 amazon/dynamodb-local.
10. Conclusion
DynamoDB Single-Table Design isn't just a technique — it's a fundamentally different way to think about data modeling. Instead of "what does the data look like" (relational), you answer "how is the data used" (access-pattern-first). Once you master patterns like GSI Overloading, Hierarchical Sort Keys, Adjacency List, Sparse Index, and Write Sharding, you can design NoSQL systems scaling to millions of requests per second at optimal cost and sub-10ms latency.
Combined with a serverless architecture (Lambda + API Gateway), the DynamoDB Free Tier is powerful enough to run production for an early-stage startup for free — a competitive advantage few other services can match.
References
- AWS Blog — Single-table vs. multi-table design in Amazon DynamoDB
- Alex DeBrie — The What, Why, and When of Single-Table Design with DynamoDB
- DEV Community — Advanced Single Table Design Patterns With DynamoDB
- AWS DynamoDB Developer Guide — Official Documentation
- AWS DynamoDB Pricing — Free Tier Details
API Gateway 2026 — Central Gateway Architecture for Microservices with YARP, Kong, and the BFF Pattern
Change Data Capture with Debezium — Real-Time Data Sync for Microservices
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.