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.
ProjectionExpressionis 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
#nameplaceholders 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
Querythat 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
}
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
nameorstatusin 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.
-
AWS DynamoDB Developer Guide, Using projection expressions in DynamoDB — read capacity is based on item size before any
ProjectionExpressionis applied. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ProjectionExpressions.html ↩ -
AWS DynamoDB Developer Guide, Reserved Words in DynamoDB. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html ↩
-
AWS DynamoDB Developer Guide, Attribute Projections (
KEYS_ONLY/INCLUDE/ALL). https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html ↩
Top comments (0)