Intermediate5 min read

DynamoDB Atomic Counters

An atomic counter is a numeric attribute you bump in place with a single UpdateItem call — no read first, no read-modify-write race. DynamoDB applies each increment in arrival order and never lets two writers clobber each other's count.

What is a DynamoDB atomic counter?

A DynamoDB atomic counter is a numeric attribute you increment in place with a single UpdateItem call using an ADD (or SET x = x + :n) update expression. DynamoDB reads, adds, and writes the value server-side, so concurrent writers serialise without lost updates — but it isn't idempotent, so a retried call increments twice.

  • Use ADD (or SET x = x + :n) to increment in one call. DynamoDB reads, adds, and writes server-side — concurrent callers serialise, no lost updates.
  • No read first. Coming from SQL you'd SELECT then UPDATE; here you skip the read entirely and the operation is still safe under concurrency.
  • Atomic counters are not idempotent. A retried UpdateItem increments again. If you can't tolerate over- or undercounting, use a conditional update.
  • ADD on a missing attribute starts at 0, so the very first increment just works — no seed write needed.

The problem with read-modify-write

Say you track views on a video. The naive instinct, straight from SQL, is: GetItem, add one in your app, PutItem the new total back.

Two viewers hit play at once. Both read views = 41. Both write 42. You counted one view, not two. That's a lost update — the classic concurrency footgun, and it doesn't show up until you have traffic.

In SQL you'd dodge it with UPDATE videos SET views = views + 1, pushing the arithmetic into the database. DynamoDB has the same move, and it's the whole point of an atomic counter.

Increment in one call

Model a per-video stats item. Partition key VID#<id>, sort key STATS#TOTAL, with a numeric play_count:

PKSKplay_count
"VID#9f3a""STATS#TOTAL"41

To register a play, send one UpdateItem with an ADD clause:

# UpdateItem
Key               PK = "VID#9f3a", SK = "STATS#TOTAL"
UpdateExpression  ADD play_count :one
Values            :one = 1

DynamoDB reads play_count, adds 1, and writes the result inside a single server-side operation. There's no window for another writer to slip in. Ten concurrent plays produce +10, every time — that's what "atomic" buys you.

You can build and copy this exact expression — names, values, and all four clause types — with the DynamoDB Expression Builder.

ADD works even when play_count doesn't exist yet: DynamoDB treats a missing numeric attribute as 0, so the first play creates it at 1. No separate seed write. (AWS: Using update expressions)

ADD vs SET +: pick one

Two expressions do the same arithmetic. AWS recommends SET for general use because it composes with other SET actions and reads more explicitly. (AWS: Using update expressions)

ADD play_count :oneSET play_count = play_count + :one
Missing attrCreates it, starting at 0Errors — needs if_not_exists
Data typesNumbers and sets onlyNumbers (and more) via SET
Combine w/ SETSeparate clauseOne SET clause, comma-separated
AWS guidanceFine for countersRecommended default

If the attribute might not exist and you want SET, guard it: SET play_count = if_not_exists(play_count, :zero) + :one. With ADD you skip that — it seeds from 0 for free.

Do it in DynoTable

Open the item, edit play_count, and you can watch an atomic increment land without hand-writing JSON — the update panel emits the ADD expression for you and shows the new value the moment it commits.

The trap: counters aren't idempotent

Here's the part that bites teams in production. An atomic counter increments every time UpdateItem runs. (AWS: Working with items)

Picture a network blip: you send the increment, the connection drops before the response comes back, and you don't know whether it landed. You retry. If the first call did succeed, you've now counted that play twice.

For video views that's fine — a few double-counts in a million plays won't hurt anyone, and AWS calls this exact "track visitors" case the canonical use of atomic counters. (AWS: Working with items)

It is not fine for anything that must be exact: inventory you can oversell, credits you can double-spend, a balance you can corrupt. There, reach for a conditional update.

When you need exactness: conditional updates

A conditional update is idempotent if you condition on the same attribute you're changing. Increment play_count to 42, but only if it's currently 41:

# UpdateItem
Key                  PK = "VID#9f3a", SK = "STATS#TOTAL"
UpdateExpression     SET play_count = :next
ConditionExpression  play_count = :current
Values               :next = 42, :current = 41

Now a retry is safe: if the first write already moved play_count to 42, the condition play_count = 41 fails the second time and nothing changes. (AWS: Working with items)

The cost is concurrency. Two writers racing on the same condition means one wins and one gets a ConditionalCheckFailedException to retry — you've traded the unconditional counter's throughput for correctness. For exact, contended counters that's the right trade. For view counts it's overkill.

Pitfalls

  • One hot item. A single counter row is one partition key. A viral video hammering VID#9f3a / STATS#TOTAL can hit a per-partition write ceiling. Shard it: spread writes across STATS#TOTAL#0..N and sum on read.
  • No batch increment. BatchWriteItem is put/delete only — it can't run update expressions. Counters go through UpdateItem, one item per call.
  • ADD is numbers and sets only. It won't touch strings or booleans; that's a SET. See DynamoDB data types for the full attribute model.

Next steps

Atomic counters are a write pattern; how you read aggregates back is a modelling question — see single-table design for keeping stats items beside their parent, and Query vs Scan so rolling up a sharded counter stays a Query.

Draft and copy the increment in the DynamoDB Expression Builder, then try DynoTable to run atomic updates against your own tables and watch the counts move.

Updated