Intermediate5 min read

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 must be an equality. PK = :v and nothing else — no ranges, no begins_with, no IN. DynamoDB hashes it to locate one partition.
  • The takes a range operator. =, <, <=, >, >=, BETWEEN, or begins_with — this is where you slice an .
  • 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.

OperatorReadsUse it for
SK = :vOne exact itemA specific child by its key
SK < / <= / > / >= :vOne open-ended slice"Everything after this point"
SK BETWEEN :a AND :bA closed range (inclusive)A bounded window — a date range
begins_with(SK, :p)A prefix sliceA 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 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:

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:

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.

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

Build your request
Generated code
new QueryCommand({
  "TableName": "AuditLog",
  "KeyConditionExpression": "#hashKey = :hashKeyValue AND begins_with(#rangeKey, :rangeKeyValue)",
  "ExpressionAttributeNames": {
    "#hashKey": "pk",
    "#rangeKey": "sk"
  },
  "ExpressionAttributeValues": {
    ":hashKeyValue": {
      "S": "TENANT#acme"
    },
    ":rangeKeyValue": {
      "S": "EVENT#2026-06"
    }
  }
})

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 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 watch the capacity they actually consume.

Updated