Skip to content

4. Reads and writes

The API is small. Six operations do almost everything, and knowing exactly what each one costs is most of what it takes to use DynamoDB well.

READ WRITE
GetItem one item PutItem replace an item
Query one UpdateItem modify attributes
partition DeleteItem remove an item
Scan everything BatchWriteItem up to 25
BatchGetItem up to 100 TransactWriteItem up to 100
Terminal window
aws dynamodb get-item --table-name AppData \
--key '{"PK":{"S":"CUSTOMER#42"},"SK":{"S":"PROFILE"}}'
• requires the FULL primary key — both PK and SK
when the table has a composite key
• returns one item or nothing
• cost: 1 RCU per 4 KB, eventually consistent
2 RCU per 4 KB, strongly consistent
• ProjectionExpression fetches fewer attributes but
does NOT reduce the read cost — the whole item is
read on the server

That last line surprises people: projections save network bytes, not capacity units.

The operation the whole database is built around.

Terminal window
aws dynamodb query --table-name AppData \
--key-condition-expression 'PK = :pk AND begins_with(SK, :prefix)' \
--expression-attribute-values '{":pk":{"S":"CUSTOMER#42"},
":prefix":{"S":"ORDER#2026-"}}' \
--scan-index-forward false --limit 20
KEY CONDITION (mandatory)
PK = :value ← always equality
AND SK = | < | <= | > | >=
| BETWEEN | begins_with
OPTIONS
ScanIndexForward=false newest first
Limit items per page
FilterExpression applied AFTER the read
ExclusiveStartKey pagination cursor
COST: the total size of items READ, before filtering,
at 4 KB per RCU. A query that reads 1 MB and filters
down to one item costs 1 MB of reads.
SCAN reads EVERY item in the table or index.
LEGITIMATE NOT LEGITIMATE
one-off migrations anything a user waits for
exports and backfills "search"
small config tables a report you run hourly
parallel batch jobs because there is no index
Terminal window
# if you must scan large tables, parallelise it
aws dynamodb scan --table-name AppData --segment 0 --total-segments 8

A scan on a large table is expensive, slow, and consumes the capacity your application needs. The right response to “I need a scan” is almost always a new index (chapter 7) or a different key design (chapter 8).

EVENTUALLY CONSISTENT (default)
• may read a replica that has not caught up
• typically milliseconds behind, occasionally
more after a failover
• 0.5 RCU per 4 KB — HALF price
STRONGLY CONSISTENT (ConsistentRead=true)
• reads the leader; sees every acknowledged write
• 1 RCU per 4 KB
• not available on global secondary indexes (ch 7)
• not available across regions in global tables
(ch 11)
WHEN YOU NEED STRONG CONSISTENCY
• read-modify-write without a conditional check
• "did my write land?" immediately after writing
• anything where a stale read causes a wrong
decision (balances, inventory, permissions)
WHEN EVENTUAL IS FINE — most reads
• feeds, profiles, catalogues, dashboards
• anything already served behind a cache

Choosing eventual consistency where it is safe is the single cheapest performance and cost win available (chapter 6).

Terminal window
# PutItem REPLACES the whole item
aws dynamodb put-item --table-name AppData \
--item '{"PK":{"S":"CUSTOMER#42"},"SK":{"S":"PROFILE"},"name":{"S":"Alice"}}'
PutItem replaces the item entirely. attributes
not supplied are GONE.
UpdateItem modifies named attributes, leaves the
rest alone, creates the item if absent.
DeleteItem removes it.
COST: 1 WCU per 1 KB (rounded up), for all three.
a 0.5 KB update costs the same as a 1 KB one.

UpdateItem is usually what you want, and its expression language does real work:

Terminal window
aws dynamodb update-item --table-name AppData \
--key '{"PK":{"S":"CUSTOMER#42"},"SK":{"S":"PROFILE"}}' \
--update-expression 'SET #n = :n, updated = :t ADD loginCount :one' \
--expression-attribute-names '{"#n":"name"}' \
--expression-attribute-values '{":n":{"S":"Alice"},":t":{"S":"2026-09-23"},
":one":{"N":"1"}}'
SET assign, including list_append and
if_not_exists(path, default)
REMOVE delete an attribute
ADD atomic numeric increment, or add to a set
DELETE remove elements from a set

ADD is a genuine atomic counter — no read, no race, no transaction — and it is the right tool for view counts, quotas and rate counters.

