DynamoDB Single-Table Design — The Art of NoSQL Modeling for Large-Scale Systems

Posted on: 4/18/2026 11:10:58 AM

< 10msConsistent latency at any scale
10 GBMaximum size per partition
25 GBPermanent Free Tier
Virtually unlimited horizontal scaling

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:

PKSKAttributes
CUSTOMER#C001PROFILEName: "Nguyen Van A", Email: "a@mail.com"
CUSTOMER#C001ORDER#2026-04-18#O100Total: 2500000, Status: "processing"
CUSTOMER#C001ORDER#2026-04-18#O100#ITEM#1Product: "Laptop", Qty: 1, Price: 2500000
CUSTOMER#C001ORDER#2026-04-15#O099Total: 350000, Status: "delivered"
CUSTOMER#C001ORDER#2026-04-15#O099#ITEM#1Product: "Book", Qty: 2, Price: 175000
CUSTOMER#C002PROFILEName: "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 PatternOperationKey Design
1Fetch customer profileGetItemPK=CUSTOMER#id, SK=PROFILE
2Fetch all orders of a customerQueryPK=CUSTOMER#id, SK begins_with ORDER#
3Fetch orders in a date rangeQueryPK=CUSTOMER#id, SK BETWEEN ORDER#date1 AND ORDER#date2
4Fetch an order + its itemsQueryPK=CUSTOMER#id, SK begins_with ORDER#date#orderId
5Find orders by statusQuery GSIGSI1PK=STATUS#processing, GSI1SK=date
6Fetch an order by orderIdQuery GSIGSI2PK=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 PatternQuery
Fetch all courses of Student S01PK = "STUDENT#S01", SK begins_with "COURSE#"
Fetch all students in Course C01PK = "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:

CriterionSingle-Table ✅Multi-Table ✅
Cross-entity queriesNeed to fetch multiple entity types together (materialized joins)Each entity queried independently
DynamoDB StreamsMax 2 consumers — usually enoughNeed >2 stream consumers across entities
Analytics/OLAPPure OLTP workloadsNeed to export each entity to Redshift/S3
Team sizeOne team owns the entire serviceMultiple teams own separate entities
MonitoringOne table is easier to monitorNeed per-entity metrics
CostOptimized RCU/WCU via fewer round-tripsCan cost more due to many requests
ComplexityComplex schema — requires deep DynamoDB expertiseSimpler, 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:

1 RCU= 1 strongly consistent read ≤ 4 KB
1 WCU= 1 write ≤ 1 KB
$1.25/ million WCU on-demand
$0.25/ million RCU on-demand

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

Anti-pattern #1: Full-table Scans
Using 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.
Anti-pattern #2: Hot Partitions
Uneven Partition Key distribution (e.g., using today's date as PK → traffic piles into today's partition). Fix: include entityId in the PK and use Write Sharding for global counters.
Anti-pattern #3: Large Items
DynamoDB caps items at 400 KB. Store blob data (images, documents) in S3 and only keep the S3 URL in DynamoDB. Similarly, avoid storing large arrays in one attribute — split them into separate items.
Anti-pattern #4: Relational Thinking
Normalizing data and "JOINing" in application code via many GetItem requests is the worst approach — it throws away DynamoDB's core advantage. Denormalize and store data redundantly.
Anti-pattern #5: Too Many GSIs
Each GSI is a copy of the data, costing storage and write capacity. Prefer GSI Overloading and Sparse Indexes before adding new GSIs. 5-7 GSIs is a reasonable upper bound.

9. DynamoDB Free Tier and Getting Started

AWS provides a permanent DynamoDB Free Tier (not limited to the first 12 months):

ResourceFree Tier / monthEnough for
Read Capacity25 RCU provisioned~200 million reads/month (eventually consistent)
Write Capacity25 WCU provisioned~66 million writes/month
Storage25 GBPlenty for side projects and MVPs
DynamoDB Streams2.5 million read requestsSmall event-driven architectures
Global TablesNot freeMulti-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