DEV Community

Cover image for DynamoDB Key Condition Expressions
DynoTable
DynoTable

Posted on • Edited on • Originally published at dynotable.com

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 partition key must be an equality (PK = :v); the sort key 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 = :v and nothing else — no ranges, no begins_with, no IN. DynamoDB hashes it to locate one partition.
  • The sort key takes a range operator. =, <, <=, >, >=, BETWEEN, or begins_with — this is where you slice an item collection.
  • It is not a filter. A key condition decides what gets read and billed; a FilterExpression only 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. Substring matching exists via contains() in a
FilterExpression (where you've already paid for the read); suffix matching
isn't available server-side at all — store a reversed key or filter it
client-side. (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 ChannelRefCH#{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:

Example Notes
KeyConditionExpression ChannelRef = :ch
ExpressionAttributeValues { ":ch": "CH#general" }
ScanIndexForward false

ScanIndexForward: 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:

Example Notes
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:

Example Notes
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.

Tip

Fixed-width, zero-padded timestamps are non-negotiable for range queries on a
String sort key. ISO-8601 sorts correctly as text; a Unix-epoch value stored
as a string works too, but only if every value is the same digit width — 9
sorts after 10 lexicographically. A Number-typed sort key compares
numerically, so padding is unnecessary (but you lose begins_with).

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.

This builder is preset to a pk = … AND begins_with(sk, …) query — change the
operator to see the KeyConditionExpression update:

See it in DynoTable

Run the same key condition against a real channel partition. The moment you set
a partition-key filter, DynoTable issues a Query — so you load only that 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. A
filter can't even reference PostedAt — it's the sort key, and DynamoDB rejects
a filter on a key attribute with a ValidationException. So the workaround is to
duplicate the date into a plain, non-key attribute (MessageDate) and filter on
that instead:

Example Notes
KeyConditionExpression ChannelRef = :ch
FilterExpression begins_with(MessageDate, :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 modeling 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 single Query.
  • One sort-key condition per query. You can't AND two sort-key predicates; pick BETWEEN or begins_with, not both.
  • Reserved words need aliases. A key named Timestamp or Name must use ExpressionAttributeNames (#ts), or the query errors. (AWS: reserved words)
  • BETWEEN is 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 see exactly
which slice each key condition returns.

Top comments (0)