Expression Attribute Names and Values in DynamoDB
DynamoDB expressions are templates: you write placeholders, then supply the real
names and values in two side maps. #name is a name placeholder;
:value is a value placeholder. Get the two confused and DynamoDB rejects the
whole call.
What is the difference between #name and :value in DynamoDB?
#name is a placeholder for an attribute name, supplied through ExpressionAttributeNames; :value is a placeholder for an attribute value, supplied through ExpressionAttributeValues. Use #name to dodge reserved words, dots, or spaces, and :value for every literal — DynamoDB never inlines values. They are not interchangeable; swapping them throws a ValidationException.
#namesubstitutes an attribute name viaExpressionAttributeNames— use it whenever an attribute clashes with a reserved word or contains a dot/space.:valuesubstitutes a value viaExpressionAttributeValues— DynamoDB never inlines literals into expression text, so every value is a placeholder.- They are not interchangeable. A
#where a:belongs is aValidationException, not a silent no-op.
Coming from SQL, you inline both — WHERE status = 'published'. DynamoDB inlines
neither. That split is the thing that trips up every newcomer.
Why the two maps exist
In SQL the query string carries everything: column names, literals, operators. DynamoDB deliberately separates the shape of the expression from its data.
Values go in their own map so DynamoDB can type each one (S, N, BOOL, …)
and so the parser never has to guess where a string ends — there is no quoting
or escaping to get wrong. See data types in DynamoDB for
the full type-tag list.
Names get the same treatment for a different reason: DynamoDB has a long list of reserved words, and any attribute matching one cannot appear as a bare name in an expression. The placeholder dodges the reservation entirely.
The reserved-word trap
Here is a CMS articles table — partition key BLOG#<blog>, sort key
ARTICLE#<slug> — whose attributes read naturally but happen to collide with
reserved words:
| Attribute | Reserved? | What it holds |
|---|---|---|
status | yes | draft / published |
name | yes | author display name |
size | yes | rendered byte length |
ttl | yes | archive expiry (epoch) |
slug | no | URL slug |
status, name, size, and ttl are all on AWS's reserved-words list, so this
filter fails on the first word:
FilterExpression status = :s
DynamoDB returns a ValidationException — "Attribute name is a reserved
keyword; reserved keyword: status". The fix is a name placeholder, never
renaming the attribute:
FilterExpression #status = :s
ExpressionAttributeNames { "#status": "status" }
ExpressionAttributeValues { ":s": { "S": "published" } }
The trap: slug is not reserved, so a query you tested against slug works,
and you assume the next one will too. Then status breaks it. The full list
moves, so don't memorize it — placeholder every name and you never get bitten.
Map every value, always
Values are non-negotiable: there is no syntax for an inline literal. Even a plain number gets a placeholder. This update marks an article published, stamps its size, and sets a 30-day archive :
UpdateExpression: SET #status = :s, #size = :sz, #ttl = :exp
ExpressionAttributeNames: { "#status": "status", "#size": "size", "#ttl": "ttl" }
ExpressionAttributeValues: {
":s": { "S": "published" },
":sz": { "N": "20480" },
":exp": { "N": "1719792000" }
}Note :sz and :exp are sent as N strings — DynamoDB's number type is wire-
encoded as a string. The value map is also where you reuse a value across
clauses: define :s once, reference it in both a ConditionExpression and a
FilterExpression.
Building these two maps by hand is where typos hide. The Expression Builder generates the expression string and both maps together, with the type tags filled in, so the placeholders can't drift out of sync.
The builder below filters on status — a reserved word — so you can see it
auto-alias to #status in the ExpressionAttributeNames map:
Names for nested and awkward paths
The # placeholder does more than dodge reserved words. Document-path syntax uses
dots and brackets, so an attribute that literally contains a dot — say a metadata
key og.title — is unaddressable without a placeholder:
ProjectionExpression #og
ExpressionAttributeNames { "#og": "og.title" }
Without it, DynamoDB reads og.title as "the title field inside the og map"
— a different thing entirely. Same story for names with spaces or leading digits.
For nesting, you placeholder each segment: #meta.#author with both #meta and
#author defined.
Names vs values, side by side
#name | :value | |
|---|---|---|
| Substitutes | an attribute name | an attribute value |
| Map | ExpressionAttributeNames | ExpressionAttributeValues |
| Prefix | # | : |
| Needed for | reserved words, dots, spaces | always — no inline literals |
| Wrong one errors | ValidationException | ValidationException |
If a value were typed as a name, DynamoDB would look for an attribute called
published and your condition would never match the way you meant — so the API
fails loud instead. That strictness is a feature: there is no quiet wrong answer.
Pitfalls and next steps
- Declaring a placeholder you don't use — DynamoDB rejects unused entries in either map. Build the maps from the expression, not ahead of it.
- Reusing
:vafter editing the expression — drop a clause and its value can linger, triggering the unused-entry error. The builder keeps them in lockstep. - Assuming a name is safe because it worked once — reserved-word collisions are per-attribute. Placeholder uniformly and stop guessing.
These maps show up in every write path, so they pair naturally with single-table design and with knowing when to Query vs Scan before you ever attach a filter.
Generate the expression plus both maps with the Expression Builder, then try DynoTable to run them against your own tables and watch the placeholders resolve.