---
title: "What a write to an indexed DynamoDB table costs - Martin Hicks"
description: "What a write to a DynamoDB table carrying GSIs and LSIs actually costs, measured against real DynamoDB in eu-west-2 rather than lifted from the docs."
canonical: https://martinhicks.dev/articles/what-a-write-to-an-indexed-dynamodb-table-costs
last-updated: 2026-08-13
---

![The Parity Suite logo, a teal rounded square with three descending bars, on a near-black background above the paritysuite.org wordmark](https://martinhicks.dev/images/articles/parity-suite-card.png)13th August 2026

# What a write to an indexed DynamoDB table costs

My [conformance suite](https://paritysuite.org) has had a per-index capacity assertion since fairly early on. It was on a `Query`.

Which is the wrong half. Reading through an index is the cheap, obvious, well-understood part. The write side is where a secondary index actually costs you money, because every write to the base table fans out to every index the item lands in, and you pay for each one. That went unmeasured for months, until [spaceemotion](https://github.com/spaceemotion) filed [an issue](https://github.com/paritysuite/dynamodb-conformance/issues/124) pointing straight at the gap and listing the four cases it needed to cover. All four are below.

So I fixed it, and measured the lot against real DynamoDB in eu-west-2. Fourteen tests, every integer below observed rather than lifted from the docs.

## The setup

One on-demand table, a composite key, and two indexes chosen so each one can be isolated:

```js
KeySchema: [
  { AttributeName: 'pk', KeyType: 'HASH' },
  { AttributeName: 'sk', KeyType: 'RANGE' },
],
GlobalSecondaryIndexes: [{
  IndexName: 'gsi-inc',
  KeySchema: [{ AttributeName: 'gsiPk', KeyType: 'HASH' }],
  Projection: { ProjectionType: 'INCLUDE', NonKeyAttributes: ['proj'] },
}],
LocalSecondaryIndexes: [{
  IndexName: 'lsi1',
  KeySchema: [
    { AttributeName: 'pk', KeyType: 'HASH' },
    { AttributeName: 'lsiSk', KeyType: 'RANGE' },
  ],
  Projection: { ProjectionType: 'ALL' },
}],
```

The GSI uses an `INCLUDE` projection carrying exactly one non-key attribute, `proj`. That's what gives the update tests a lever: I can touch an attribute the GSI projects, or one it doesn't, and watch the difference. The LSI projects everything, which sets up the mirror case.

Every item is under 1KB, so each index write is exactly one unit and the arithmetic shows structure rather than rounding. Every test seeds a fresh key, for a reason that becomes obvious further down.

All of it read back with `ReturnConsumedCapacity: 'INDEXES'`.

## The basic shape

A write costs one unit for the table, plus one for each index it lands in.

Write

Table

GSI

LSI

Total

Item with both index keys

1

1

1

**3**

Item with the GSI key only

1

1

\-

**2**

Item with the LSI key only

1

\-

1

**2**

Item with neither

1

\-

\-

**1**

Two things there are worth stating plainly because the docs are vague on both.

LSI units fold into the top-level total exactly as GSI units do. I'd half expected local indexes to be free on the write path, given they share a partition with the item. They aren't.

And a write that costs an index nothing reports no arm for that index at all. Not a zero:

```js
// Sparse write, misses both indexes
{
  CapacityUnits: 1,
  Table: { CapacityUnits: 1 },
  // GlobalSecondaryIndexes: absent
  // LocalSecondaryIndexes: absent
}
```

If you're summing arms to reconcile a bill, absent and zero need the same handling, and a naive `Object.keys(cc.GlobalSecondaryIndexes)` throws rather than returning nothing. That's the sort of thing that survives testing right up until a sparse write shows up in production.

Two more shape notes. Under `ReturnConsumedCapacity: 'TOTAL'` you get the correct total and nothing else, with no `Table` field and no index arms. And under `INDEXES`, the arms you do get carry only an aggregate `CapacityUnits`, with no read/write split inside them.

## The update ladder

This is the interesting part, and the part I'd get wrong if I were guessing.

Updating an item costs the index nothing, one unit, or two, depending entirely on what you touched. Same item, same table, four different updates:

Update

Table

GSI

LSI

Total

`SET other = :v` (not projected by the GSI)

1

\-

1

**2**

`SET proj = :v` (projected by the GSI)

1

1

1

**3**

`SET gsiPk = :v` (moves the GSI key)

1

**2**

1

**4**

`REMOVE gsiPk` (leaves the GSI)

1

1

1

**3**

The two-unit row is the one to internalise. Changing an item's GSI partition key isn't an update on that index, it's a delete from the old partition and an insert into the new one, and you pay for both. So a status attribute that doubles as a GSI key costs two index writes on every transition, not one, and a workflow that moves an item through several states pays that each time.

Removing the key costs one, because there's a delete and no insert.

The first row shows the projection doing its job: `other` isn't in the GSI's `INCLUDE` list, so the GSI's stored copy of the item didn't change and the GSI charged nothing. The LSI still charged one, because it projects `ALL` and its copy did change.

That mirror case holds in the other direction too. Updating the LSI's sort key costs the LSI two, a delete and an insert, and costs the GSI nothing, because `lsiSk` sits outside the GSI's projection:

Update

Table

GSI

LSI

Total

`SET lsiSk = :v`

1

\-

**2**

**3**

So the rule underneath all of this is not about keys or attributes as such. An index charges you when the bytes it stores change, and the projection decides which bytes those are. A wide projection is a bigger write bill on every update, not just a bigger index.

One caveat if you're scaling any of this up. Every item here is under 1KB, so each charge is a single unit and the ladder shows structure rather than rounding. Above 1KB the shapes stop agreeing: an in-place entry update and a key move are no longer the same arithmetic, and the ladder's integers stop generalising. I've measured where they diverge and I'll write that up separately with the capture behind it.

## The overwrite that costs nothing

Put an item, then put the identical item again:

```js
const item = {
  pk: { S: 'k' }, sk: { S: '1' },
  gsiPk: { S: 'g4' }, lsiSk: { S: 'L1' }, proj: { S: 'p' },
}

await put(item)   // total 3: table 1, gsi 1, lsi 1
await put(item)   // total 1: table 1, no index arms at all
```

The second write charges for the table and nothing else. Index replication is delta-based, so re-writing bytes an index already holds costs nothing on that index.

This is why every test in the file seeds a fresh key. I lost an hour to a test that passed alone and failed in the suite, because a retry was overwriting an item it had already written and getting a legitimately smaller bill for it.

Practically, this makes idempotent writes cheaper than they look. A retry loop that re-puts the same item is paying for one table write per attempt, not the full fan-out. It also means you can't infer index cost from write volume, because a workload that rewrites unchanged records has an index bill near zero.

The same behaviour shows up on [DynamoDB's new vector indexes](https://martinhicks.dev/articles/dynamodb-vector-search-docs-get-wrong), where an overwrite with an identical embedding reports no vector write capacity whatsoever. Same replication layer, presumably.

## Deletes and batches

Deletes mirror puts. One unit for the table, one for each index the item occupied:

Delete

Table

GSI

LSI

Total

Item that was in both indexes

1

1

1

**3**

Item that was in neither

1

\-

\-

**1**

`BatchWriteItem` reports one entry per table with the arms the batch actually touched, aggregated. Two puts in one batch, one indexed and one sparse:

```js
{
  TableName: '...',
  CapacityUnits: 3,
  Table: { CapacityUnits: 2 },      // both items
  GlobalSecondaryIndexes: { 'gsi-inc': { CapacityUnits: 1 } },  // one item
}
```

The table arm counts both writes, the index arm counts only the one that landed in it. There's no per-item breakdown, so a batch is the point where you stop being able to attribute cost to a specific write.

## What I'd take from it

The headline number people carry around is "an index roughly doubles your write cost", and that's about right for a table with one index where every item carries the key. It stops being right in both directions fairly quickly.

A sparse index costs nothing on writes that miss it, which makes sparse indexes cheaper than their read behaviour suggests. A narrow projection costs nothing on updates that don't touch projected attributes. And an attribute that's both a GSI key and a mutable field is the expensive shape, because every change to it is two index writes rather than one.

If you want the numbers for your own schema rather than mine, `ReturnConsumedCapacity: 'INDEXES'` on a scratch table is a twenty-minute exercise and it will tell you more than the docs will. The tests behind this post are in [paritysuite/dynamodb-conformance](https://github.com/paritysuite/dynamodb-conformance) under `tests/tier1/putItem/indexConsumedCapacity.test.ts`, and they re-run against real DynamoDB on a schedule, so if any of these integers move I'll know.

On their first run those tests put ten failures against Dynoxide, my own engine, scored by the same suite.

## Further reading

-    [Parity Suite 3.0.0: measuring the wrong thing
    
    Parity Suite 3.0.0 splits the old conformance percentage into divergence and coverage, grades every DynamoDB emulator from A to F, and takes the suite to 1,054 tests with the first coverage of DynamoDB's new vector search.](https://martinhicks.dev/articles/parity-suite-300-release-notes)
-    [When a DynamoDB vector index is actually ready
    
    AWS said to wait for IndexStatus ACTIVE and Backfilling false. I measured it, and that state never happens. AWS have since rewritten the docs.](https://martinhicks.dev/articles/dynamodb-vector-search-docs-get-wrong)
-    [What a conditional DynamoDB transaction actually costs
    
    The AWS docs aren't clear on what a conditional TransactWriteItems costs in capacity units, so I ran it against real DynamoDB and pinned the numbers.](https://martinhicks.dev/articles/what-a-conditional-dynamodb-transaction-costs)
-    [How close is your DynamoDB emulator to AWS?
    
    The DynamoDB conformance suite now has a home: eight emulators and 684 tests scored against live AWS DynamoDB, with results published on every run.](https://martinhicks.dev/articles/dynamodb-conformance-org)

---

Source: https://martinhicks.dev/articles/what-a-write-to-an-indexed-dynamodb-table-costs
