My conformance suite 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 filed an issue 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:
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:
// 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:
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, 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:
{
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 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.
Top comments (0)