Intermediate5 min read

Expression Attribute Names and Values in DynamoDB

DynamoDB expressions are templates: you write placeholders, then supply the real attribute 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.

  • #name substitutes an attribute name via ExpressionAttributeNames — use it whenever an attribute clashes with a reserved word or contains a dot/space.
  • :value substitutes a value via ExpressionAttributeValues — DynamoDB never inlines literals into expression text, so every value is a placeholder.
  • They are not interchangeable. A # where a : belongs is a ValidationException, 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:

AttributeReserved?What it holds
statusyesdraft / published
nameyesauthor display name
sizeyesrendered byte length
ttlyesarchive expiry (epoch)
slugnoURL 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 memorise 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 TTL:

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:

Build your request
Generated code
new QueryCommand({
  "TableName": "AuditLog",
  "KeyConditionExpression": "#hashKey = :hashKeyValue",
  "FilterExpression": "#filter0 = :filterValue0",
  "ExpressionAttributeNames": {
    "#hashKey": "pk",
    "#filter0": "status"
  },
  "ExpressionAttributeValues": {
    ":hashKeyValue": {
      "S": "TENANT#acme"
    },
    ":filterValue0": {
      "S": "active"
    }
  }
})

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
Substitutesan attribute namean attribute value
MapExpressionAttributeNamesExpressionAttributeValues
Prefix#:
Needed forreserved words, dots, spacesalways — no inline literals
Wrong one errorsValidationExceptionValidationException

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 :v after 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.

Updated