AWS DynamoDB

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

DynamoDB for people who know SQL. Partition and sort keys, why access patterns come before the schema, hot partitions and what it cannot do.

Amazon DynamoDB

DynamoDB is a managed key-value and document database with single-digit millisecond latency at effectively any scale. There are no servers, no connection pools and no version upgrades.

DynamoDB: Data Access Model

Application
API call (SDK / CLI)
DynamoDB Table
partition key + optional sort key
index lookup
Primary Key
partition key
GSI
global secondary index
LSI
local secondary index

Query uses an index (fast); Scan reads every item (expensive at scale)

The price of that is a genuine change in how you design. In a relational database you model the data and write whatever queries you need later. In DynamoDB you list your access patterns first and design the table to serve them, because a query the key schema does not support is not slow. It is impossible without scanning the entire table.

If you take one thing from this page: write down your queries before you create the table.

Keys decide everything

aws dynamodb create-table --table-name Orders \
  --attribute-definitions \
    AttributeName=customerId,AttributeType=S \
    AttributeName=orderDate,AttributeType=S \
  --key-schema \
    AttributeName=customerId,KeyType=HASH \
    AttributeName=orderDate,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST

The partition key (HASH) is hashed to choose which physical partition holds the item. The sort key (RANGE) orders items within that partition, and is what makes range queries possible.

Together they form the primary key and must be unique. A partition key alone is a simple primary key; with a sort key it is composite.

What you can do efficiently:

# All orders for one customer, newest first
aws dynamodb query --table-name Orders \
  --key-condition-expression 'customerId = :c' \
  --expression-attribute-values '{":c":{"S":"C-4821"}}' \
  --no-scan-index-forward

# Orders in a date range. This is what the sort key buys you
aws dynamodb query --table-name Orders \
  --key-condition-expression 'customerId = :c AND orderDate BETWEEN :a AND :b' \
  --expression-attribute-values '{":c":{"S":"C-4821"},":a":{"S":"2026-09-01"},":b":{"S":"2026-09-30"}}'

A query always requires an exact partition key value. "All orders over £100 last month, any customer" is not a query. It is a scan of the whole table, and that is a design problem rather than a tuning problem. Either add a global secondary index or accept that this data belongs in something relational.

Choose the partition key for distribution

Data is spread across partitions by the hash of the partition key, so a key with few distinct values concentrates traffic on one partition. That is a hot partition, and it throttles at roughly 3,000 read units and 1,000 write units per second no matter how much capacity the table has overall.