#n is required whenever an attribute name is a
RESERVED WORD — and the reserved list is enormous
(name, status, size, timestamp, count, year, …).
Symptom: ValidationException "Invalid …expression:
Attribute name is a reserved keyword".
Fix: alias every attribute name. some teams alias
all of them, always, and never think about it again.

This is how DynamoDB does concurrency control, and it is the feature that makes single-item correctness possible without transactions.

Terminal window
# create only if it does not already exist
--condition-expression 'attribute_not_exists(PK)'
# optimistic locking
--condition-expression 'version = :expected'
--update-expression 'SET version = :next, status = :s'
# business rule enforced in the database
--condition-expression 'balance >= :amount'
A failed condition returns
ConditionalCheckFailedException
…and still costs WRITE capacity. Conditions are
cheap, not free.

Chapter 9 builds transactions on top of these; most applications need conditions far more often than they need transactions.

BatchGetItem up to 100 items, across tables
BatchWriteItem up to 25 PutItem/DeleteItem
(no UpdateItem, no conditions)
• NOT atomic — individual items can fail
• failures return in UnprocessedItems, and you MUST
retry them with backoff
• the win is FEWER ROUND TRIPS, not lower capacity
cost — each item is billed as usual
# the loop everyone forgets to write
while request:
resp = client.batch_write_item(RequestItems=request)
request = resp.get("UnprocessedItems") or None
if request: time.sleep(backoff())
Limit = 50 means
"read at most 50 ITEMS from storage"
It does NOT mean
"return 50 results after filtering"
# what a page actually tells you
resp = table.query(KeyConditionExpression=Key("PK").eq(pk),
FilterExpression=Attr("status").eq("open"),
Limit=50)
resp["Count"] # items returned AFTER the filter
resp["ScannedCount"] # items READ — what you paid for
resp["LastEvaluatedKey"] # present ⇒ there may be more
① loop until LastEvaluatedKey is ABSENT, never
until a page is empty
② treat LastEvaluatedKey as an opaque cursor; hand
it to clients encoded, never construct one
③ there is no OFFSET. "page 7" requires reading
pages 1–6. design for cursors, or precompute.

A page can legitimately contain zero items and still have more data behind it — the filter removed everything in that 1 MB. Code that stops there loses data silently (chapter 14).

ReturnValues on writes
NONE (default)
ALL_OLD the item as it was before
UPDATED_OLD just the changed attributes, before
ALL_NEW the item after the change
UPDATED_NEW just the changed attributes, after
resp = table.update_item(Key=key,
UpdateExpression="ADD stock :n",
ExpressionAttributeValues={":n": -1},
ConditionExpression="stock > :zero",
ReturnValues="ALL_NEW")
remaining = resp["Attributes"]["stock"] # no extra read

That is a read-modify-write with no read: the condition enforces the rule and ALL_NEW returns the result, in one billed write. Chapter 9 leans on this pattern heavily.

ReturnConsumedCapacity NONE | TOTAL | INDEXES
ReturnItemCollectionMetrics SIZE
→ tells you how big the item collection is
getting, which is the early warning for the
10 GB LSI limit (ch 7)
SELECT * FROM AppData WHERE PK = 'CUSTOMER#42' AND begins_with(SK, 'ORDER#');

PartiQL gives a SQL-like syntax over the same operations. It is convenient in the console and in ad hoc work, and it is a genuine hazard in application code: a statement that looks like SQL can silently be a full table scan. The underlying rules do not change, so prefer the explicit API where cost matters.

  • Six operations cover almost everything: GetItem, Query, Scan for reads; PutItem, UpdateItem, DeleteItem for writes, plus batch and transactional forms.
  • GetItem needs the full primary key; ProjectionExpression saves network bytes, not capacity.
  • Query reads one partition by exact PK with an optional sort-key range — and costs the bytes read before filtering.
  • FilterExpression is not an index, and it can return empty pages; always paginate until LastEvaluatedKey is absent.
  • Scan reads everything and belongs in migrations and batch jobs, never on a request path.
  • Consistency is chosen per read: eventual is the default and half price; strong reads the leader and is unavailable on GSIs and across regions.
  • PutItem replaces, UpdateItem merges. Writes cost 1 WCU per KB, and ADD gives a true atomic counter.
  • Alias attribute names with # — the reserved-word list is long enough that aliasing everything is a reasonable habit.
  • Conditional expressions are DynamoDB’s concurrency control: existence checks, optimistic locking and business rules, enforced server-side, billed even when they fail.
  • Batches are not atomic; you must retry UnprocessedItems with backoff.

Next: a request traced — from the SDK call through the request router to the storage node and back, with the milliseconds accounted for.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source