The 5 Most Common DynamoDB Single-Table Design Mistakes
I’ve been doing DynamoDB schema reviews through singletable.dev and answering questions in the SST Discord, ElectroDB GitHub Discussions, and r/aws. The same mistakes come up again and again.
These aren’t edge cases. They’re the five patterns I see most often when a team tries single-table design and ends up frustrated. Every one of them is fixable - but easier to prevent than to fix after data is in production.
1. Designing keys before listing access patterns
This is the most damaging mistake because it happens first and everything downstream inherits the problem.
The pattern: a developer reads about single-table design, gets excited about PK/SK structures, and immediately starts designing keys. USER#<userId> as PK, ORDER#<orderId> as SK - looks clean, feels right. (And often the ORDER#<orderId> choice hides a second mistake - see ULIDs vs UUIDs vs timestamps for why a plain UUID sort key breaks chronological queries you didn’t realize you needed.)
Then the product manager asks for a feature that needs “all orders by status” and there’s no key structure that supports it without a table scan.
Write down every access pattern before touching key design. Every single one. Use a numbered list:
- Get user by ID
- Get all orders for a user, newest first
- Get orders by status (pending, shipped, delivered)
- Get order details with items
- …
This list is the requirements document for your schema. The key design exists to serve these patterns - not the other way around. If you can’t list your access patterns, you’re not ready for single-table design.
The SaaS Multi-Tenant pattern starts with 10 access patterns. The E-Commerce Orders pattern starts with 8. The key design follows.
Production issue
You're likely losing money on this in production.
A wrong partition key or missing GSI is a live cost problem. Get a DynamoDB schema review before your next deploy — async, fixed price, 5 business days.
2. Treating GSIs as relational indexes
In a relational database, you add an index when a query is slow. It’s reactive - the query exists, the index makes it faster.
DynamoDB GSIs don’t work that way. A GSI is a full copy of your data with a different key structure. It has its own throughput, its own storage costs, and it replicates every write. Adding a GSI isn’t “adding an index” - it’s creating a parallel table.
The mistake: teams add GSIs for every new query need, ending up with 5+ GSIs on a single table. Each GSI multiplies write costs. At scale, the GSI write amplification becomes the dominant cost.
Design GSIs to serve specific, pre-defined access patterns - not ad-hoc queries. Before adding a GSI, ask:
- Is this access pattern actually needed, or is it a “nice to have”?
- Can I serve this with a different SK prefix on an existing GSI (overloading)?
- Can I restructure the PK/SK to absorb this pattern into the base table?
GSI overloading - using non-colliding prefixes to share a single GSI across multiple entity types - is the standard technique for keeping GSI count low. The SaaS Multi-Tenant pattern shows how 3 GSIs collapse into 1. On the cost side, this is one of the biggest levers in DynamoDB cost optimization - every avoided GSI is one less write amplification per item.
3. Ignoring hot partition keys until production
DynamoDB distributes data across partitions based on the partition key. If too many reads or writes hit the same partition key, you get throttling - even if you have capacity to spare on other partitions.
The mistake: using low-cardinality values as partition keys or GSI partition keys. Common offenders:
STATUS#pendingas a GSI PK - all pending items in one partitionDATE#2026-04-13as a PK - all writes for today hit one partitionTENANT_LISTas a GSI PK - every tenant in one partition
These work fine in development. They break in production when traffic concentrates.
Evaluate your partition keys for cardinality before you deploy. Ask: “What’s the maximum number of items that will share this partition key, and what’s the peak write/read throughput on that partition?”
DynamoDB partitions can handle ~3,000 RCUs and ~1,000 WCUs per second. If your hottest partition key will exceed that, you need write-sharding: append a random shard number to the key and fan out reads across shards.
The E-Commerce Orders pattern shows this technique for a STATUS#pending GSI key.
4. Nesting data that should be separate items
DynamoDB supports complex attribute types - lists, maps, sets. It’s tempting to store child data as a nested list on the parent item.
The mistake: storing order items as a list attribute on the order record, or storing comments as a nested array on a post. It looks clean in the data model and saves a Query call.
Then the order gets 50 items and you’re reading 200KB per order. Or you need to query across order items (“find all orders containing product X”) and there’s no way to do it without scanning every order.
Use separate items when:
- The list can grow beyond 10-20 items
- You need to query child items independently of the parent
- Individual child items are updated without touching the parent
- The combined size could approach the 400KB item limit
Use nested lists only when the child data is always read with the parent, the list is small and bounded, and you never query children across parents.
The E-Commerce Orders pattern explains this tradeoff for order items - separate records is the safer default.
Production issue
The longer this runs in production, the harder it is to fix.
DynamoDB schemas harden over time. A review now is hours of work. A migration later is weeks. Async, fixed price, 5 business days.
5. Not versioning your schema from day one
DynamoDB doesn’t have schema migrations in the relational sense. There’s no ALTER TABLE. Key structures are immutable. Changing a PK/SK pattern requires a dual-write migration with backfill.
The mistake: treating the schema as fixed once deployed. No versioning, no documentation of what the schema supports, no plan for evolution.
Then the product grows, access patterns change, and the team discovers they need a key structure change on a table with 50 million items. The migration takes weeks.
Version your schema from day one. ElectroDB has built-in entity versioning - the version field in your entity model lets you handle schema evolution at the application layer. Old items with version “1” can coexist with new items at version “2” in the same table.
Beyond the code: maintain a document that lists every entity, every access pattern, every GSI, and what the schema explicitly doesn’t support. The unsupported queries post has a template for this. Update it with every schema change.
When a migration is needed, the schema migrations guide covers the four types of changes and how to handle each one.
The meta-mistake
All five mistakes share a root cause: applying relational database intuitions to DynamoDB.
In Postgres, you design tables around entities and add indexes later. In DynamoDB, you design keys around access patterns and the entity structure follows.
In Postgres, schema changes are cheap. In DynamoDB, key changes are expensive.
In Postgres, any query is possible (just maybe slow). In DynamoDB, impossible queries are impossible - no amount of capacity fixes a missing access pattern.
Single-table design isn’t harder than relational design. It’s a different discipline. The teams that succeed invest in understanding it on its own terms. The teams that struggle spend their time fighting the fact that it isn’t Postgres.
If you’re designing a DynamoDB schema and want a second pair of eyes before you commit to production, I offer async schema reviews - you send access patterns and entities, I return a full schema design with ElectroDB entity definitions. The patterns in the singletable.dev library cover the most common use cases.