DynamoDB Fundamentals: The Mental Model Before the Schema
If you’re coming from Postgres or MySQL, DynamoDB will feel wrong at first. Not broken - wrong in a “why would you design a database that can’t do X” way. The constraints feel arbitrary until you understand what they’re optimizing for.
Not a complete DynamoDB reference - just the handful of concepts that change how you think about data access, explained for someone who already knows how relational databases work.
The fundamental constraint
In Postgres, you design tables around entities and add queries later. Indexes are reactive - you find a slow query and add an index to speed it up. You can always write a WHERE clause for anything. It might be slow without an index, but it’s possible.
DynamoDB doesn’t have slow paths. Every query must be served by a key. If you don’t have a key that supports the query, it’s either impossible or requires reading the entire table. There’s no query optimizer, no execution plan, no fallback.
That constraint is what makes DynamoDB fast at any scale - predictable single-digit millisecond latency, because there are literally no slow paths in the system. You pay for it upfront: you have to decide which queries your schema supports before you write any data.
In SQL, access patterns are a performance concern. In DynamoDB, they’re a correctness concern. A missing access pattern isn’t slow - it’s impossible. This is why listing access patterns before touching key design is the first rule of DynamoDB schema design.
Items and attributes, not rows and columns
DynamoDB stores items. An item is a collection of attributes. That’s it.
There are no columns. Items in the same table can have completely different attributes. Two users in a Users table could have entirely different fields - DynamoDB doesn’t care. The only attributes the database enforces are the primary key attributes. Everything else is optional, untyped, and flexible.
This schema flexibility is a feature in single-table design, where different entity types share one table and each has different attributes. It’s a footgun if you’re used to the database enforcing structure.
The primary key: partition key and sort key
Every table has a primary key. Simple tables use just the partition key (each item identified by one attribute). Composite tables use partition key + sort key (each item identified by the combination of two attributes). Most production tables are composite.
The partition key determines which physical node stores the item. DynamoDB hashes the value and routes the item there. Items with the same partition key land on the same node.
The sort key determines the order of items within a partition - alphabetically for strings, numerically for numbers.
Three operations, that’s the whole query model:
GetItem- exact match on both partition key and sort key. O(1), always fast.Query- exact match on partition key, plus optional conditions on the sort key (begins_with,between,>,<, etc.). Returns items in sort key order.Scan- reads every item in the table. Avoid it.
If a query starts with the partition key, it’s fast. If it doesn’t, it’s a Scan or impossible.
The sort key is not just an identifier
Most teams miss this when they first design a schema.
A sort key of ORDER#2026-02-17T10:00:00Z#order_01 lets you query at any prefix level:
- All orders:
begins_with ORDER# - Orders after a date:
> ORDER#2026-02-17 - Orders in a date range:
between ORDER#2026-02-01 and ORDER#2026-03-01 - One specific order: exact match
The sort key is a hierarchy. The more structure you embed in it, the more query flexibility you get within a partition, without any additional indexes. That’s why single-table schemas spend so much effort on composite sort key design.
The ULID vs UUID sort key post covers the specific decision of what to put in a sort key. The GSI vs sort key reshape post covers when to extend the sort key vs when to add a new index.
Global Secondary Indexes (GSIs)
A GSI is a second copy of your data with a different primary key. You designate which attribute becomes the GSI’s partition key and which becomes its sort key. DynamoDB replicates every write to the GSI automatically.
Items without the GSI’s partition key attribute are excluded from the GSI entirely - not indexed, not stored, not charged for. This is called a sparse index and it’s actually useful, not a limitation.
Every write to the base table is also a write to every GSI. A table with 3 GSIs pays 4x the write cost per item (1 base + 3 replications). GSIs also have their own storage cost - each one is a full copy of the projected attributes. There is no free GSI.
A table can have up to 20 GSIs. Well-designed single-table schemas typically have 1–3. If you’re regularly hitting 5+, it usually means access patterns weren’t fully defined before schema design started - which is one of the most common single-table mistakes.
Read and write capacity units
DynamoDB charges per operation, not per hour of server time. A Read Capacity Unit (RCU) covers one strongly consistent read of up to 4KB - eventually consistent reads cost half. A Write Capacity Unit (WCU) covers one write of up to 1KB.
Item size matters. A 3KB item costs 1 RCU per read. A 5KB item costs 2 RCUs. Projections (requesting only specific attributes) reduce what you’re charged for. This is why key design that keeps items small and queries tight has real cost implications at scale.
Two capacity modes: on-demand (pay per request, no capacity planning, more expensive) and provisioned (reserve RCUs and WCUs per second, cheaper for predictable traffic, requires setting sensible limits).
The on-demand vs provisioned post has the actual break-even math.
The 400KB item limit
Each DynamoDB item can be at most 400KB. This sounds like plenty until you try to store a document’s full version history, a chat conversation’s messages, or a product with hundreds of variants as a nested attribute.
Don’t store unbounded lists as item attributes. An order with 5 line items as a nested list is fine. An order that could have 500 line items is a problem - you’ll either hit the limit or pay for reading 50KB when you only need the total.
The fix is separate items. Each line item gets its own DynamoDB item with a sort key that groups it with the parent order. This is one of the most common single-table design mistakes - and it’s hard to fix after data is in production.
Why single-table design exists
In a Lambda-based application, each DynamoDB call adds latency. A request that needs a user record, their subscription, and their recent activity takes three round trips if those live in separate tables.
Single-table design co-locates data that’s accessed together. All tenant data under TENANT#<id>. All conversation messages under CONVO#<id>. One Query fetches everything in a single round trip, because the data that belongs together lives in the same partition.
The tradeoff: you have to design for your access patterns upfront, because there’s no “I’ll just add a query later” escape hatch without a schema migration.
The when not to use single-table design post is the first thing I’d read before committing to this approach - it’s more useful than any of the pattern posts for teams deciding whether to adopt it.
What to read next
In this order:
- When NOT to Use Single-Table Design - know if it applies to your situation before going further
- The 5 Most Common Single-Table Design Mistakes - what goes wrong, so you can avoid it
- SaaS Multi-Tenant Pattern - the most common schema, walked through completely: 10 access patterns, GSI design, ElectroDB entity code
- ULIDs vs UUIDs vs Timestamps for Sort Keys - the sort key decision that affects every entity in your schema
Or go to the Start Here page for a curated path based on what you’re building.
If something in the pattern posts doesn’t make sense, it’s usually because one of the concepts above isn’t fully clicked yet. Come back here. I’m building singletable.dev to make these schema decisions visual - so the key design implications are obvious before you write any code.