DynamoDB Key Condition Expressions
A key condition expression is the KeyConditionExpression you pass to a
Query — the only part of the request DynamoDB uses to find items. Everything
else (filters, projections) runs after the read is already metered.
What is a key condition expression in DynamoDB?
A key condition expression is the KeyConditionExpression on a Query that tells DynamoDB which items to read. The must be an equality (PK = :v); the takes one range operator — =, <, <=, >, >=, BETWEEN, or begins_with. It decides what gets read and billed, unlike a filter.
- The partition key must be an equality.
PK = :vand nothing else — no ranges, nobegins_with, noIN. DynamoDB hashes it to locate one partition. - The sort key takes a range operator.
=,<,<=,>,>=,BETWEEN, orbegins_with— this is where you slice an item collection. - It is not a filter. A key condition decides what gets read and billed; a
FilterExpressiononly trims the result after you've paid for the read. - Sort keys are byte-ordered. Range operators compare lexicographically, so how you format the sort key string is your query power.
Why the partition key is locked to equality
DynamoDB stores items by hashing the partition key to a physical partition. A hash gives you one location, not a range — so there's nothing to scan across.
That's why PK > :v or begins_with(PK, :v) are rejected outright. The engine
can't answer "all partitions whose key starts with X" without reading the whole
table, which is exactly the Scan it's built to avoid.
Coming from SQL, this feels backwards: WHERE id LIKE 'order%' is trivial in
Postgres. In DynamoDB the partition key is an address, not a searchable column.
The sort key is where the power lives
Within one partition, items are stored sorted by the sort key. That ordering is what range operators exploit — DynamoDB seeks to a position and reads forward.
| Operator | Reads | Use it for |
|---|---|---|
SK = :v | One exact item | A specific child by its key |
SK < / <= / > / >= :v | One open-ended slice | "Everything after this point" |
SK BETWEEN :a AND :b | A closed range (inclusive) | A bounded window — a date range |
begins_with(SK, :p) | A prefix slice | A type or hierarchy under the PK |
There is no LIKE, no CONTAINS, no ENDS_WITH on the key. Substring and
suffix matching aren't byte-ordered, so they'd force a full read — by design,
the API won't let you. Those live in FilterExpression, where you've already
paid. (AWS: Key condition expressions)
A worked example: messages in a chat app
Say you're building channel-based chat. One table, partitioned by channel, sorted by message time. Original key schema:
- Partition key
ChannelRef—CH#{channelId} - Sort key
PostedAt— an ISO-8601 timestamp,MSG#2026-06-23T14:05:00Z
The MSG# prefix keeps message rows sortable and distinct from any other row
type you might co-locate under the same channel (pinned config, membership).
Load a channel's latest messages. Just the partition key, newest first:
KeyConditionExpression: ChannelRef = :ch
ExpressionAttributeValues: { ":ch": "CH#general" }
ScanIndexForward: falseScanIndexForward: false walks the sorted collection in reverse — the cheap way
to get "most recent first" without sorting client-side.
A specific day with begins_with. Because the timestamp is the sort key and
it's stored as text, a date prefix is a clean slice:
KeyConditionExpression ChannelRef = :ch AND begins_with(PostedAt, :day)
:ch "CH#general"
:day "MSG#2026-06-23"
That reads every message on 2026-06-23 and nothing else — DynamoDB seeks to the prefix and stops when it falls off the end. This only works because the prefix is a true left-anchor of a byte-sorted string.
A precise window with BETWEEN. For "the messages during the 14:00 hour",
an inclusive range beats a prefix:
KeyConditionExpression ChannelRef = :ch AND PostedAt BETWEEN :lo AND :hi
:ch "CH#general"
:lo "MSG#2026-06-23T14:00:00Z"
:hi "MSG#2026-06-23T14:59:59Z"
BETWEEN is inclusive on both bounds, so pick your endpoints deliberately — an
off-by-one here silently drops or doubles an edge message.
You can assemble and copy any of these expressions, with the
ExpressionAttributeValues map filled in for you, in the
DynamoDB expression builder — handy for
getting the begins_with and BETWEEN syntax right the first time.
See it in DynoTable
Run the same key condition against a real channel partition and watch the consumed capacity update live, so you can confirm you're reading a slice — not the whole collection.
The trap: confusing a key condition with a filter
The expensive mistake is reaching for FilterExpression to do a key's job.
KeyConditionExpression: ChannelRef = :ch
FilterExpression: begins_with(PostedAt, :day)This looks equivalent to the begins_with key condition above and returns the
same rows — but it reads the entire channel partition first, then discards
everything outside the day. You're billed for the full read.
Filters never reduce read cost. They run after DynamoDB has metered the items,
the same footgun as a filtered Scan. If a predicate
can go in the key condition, it belongs there.
The fix is upstream: if an access pattern can't be expressed as one PK equality plus a sort-key range, that's a modelling signal. Either reshape the sort key, or add an index keyed for the pattern — see GSI vs LSI and single-table design for how to lay the keys out.
Pitfalls and next steps
- Partition key is always
=. No ranges, ever. If you need a range across partitions, you've outgrown a singleQuery. - One sort-key condition per query. You can't
ANDtwo sort-key predicates; pickBETWEENorbegins_with, not both. - Reserved words need aliases. A key named
TimestamporNamemust useExpressionAttributeNames(#ts), or the query errors. (AWS: reserved words) BETWEENis inclusive. Both endpoints are matched — design your bounds accordingly.
Draft your key conditions in the expression builder, then try DynoTable to run them against your own tables and watch the capacity they actually consume.