Partition keyVerdict
customerId, orderId, deviceIdGood (high cardinality, even spread)
status (pending/shipped)Bad (a handful of values)
orderDate on a write-heavy tableBad (today's date takes every write)
countryBad if traffic is concentrated in one

The symptom is throttling while CloudWatch shows consumed capacity well below provisioned. If a low-cardinality key is unavoidable, write sharding helps, append a random suffix (ORDER#2026-09-16#3) and query all shards in parallel, at the cost of real complexity.

On-demand or provisioned

aws dynamodb update-table --table-name Orders --billing-mode PAY_PER_REQUEST

On-demand scales instantly and charges per request. Start here: you cannot get the capacity wrong, and for spiky or unknown traffic it is also cheaper than provisioning for the peak.

Provisioned is cheaper at sustained, predictable volume, often substantially so above roughly 20% steady utilisation, and cheaper again with reserved capacity. Add auto scaling if you use it, or a traffic increase becomes throttling:

aws dynamodb update-table --table-name Orders \
  --billing-mode PROVISIONED \
  --provisioned-throughput ReadCapacityUnits=100,WriteCapacityUnits=50

Capacity units are worth understanding, because they explain most surprising bills. One WCU is one write of up to 1 KB. One RCU is one strongly consistent read of up to 4 KB, or two eventually consistent reads, so the default eventually consistent read is half price. A 10 KB item costs 3 RCUs to read strongly, and 10 WCUs to write.

Transactional reads and writes cost double.

Consistency

Reads are eventually consistent by default, which means a read immediately after a write may return the previous value. Usually irrelevant; occasionally the source of a genuinely baffling bug in a read-after-write flow.

aws dynamodb get-item --table-name Orders \
  --key '{"customerId":{"S":"C-4821"},"orderDate":{"S":"2026-09-16"}}' \
  --consistent-read

Strongly consistent reads cost twice as much and are not available on global secondary indexes at all. A constraint that occasionally decides a design.

Conditional writes

The mechanism that makes concurrent access safe, and the one to reach for when building anything idempotent:

aws dynamodb put-item --table-name Orders \
  --item '{"customerId":{"S":"C-4821"},"orderDate":{"S":"2026-09-16"},"status":{"S":"placed"}}' \
  --condition-expression 'attribute_not_exists(customerId)'
An error occurred (ConditionalCheckFailedException) when calling the PutItem
operation: The conditional request failed

That error is the success case when you are deduplicating. It means the item already existed. Combined with an SQS consumer, this is how you make at-least-once delivery harmless.

Streams

aws dynamodb update-table --table-name Orders \
  --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES

A change log of every insert, update and delete, retained 24 hours and readable by Lambda. It is how you maintain an aggregate, push changes to OpenSearch, or emit a domain event without the writer knowing about the consumer. NEW_AND_OLD_IMAGES costs nothing extra and lets a consumer see what actually changed.

TTL and backups

aws dynamodb update-time-to-live --table-name Sessions \
  --time-to-live-specification 'Enabled=true,AttributeName=expiresAt'

TTL deletes expired items free of charge, no write capacity consumed, which makes it far better than a scheduled cleanup job. The attribute must be a Unix epoch in seconds; milliseconds is the usual mistake, and it quietly sets expiry tens of thousands of years away. Deletion is best-effort within about 48 hours of expiry, so filter expired items in your queries rather than trusting the timing.

aws dynamodb update-continuous-backups --table-name Orders \
  --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true

Point-in-time recovery gives second-level restore for 35 days. Enable it on anything you would miss.

Common errors

ProvisionedThroughputExceededException

Throttling. If consumed capacity looks low, it is a hot partition, not a capacity shortfall.

ValidationException: Query condition missed key schema element: customerId

A query without an exact partition key value. Add a GSI or rethink the access pattern.

ValidationException: One or more parameter values were invalid:
Missing the key orderDate in the item

Every item must carry both key attributes. DynamoDB has no schema beyond the keys, but the keys are mandatory.

Quick reference

TaskCommand
Create a tableaws dynamodb create-table --billing-mode PAY_PER_REQUEST
Writeaws dynamodb put-item --item '…'
Read one itemaws dynamodb get-item --key '…'
Query a partitionaws dynamodb query --key-condition-expression '…'
Conditional write--condition-expression 'attribute_not_exists(pk)'
Enable streams--stream-specification StreamEnabled=true,…
Enable TTLaws dynamodb update-time-to-live …
Enable PITRaws dynamodb update-continuous-backups …
See consumed capacity--return-consumed-capacity TOTAL

Key takeaways

  • List your access patterns before creating the table. The key schema is not easily changed later.
  • A query needs an exact partition key value. Anything else is a scan.
  • Pick a high-cardinality partition key or you get a hot partition that throttles at ~3,000 RCU.
  • Throttling with low consumed capacity means a hot key, not insufficient capacity.
  • Start on-demand. Move to provisioned once traffic is predictable, and add auto scaling.
  • Eventually consistent reads are the default and cost half as much as strongly consistent ones.
  • Conditional writes are how you get idempotency: ConditionalCheckFailedException is often success.
  • TTL deletions are free. The attribute must be epoch seconds.

Check your understanding

Exercise

Throttling with capacity to spare

A table provisioned for 2,000 WCU is throttling writes, while CloudWatch shows consumed write capacity around 400. The partition key is orderStatus, with values pending, shipped and delivered. What is happening?

Exercise

A query that cannot be written

A table has partition key customerId and sort key orderDate. The team needs "all orders over £100 placed last month, across all customers". What is the correct assessment?


Frequently Asked Questions

When should I use DynamoDB instead of RDS?

Choose DynamoDB when your access patterns are known and key-based, you need predictable single-digit millisecond latency at high scale, and you would rather not manage a database. Choose a relational database when queries are ad hoc, you need joins and aggregations, or reporting matters. Those are precisely what DynamoDB is bad at. The deciding question is usually whether you can enumerate your queries up front: if you can, DynamoDB is excellent; if analysts will invent new ones, it will hurt.

What is the difference between a partition key and a sort key?

The partition key determines which physical partition stores an item, via a hash, so it must have high cardinality to spread load. The sort key orders items within a partition, which is what makes range queries possible. All orders for one customer between two dates, for example. A partition key alone gives you exact-match lookups only; adding a sort key gives you ordering and ranges within each partition. Together they must uniquely identify an item.

Why is DynamoDB throttling when I have provisioned capacity available?

Because capacity is provisioned per table but enforced per partition, at roughly 3,000 read and 1,000 write units each. A low-cardinality partition key concentrates traffic on a few partitions, so those throttle while table-level consumed capacity looks comfortable. Check whether your partition key has few distinct values, status, country, or a date on a write-heavy table are the classic offenders. The fix is a higher-cardinality key, or write sharding with a random suffix if the key cannot change.

Should I use on-demand or provisioned capacity?

Start with on-demand. It scales instantly, cannot be under-provisioned, and for spiky or unpredictable traffic it is genuinely cheaper than provisioning for the peak. Move to provisioned once traffic is steady and predictable, where it can cost substantially less at sustained utilisation, with reserved capacity cheaper still, and always pair it with auto scaling, otherwise growth turns into throttling. Switching between modes is a single update-table call, limited to once every 24 hours.

What is DynamoDB single-table design and do I need it?

Storing several entity types in one table, using generic key names and prefixed values such as CUSTOMER#4821 and ORDER#2026-09-16, so one query can fetch related items together. The benefit is fewer requests and no joins; the cost is a schema that is hard to read and hard to change. It is worth it at high scale with well-understood access patterns. For most applications, separate tables per entity are clearer and the extra request is not the bottleneck. Start there and consolidate only if you measure a reason to.

How does DynamoDB TTL work and why are my items not being deleted?

TTL deletes items whose specified attribute holds a past timestamp, consuming no write capacity, which makes it much better than a cleanup job. Two things commonly go wrong. The attribute must be a Unix epoch in seconds; supplying milliseconds sets expiry far in the future and nothing appears to happen. And deletion is best-effort, typically within 48 hours of expiry rather than immediately, so queries should still filter expired items rather than assuming they are gone.

Continue Learning

Explore Related Topics

When it breaks

Related Resources