DEV Community

Cover image for DynamoDB Projection Expressions
DynoTable
DynoTable

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

DynamoDB Projection Expressions

A projection expression is the SELECT col1, col2 of DynamoDB: a
comma-separated list of attribute names that tells GetItem, Query, or Scan
to return only those attributes instead of the whole item.

Do DynamoDB projection expressions reduce read cost?

No. A ProjectionExpression trims the response payload, not the read capacity you're billed. DynamoDB reads the full item from storage, meters the RCU on its on-disk size, then drops the attributes you didn't name on the way out. To actually cut read cost, use a covering GSI instead.

  • It trims the payload, not the read cost. DynamoDB reads (and bills) the full item from storage, then drops the attributes you didn't name on the way out. ProjectionExpression is a network optimization, not a capacity one.
  • It's how you fetch a public subset. Name the few attributes a caller is allowed to see; the rest never leave the table.
  • Use #name placeholders for anything that might be reserved. Plain attribute names in the expression collide with DynamoDB's ~570 reserved words and fail the request.
  • For real read savings, use a covering index instead. A GSI that projects only the columns you need is read at its own (smaller) size.

What it actually saves

Coming from SQL, you'd assume SELECT a, b scans less than SELECT *. In
DynamoDB that intuition is wrong. The
capacity unit for a read is computed from
the size of the item on disk, rounded up to the next 4 KB — before the
projection is applied. AWS is explicit: a ProjectionExpression does not change
the read capacity a request consumes.1

So a projection saves you two things, both real but both downstream of the read:

  • Bytes over the wire. A 6 KB item returned as two small attributes is a tiny response. On a Query that returns hundreds of items, that adds up fast.
  • Client-side work. Less to deserialize, less to hold in memory, less to leak into a log or an API response by accident.

What it does not save is the RCU. That's the footgun: people reach for a
projection to cut their bill, see no change, and conclude DynamoDB is broken. It
isn't — you measured the wrong lever.

Project a public user profile

Say you run a user directory. Each profile is one item, keyed so you can fetch a
person by handle:

Example Notes
PK = "PROFILE#ada" (partition key)
SK = "PROFILE#ada" (sort key — single-item collection)

The item is fat. It carries the public face of the account plus a pile of
private and operational attributes:

{
  "PK": "PROFILE#ada",
  "SK": "PROFILE#ada",
  "displayName": "Ada L.",
  "avatarUrl": "https://cdn.example.com/u/ada.png",
  "bio": "Builds things.",
  "emailAddress": "ada@example.com",
  "passwordResetToken": "…",
  "billingCustomerId": "cus_…",
  "lastLoginIp": "…",
  "internalRiskScore": 0.02
}
Enter fullscreen mode Exit fullscreen mode

A public profile card needs three fields. Fetching the whole item means
emailAddress, lastLoginIp, and internalRiskScore travel to a context that
should never see them. Name only the public subset:

Example Notes
GetItem PK = "PROFILE#ada" SK = "PROFILE#ada"
ProjectionExpression: displayName, avatarUrl, bio

The response carries three attributes. The private ones stay in the table — not
filtered out by your app after arrival, but never serialized into the response
at all. That's the security win, and it's the one that's hard to undo once a
secret has already crossed a boundary.

You can assemble and copy this exact request — names, placeholders, and the SDK
call — in the
DynamoDB Expression Builder, which emits
the ProjectionExpression and ExpressionAttributeNames map for you.

Add or remove fields in the preset below to watch the ProjectionExpression
change — only the listed attributes come back:

Escape reserved words with # placeholders

Here's where a clean projection blows up. DynamoDB reserves a long list of
words — name, status, comment, size, timestamp, and hundreds more.2
If an attribute you're projecting is one of them, the raw name in the expression
is rejected.

Suppose the profile also has a status attribute ("active", "suspended").
This fails:

Example Notes
ProjectionExpression displayName, status

status is reserved. The fix is an expression attribute name — a #-prefixed
placeholder mapped to the real name:

Example Notes
ProjectionExpression displayName, #s
ExpressionAttributeNames { "#s": "status" }

The same mechanism reaches into nested attributes. To pull a single field out of
a map, or one element of a list, use document-path syntax — and placeholder every
segment, since any of them could be reserved:

Example Notes
ProjectionExpression #addr.#city, tags[0]
ExpressionAttributeNames { "#addr": "address", "#city": "city" }

A practical rule: placeholder everything. You never have to remember which of the
~570 reserved words you're standing on, and the expression reads the same either
way. And if you'd rather know which names are actually the problem, paste them
into the reserved-words checker — it
flags the collisions and emits the ExpressionAttributeNames alias map.

When a covering index beats a projection

If you genuinely need to cut read cost — not just payload — the lever is a
Global Secondary Index that projects only the attributes you read. A GSI is a
separate copy of the data; you choose KEYS_ONLY, INCLUDE, or ALL for its
projection.3 A KEYS_ONLY or narrow INCLUDE index is physically
smaller per item, so a Query against it is metered at that smaller size.

That's a covering index: the query is answered entirely from the index, no
trip back to the base table. Use it when a hot read pattern only ever needs a few
attributes from large items.

ProjectionExpression Covering GSI
Cuts payload Yes Yes
Cuts read cost No Yes — read at the index's size
Extra storage None A second copy of the projected fields
Extra write cost None Writes propagate to the index
Best for Hiding private fields; small wins Hot reads of a few fields from big items

The trade-off is honest: the index costs you storage and write capacity to save
read capacity. Worth it for a frequent read of a thin slice from a heavy item;
not worth it to shave a one-off GetItem. See
GSI vs LSI for picking the index type, and
when a GSI read can be stale before
you put one on the hot path.

Pitfalls and next steps

  • Don't expect a smaller bill. A projection alone never changes RCU. If the number didn't move, that's the documented behavior, not a bug.
  • Placeholder reserved words. A bare name or status in the expression fails the request — #-map it.
  • Always include the key attributes — they add negligible payload and let you page or re-fetch the item.
  • Reach for a covering index only when a hot pattern reads a few fields from large items; weigh the write/storage cost first.

Build the ProjectionExpression and its attribute-name map in the
Expression Builder, and
try DynoTable to run these projections against your own tables and
watch the response shrink.


  1. AWS DynamoDB Developer Guide, Using projection expressions in DynamoDB — read capacity is based on item size before any ProjectionExpression is applied. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ProjectionExpressions.html 

  2. AWS DynamoDB Developer Guide, Reserved Words in DynamoDB. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html 

  3. AWS DynamoDB Developer Guide, Attribute Projections (KEYS_ONLY / INCLUDE / ALL). https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html 

Top comments (0)