DynamoDB gives you single-digit millisecond reads at any scale in exchange for one hard constraint: every access has to be expressible as a key lookup. There is no query planner, no join, no index the optimiser picks for you, and no way to ask a question you did not design the table to answer. That trade is the entire product. It is why performance does not degrade as the table grows -- the work per request is the same at a gigabyte and at a petabyte -- and it is why the modelling process runs backwards compared with a relational database: you enumerate access patterns first and derive the keys from them, rather than normalising entities and querying whatever you need later. Most DynamoDB failures are that step done wrongly, showing up months later as hot partitions, unaffordable scans, or a feature request that the key schema cannot serve.
The data model
A table holds items; an item is a collection of attributes and can be up to 400 KB. Only the key attributes have a declared schema -- everything else is per item, so two items in the same table need not resemble each other at all.
The primary key is either a partition key alone, which must be unique across the table, or a partition key plus a sort key, in which case the pair must be unique and many items may share a partition key. The composite form is what makes DynamoDB useful rather than a plain key-value store: all items sharing a partition key form an item collection, stored together and ordered by sort key, and that ordering is the only range access the database offers.
Everything expressive flows from sort key design. A sort key of ORDER#2026-08-13#4471 lets you fetch all of a customer's orders, orders in a date range, or orders since a point, with one request and no filtering. A sort key that is a bare identifier gives you none of that. The convention of building sort keys as prefixed, hierarchical, lexicographically-sortable strings is not decoration -- it is how range queries get built on a database whose only range operator is 'begins with' or 'between' on a single sorted dimension.
Partitions, and the hot partition problem
DynamoDB hashes the partition key to place an item on a physical partition. Each partition holds up to about 10 GB and can serve roughly 3,000 read units and 1,000 write units per second. Tables are split into more partitions as they grow in size or throughput.
The failure mode follows immediately. If traffic concentrates on one partition key, it concentrates on one partition, and that partition's per-partition ceiling applies no matter how much capacity the table has. A table provisioned for 100,000 writes a second will still throttle if every write goes to the same key. This is the hot partition, and it is the single most common DynamoDB production problem.
Adaptive capacity has taken most of the sharp edges off: unused throughput is redistributed to hot partitions automatically and within seconds, and a persistently hot item collection can be isolated onto its own partition. It does not repeal the per-partition ceiling -- it just means you have to be genuinely, sustainedly skewed before you notice.
So partition key selection is a design decision with a single criterion: high cardinality and even access. A user identifier or a tenant identifier is usually good. A status field, a country, a date, or a constant is bad -- and 'a date' is the one that catches people, because a table keyed by day looks beautifully organised and sends one hundred percent of today's traffic to one partition. Where the natural key is unavoidably skewed, write sharding is the standard remedy: append a small random or hashed suffix to spread writes across N keys, and fan out across those N on read.
Reading — Get, Query and the trouble with Scan
Three read operations, with a large gap between them.
GetItem fetches one item by full primary key. Constant cost, millisecond latency, the operation the database is built for. BatchGetItem does up to a hundred of these in one call.
Query reads within a single partition key, optionally with a sort key condition, in sort order, forwards or backwards. This is the workhorse for anything collection-shaped -- a user's orders, a conversation's messages, a device's readings for a time range. Note the constraint precisely: a Query always names exactly one partition key value. There is no query across partitions.
Scan reads the entire table. It is the operation that makes DynamoDB look slow and expensive, because it is -- cost is proportional to the data scanned, not to the data returned, and a filter expression is applied after the read, so filtering does not reduce what you are charged. A scan in a request path is almost always a modelling failure that should be fixed with an index, and a scan in a periodic job is acceptable if it is budgeted. Parallel scan splits the work into segments for throughput when you genuinely must read everything, at the cost of consuming capacity faster.
Two mechanics that catch people: every read is paginated at 1 MB, so any result set may be partial and correct code loops on the returned continuation key; and reads are eventually consistent by default, costing half as much as a strongly consistent read. Eventual consistency here means a read may miss a write from the last fraction of a second, which is fine for most workloads and wrong for read-after-write flows -- ask for consistency explicitly where it matters, and know that global secondary indexes cannot provide it at all.
Capacity modes
Provisioned capacity means you declare read and write units per second and pay for them whether used or not. One write unit covers a 1 KB write per second; one read unit covers a 4 KB strongly consistent read, or two eventually consistent ones. Auto-scaling adjusts the provisioned level towards a target utilisation, which handles daily cycles well and sudden spikes poorly -- it reacts over minutes, so a step change gets throttled while it catches up.
On-demand charges per request with no capacity planning, absorbs spikes instantly up to double the previous peak, and scales beyond that within minutes. It costs several times more per unit of work than provisioned capacity does.
The choice is arithmetic. Because on-demand's per-request price is roughly six to seven times the provisioned equivalent, provisioned capacity wins whenever sustained utilisation is comfortably above about fifteen to twenty percent of what you would have to provision. Below that -- spiky, unpredictable, or genuinely low-traffic workloads, and anything new whose traffic you cannot yet estimate -- on-demand is both cheaper and less work. Reserved capacity cuts the provisioned price further for a committed baseline and is worth it for stable, long-lived tables.
The practical pattern for a new service: start on-demand, watch the consumed capacity graphs for a few weeks, and switch to provisioned with auto-scaling once the shape of the load is known and the arithmetic favours it. Switching modes is an online operation with a cooldown, not a migration.
Secondary indexes
An index is a second view of the table with different keys, and DynamoDB has two kinds with genuinely different properties.
A local secondary index keeps the table's partition key and substitutes a different sort key. It lives in the same partition as the base item, which is why it can serve strongly consistent reads and why it must be created with the table and can never be added afterwards. It also brings a constraint that is easy to miss until it bites: a table with an LSI caps each item collection at 10 GB, because the collection and its index entries must fit one partition. If any single partition key value can accumulate unbounded items, an LSI is a time bomb.
A global secondary index defines an entirely new partition and sort key over the same items, stored separately with its own capacity. It can be created and deleted at any time, has no size limit, and is eventually consistent only. This is the one to reach for by default.
The GSI hazard to internalise: throttling on an index propagates back to the base table. Writes to the table must also be applied to each GSI, so if a GSI's capacity is insufficient, or its partition key is skewed while the table's is not, base table writes begin to throttle. A well-designed table can be brought down by a badly-keyed index, which is why index key cardinality deserves the same scrutiny as the table's.
Two techniques worth knowing. A sparse index exploits the fact that an item only appears in an index if it has the index's key attributes -- so writing a status attribute only on items needing attention produces an index containing only those items, which is a very cheap work queue. And projection choice matters for cost: projecting only the attributes a query needs keeps the index small, while projecting everything doubles storage and write cost but avoids a fetch back to the base table.
Single-table design
The pattern that dominates advanced DynamoDB usage is to put several entity types in one table with generic key attribute names, so that one request can retrieve related items of different types together.
PK SK type attributes
CUSTOMER#42 PROFILE customer name, email, tier
CUSTOMER#42 ORDER#2026-08-13#01 order total, status
CUSTOMER#42 ORDER#2026-08-14#07 order total, status
CUSTOMER#42 ADDRESS#HOME address line1, city
ORDER#01 ITEM#SKU-9 lineitem qty, priceA single Query on PK = CUSTOMER#42 returns the profile, the addresses and the orders in one round trip -- the join done at write time by key design rather than at read time by the database. Adding SK begins_with ORDER# narrows it to orders; adding a date range narrows further. Many-to-many relationships are modelled as adjacency lists, and a GSI with the keys inverted lets you traverse the relationship in the other direction.
The honest counter-argument deserves equal space. Single-table design makes the data hard to read directly, hard to analyse without exporting, and hard to evolve when a new access pattern does not fit the existing keys -- which requires either a new GSI or a backfill. It concentrates schema knowledge in application code, and it is genuinely difficult to hand to a team that has not internalised it.
A defensible middle position: use single-table design where entities are tightly related and always accessed together, and separate tables where they are not. The purist argument for one table per service is about minimising round trips, which matters enormously at high scale and very little at moderate scale. Choose based on your actual latency budget rather than on doctrine, and be aware that the pattern's cost is paid by the next engineer.
Streams and change data capture
Every table can emit an ordered log of item-level changes. DynamoDB Streams retains these for 24 hours, and each record can carry the keys only, the new image, the old image, or both, configured per table.
Ordering is guaranteed per partition key, which is exactly the guarantee needed for per-entity consistency and no more. A Lambda function attached to the stream is invoked with batches per shard, retries on failure, and -- importantly -- blocks that shard while it retries, so one poison record can stall processing for that partition key range. Configure a failure destination and bisection on error, exactly as for any stream source.
The uses are the standard event-driven set: maintaining aggregates that DynamoDB cannot compute itself, syncing a search index for the ad-hoc queries the key schema cannot serve, fanning out notifications, and replicating into an analytics store. That second one is worth stating plainly, because it is the standard answer to DynamoDB's biggest limitation: when you need queries the keys do not support, stream the data to something that does rather than contorting the table.
Where 24 hours of retention or multiple independent consumers are insufficient, the Kinesis integration writes the same change records to a Kinesis stream with longer retention and a wider consumer ecosystem -- at the cost of losing the strict per-key ordering guarantee.
TTL, transactions and conditional writes
TTL deletes items automatically based on an attribute holding an expiry timestamp in epoch seconds. It is free -- the deletes consume no write capacity -- and it is not prompt: expired items are typically removed within a couple of days, not at the instant they expire. That leads to the rule people learn the hard way: filter on the expiry attribute in your queries as well, because an expired item remains readable until the background process reaches it. TTL deletions appear in the stream, marked as system-initiated, which makes archive-on-expiry a clean pattern.
Conditional writes are the concurrency primitive. A condition expression makes a write apply only if the item is in an expected state -- attribute_not_exists(PK) for insert-if-absent, version = :v for optimistic locking, balance >= :amount for a guarded decrement. The write and the check are atomic at the item level, which covers a surprising share of what people reach for transactions to do, at a fraction of the cost.
Transactions extend that across items: up to a hundred items written all-or-nothing, with conditions, across tables in the same region and account. They cost twice as much as the equivalent non-transactional operations and they can fail on conflict, so they are for the cases that genuinely need multi-item atomicity -- moving money, claiming a unique name while writing the entity that owns it -- and not a default posture. Supplying a client request token makes a transaction idempotent across retries, which matters because a timed-out transaction may or may not have committed.
Operations, cost and the things around the table
Backups. Point-in-time recovery gives continuous backup with restore to any second in the last 35 days, which is the protection against a bad deploy that corrupts data. On-demand backups are the long-term snapshots. Both restore into a new table rather than in place, so recovery involves a cutover.
Analytics. Export to S3 dumps a table to object storage without consuming table capacity, which is the right way to feed a data lake or run a one-off analysis. Running analytical scans against the live table is the wrong way, and it is how production latency gets ruined by a report.
Caching. DAX is an in-front, write-through cache offering microsecond reads for read-heavy workloads with a tolerance for eventual consistency. It is genuinely useful and it is another cluster to run; for many workloads an application cache is enough.
Access control. IAM policies can restrict access down to specific partition key values through a condition on the leading key, which is how multi-tenant systems enforce isolation at the database boundary rather than in application code. Combine with a VPC endpoint to keep traffic off the public internet.
Cost. The bill is capacity or requests, plus storage, plus index copies, plus streams and backups. Two levers dominate: item size, because units are billed in 1 KB write and 4 KB read increments so trimming large attributes reduces cost proportionally; and index count and projection, because every GSI is a full second copy of the write traffic. Multi-region replication through global tables is a further multiplier and a topic of its own, covered separately in this category.
Anti-patterns
Scan in a request path. Fix the model, add an index, or stream to a search engine. A scan that is fast in development is a bill and a latency problem in production.
Low-cardinality partition keys. Status, type, tenant-with-one-big-tenant, or today's date. All of them concentrate traffic on one partition.
Unbounded item collections. A partition key whose items grow forever eventually hits the 10 GB limit if an LSI exists, and gets slow to query regardless. Bound collections by including a time bucket in the key.
Treating it as relational. Normalised tables joined in application code produce N+1 round trips and no referential integrity -- the worst of both models. If the access patterns genuinely need joins and ad-hoc queries, a relational database is the right tool and choosing it is not a failure.
Large items. Approaching 400 KB makes every read and write expensive. Store large payloads in object storage and keep a pointer in the item.
Designing before enumerating access patterns. The most consequential one. Write down every query the application will ever make, then design keys and indexes to serve them. A pattern discovered after launch usually costs a new index and a backfill, and occasionally a migration.