DynamoDB Reference Counts
A reference count is a number you store on a parent item that tracks how many child items point at it — likes on a post, members in a workspace, replies on a comment. You keep it because counting the children on every read is too expensive.
How do you maintain a count in DynamoDB?
Store the running total as a number on the parent item and update it in the same write that creates the child. A makes both land or neither, and a condition on the child write stops retries from double-counting — so a single GetItem returns an accurate count.
- Don't count children at read time. A
Queryto count likes pays for every like item it scans. Store the total on the post and read one item instead. - Maintain the count where the child is written, not after. Bump it in the same operation that creates the child so the two never drift.
- Use a transaction when the write and the bump touch different items. A like is
one item, the count lives on another —
TransactWriteItemsmakes both land or neither. - The footgun is double-counting. A retried or duplicated like that re-runs the increment inflates the number. Guard the child write with a condition.
Why count at all
Coming from SQL, you'd never store a like-count — you'd SELECT COUNT(*) FROM likes WHERE post_id = ? and let an index make it cheap. DynamoDB has no COUNT(*) that
skips reading items.
A Query over a post's likes reads — and bills for — every like item in that
partition, even if you only want the number. On a viral post that's thousands of
RCUs to answer "how many likes?" That's the read footgun reference counts exist to kill.
So you denormalise: store the running total on the post itself. Reading the count
becomes a single GetItem. The cost is that you now own keeping it accurate.
Model the items
Two item types share a partition so the post and its likes sit in one item collection. Invented keys:
| PK | SK | attributes |
|---|---|---|
| POST#a91f | META | likeTally (Number), body, authorId, createdAt |
| PK | SK | attributes |
|---|---|---|
| POST#a91f | LIKE#USER#7c20 | likedAt |
The likeTally attribute on the META item is the reference count. Each LIKE#
item is a child. Putting both under PK = "POST#a91f" means a single Query can
fetch the post and its likers together when you do want the list.
Bump the count atomically
DynamoDB increments a number with an ADD (or SET x = x + :n) update expression —
this is an atomic counter: DynamoDB applies the delta server-side without you
reading the current value first, so concurrent increments don't clobber each other.
(AWS: atomic counters)
The problem: liking a post is two writes to two items — create the LIKE#
item, and add 1 to likeTally on META. If the like lands but the bump fails, the
tally is wrong forever. You need both or neither.
That's what TransactWriteItems guarantees — all-or-nothing across multiple items,
and it cancels the whole transaction if any item is modified concurrently
(AWS: pessimistic locking with transactions):
{
"TransactItems": [
{
"Put": {
"TableName": "Social",
"Item": {
"PK": {"S": "POST#a91f"},
"SK": {"S": "LIKE#USER#7c20"},
"likedAt": {"N": "1750636800"}
},
"ConditionExpression": "attribute_not_exists(SK)"
}
},
{
"Update": {
"TableName": "Social",
"Key": {
"PK": {"S": "POST#a91f"},
"SK": {"S": "META"}
},
"UpdateExpression": "ADD likeTally :one",
"ExpressionAttributeValues": {":one": {"N": "1"}}
}
}
]
}The Put and the Update commit together. If either fails, DynamoDB rolls both
back and returns a TransactionCanceledException.
Guard against double-counting
The real bug isn't a half-written like — the transaction prevents that. It's the
same user liking twice, or a client retry replaying the request. Each replay adds
another 1, and likeTally quietly drifts above the true count.
The ConditionExpression: attribute_not_exists(SK) on the Put is the guard. If
that user's LIKE# item already exists, the Put's condition fails, the whole
transaction is cancelled, and — critically — the ADD never runs. One like per user,
enforced by the key.
Build and copy these update and condition expressions — with the right
ExpressionAttributeValues and attribute_not_exists guard — in the
DynamoDB Expression Builder rather than
hand-assembling the JSON.
Unlike, and the cost
Removing a like is the mirror image: Delete the LIKE# item with
ConditionExpression: attribute_exists(SK), and ADD likeTally :minusOne in the
same transaction. The condition stops a double-unlike from driving the tally negative.
Know the price. A transactional write costs 2 WCUs per item for items up to 1 KB — one to prepare, one to commit — versus 1 WCU for a plain write. A like is two items, so each like is roughly four WCUs. Cheap per action, but worth knowing before a celebrity post takes a like-storm.
See it in DynoTable
When you suspect a tally has drifted, you want to compare the stored likeTally
against the actual number of LIKE# children — without running a count query in prod.

For a true reconciliation across a bounded set of posts — "which tallies don't match
their child counts?" — DynoTable's SQL Workbench runs the GROUP BY and the join
client-side over the rows you've loaded, which plain PartiQL can't express.
Pitfalls and next steps
- Don't maintain the count out-of-band (a Lambda that recounts nightly). It's a band-aid over a write path that should have been transactional from the start.
- Watch hot partitions. A single wildly popular post concentrates every like — and every tally bump — on one partition key. The count is correct; the partition may still throttle.
- Reconcile rarely, repair surgically. Drift should be near-zero if every mutation is conditioned. Treat a mismatch as a bug to find, not a number to overwrite.
Related reading: single-table design for why the post and likes share a partition, and Query vs Scan for why counting children at read time is the pattern you're avoiding.
Then download DynoTable to inspect these item collections and verify your tallies against your own tables.


