DynamoDB Deep Dive
Most “DynamoDB knowledge” online is too generic/too long to help.
I’ve been living inside DynamoDB for a while now, building and scaling a distributed system on AWS that has to survive couriers, customers, and agents all hammering it at once (at a scale of 10+ million orders per day).
Along the way, I collected a pile of notes with, let's say, my best practices. Half of them learned the easy way (reading docs), half learned the hard way (staring at a CloudWatch, Grafana at 2am wondering why one partition is on fire while the rest of the table is bored).
This is the cleaned-up version of my notes. Just the things I actually needed to know. I assume if you are reading this, you are already familiar with the basic DynamoDB concepts, so we will not stop there.
eventual vs strong consistency
By default, DynamoDB gives you eventual consistency. You write, and a moment later every replica has the update. Almost always fast enough, almost always correct enough, cheap too.
If “almost” isn’t good enough for you, there’s ConsistentRead: true. Add that to a GetItem, Query, or Scan (!! do not use Scan on production, it is bad, it is expensive), and DynamoDB routes your read through the primary replica that’s guaranteed to have every successful write applied. You get the freshest possible data.
Of course, it costs more to you. Literally, double the RCUs, and it can be slower because there’s coordination happening across nodes instead of just reading whatever replica is closest. And it’s not available everywhere. Base tables and LSIs support it, GSIs don’t. If your access pattern needs strong consistency, that alone might decide whether you reach for an LSI or a GSI.
My rule of thumb: default to eventual, and only reach for strong consistency when the alternative is a genuinely bad user experience.
LSI vs GSI
This is the one that trips people up the most, because on paper LSIs and GSIs look like siblings. In practice they behave very differently, and one of them is way less forgiving.
LSI (Local Secondary Index)
Same partition key as the base table, different sort key. That’s the whole point. It lets you resort items within a partition to support another data access pattern.
Can be strongly consistent. GSIs can never do this, at least now (July 2026).
Must be created at table creation time. You cannot bolt one on later, and you cannot delete one once it exists. Get this wrong on day one and you’re rebuilding the table.
Shares the base table’s provisioned throughput. No separate capacity to manage, but also no isolation. A hot LSI can starve your base table.
Everything sharing a partition key (the “item collection”) is capped at 10GB total, across the base table and all its LSIs combined. This is easy to forget until you hit it.
GSI (Global Secondary Index)
Independent partition key and sort key. You can query across the entire table, not just within a partition.
Add or remove them whenever you want.
Own provisioned throughput, separate from the base table.
Eventually consistent only.
So the real decision isn’t “which is more powerful”. GSIs almost always win on flexibility. If you’re not 100% sure, use a GSI. You can always add another one next sprint. You can’t undo an LSI.
partition keys
DynamoDB spreads your data across partitions based on the hash of your partition key. If your key distributes evenly, life is good. Throughput scales roughly linearly and nothing gets hot. If it doesn’t, one partition eats all the traffic while its neighbors sit idle, and you throttle even though your table-level metrics look fine.
So, always aim for a pattern where your partition key is random enough and spreads writes/reads across shards (e.g., based on a random user_id, order_id, or a combination of the two).
DynamoDB does have your back to some extent. Adaptive capacity automatically borrows RCUs/WCUs from cooler partitions to help a temporarily hot one. It’s a real safety net, not marketing copy. But adaptive capacity is for temporary hot spots. If one key is permanently the busiest thing in your system (think: one massive celebrity user), no amount of borrowing saves you. You need a synthetic key strategy, like sharding that one key into KEY#0 through KEY#9 and fanning reads back out on the query side.
TTL
Set an expiry timestamp attribute on an item, and DynamoDB will delete it for you once it passes. You don't need a cron job or a batch cleanup script. No cost of storing temporary state forever. If you've got Streams enabled, those TTL deletions still show up in the stream. Just note the retention is a fixed 24 hours, not configurable so you can react to expirations just like any other write. If you need to hold onto that history longer, Kinesis Data Streams for DynamoDB is a separate, opt-in option that lets you configure retention anywhere from 24 hours up to 365 days.
the limits that will eventually find you
A few numbers worth knowing:
400KB per item hard cap. There are no exceptions.
~10GB per partition, and DynamoDB just keeps adding partitions as you grow. There is no upper bound on total table size.
3,000 RCUs or 1,000 WCUs per partition per second (that’s reads of 4KB and writes of 1KB as the unit). Blow past this on a single partition and you’re throttled and retrying, regardless of how much capacity the table as a whole has.
That last one is the sneaky part. Your table-level provisioned or on-demand capacity can look enormous, but if all your traffic funnels into one partition key, you’ll hit that per-partition ceiling long before you hit the table’s.
what it actually costs
Roughly (and prices drift per AWS region, so treat this as a mental model, not an invoice):
~$1 per million WCUs
~$0.25 per million RCUs
~$0.25/GB-month for storage
Reads are cheap, writes are the expensive lever, and storage is basically a rounding error unless you’re hoarding data you should’ve TTL’d away.
single-table design
This is the part that feels wrong the first time you do it and then feels obvious forever after. Instead of one table per entity type, you throw everything users, orders, conversations, whatever into one table, and you model your access patterns through the keys themselves.
The pattern that’s served me well:
Use boring, generic attribute names:
PK,SK,GSI1PK,GSI1SKetc. The table shouldn’t know or care that it’s storing “orders”. The semantics live in the values, not the schema.Encode meaning into the key strings themselves. Something like:
PK = U#<USER_ID>
SK = C#<CHAT_ID> assuming that chat_id is a UUIDv7. Because UUIDv7 is time-ordered, sorting by chat_id gives us chronological ordering by creation time.
GSI1PK = O#<USER_ID>#<ORDER_ID>
Now a single table can answer “give me this user’s chats” and “give me this order’s chats” without needing separate tables or a join because there is no join in DynamoDB, and pretending otherwise is how people end up fighting the database instead of using it.
Lean on sort-key ordering. The sort key isn’t just a filter. It physically determines how items are laid out within a partition. That means clever SK design gives you cheap, efficient pagination almost for free, instead of bolting on offset-based pagination that gets slower the deeper you go.
The mental shift is: stop designing your table around your entities, and start designing it around your queries/access patterns. Write down every access pattern first. The keys fall out of that list, not the other way around.
summary
Default to eventual consistency, reach for strong only when staleness actually breaks something. Use GSIs unless you specifically need LSI’s strong-consistency guarantee, because LSIs are permanent decisions. Design your partition key for even distribution before you design anything else. Let Streams and TTL do the event-driven and cleanup work for you instead of writing it yourself. And when you’re modeling the table, design around your access patterns, not your entities.
Nothing here is exotic. It’s just the stuff that only really sinks in after your first throttling incident.
If this was useful, send it to someone prepping for a system design interview or learning databases. Drop your own rules in the comments. Curious what I’m missing.
Thanks for reading,
Adlet Balzhanov
Connect with me on LinkedIn, just use the button below. I read every message. Cheers!


