Beginner6 min read

DynamoDB Projection Expressions

A projection expression is the SELECT col1, col2 of DynamoDB: a comma-separated list of 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 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 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 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:

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:

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:

Build your request
Generated code
new QueryCommand({
  "TableName": "AuditLog",
  "KeyConditionExpression": "#hashKey = :hashKeyValue AND begins_with(#rangeKey, :rangeKeyValue)",
  "ProjectionExpression": "#proj0, #proj1, #proj2",
  "ExpressionAttributeNames": {
    "#hashKey": "pk",
    "#rangeKey": "sk",
    "#proj0": "action",
    "#proj1": "actor",
    "#proj2": "createdAt"
  },
  "ExpressionAttributeValues": {
    ":hashKeyValue": {
      "S": "TENANT#acme"
    },
    ":rangeKeyValue": {
      "S": "EVENT#"
    }
  }
})

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:

ProjectionExpression   displayName, status

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

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:

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.

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.

ProjectionExpressionCovering GSI
Cuts payloadYesYes
Cuts read costNoYes — read at the index's size
Extra storageNoneA second copy of the projected fields
Extra write costNoneWrites propagate to the index
Best forHiding private fields; small winsHot 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.
  • Projecting key attributes is free and often useful — DynamoDB returns them cheaply, and they let you page or re-fetch.
  • 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, Working with Read and Write Operations — read capacity is based on item size before any ProjectionExpression is applied. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/
  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

Updated