Intermediate5 min read

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 Query to 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 when the write and the bump touch different items. A like is one item, the count lives on another — TransactWriteItems makes 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 : 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:

Post item
PKSKattributes
POST#a91fMETAlikeTally (Number), body, authorId, createdAt
Like item
PKSKattributes
POST#a91fLIKE#USER#7c20likedAt

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) — 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 canceled, 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.

The post META item beside its LIKE# children in one item collection, so you can eyeball the stored tally against the real child count.
The post META item beside its LIKE# children in one item collection, so you can eyeball the stored tally against the real child count.

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 . 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.

Updated