Skip to content

ElectroDB GSI Overloading: Cutting Write Costs Without Losing Type Safety

Every Global Secondary Index you add multiplies your write costs. Not a little - a full extra write unit per item, per index, on every write. Three GSIs means every PutItem is charged four times.

GSI overloading is the practice of pointing several entities’ secondary indexes at the same physical index, so unrelated access patterns share one set of key attributes. Done properly it’s a two-thirds cost reduction with no functional change. Done carelessly it’s silent data corruption where one entity’s queries return another’s items.

ElectroDB makes it safe by validating the parts that are easy to get wrong.

The write amplification math

DynamoDB replicates every base table write into every GSI whose key attributes are present on the item. That replication is charged.

Take a SaaS schema with 5 million writes per month and three dedicated GSIs:

Dedicated GSIsOverloaded
Indexes31
Write units per item4 (base + 3)2 (base + 1)
Monthly write units20M10M
StorageBase + 3 projectionsBase + 1 projection

You halve write capacity and cut index storage by two-thirds. At small scale that’s a rounding error. At scale it’s the difference between a DynamoDB bill you don’t think about and one you do.

The reads are identical. Same latency, same RCU cost, same result sets.

The starting point: three dedicated GSIs

Here’s a multi-tenant schema written the clear way, one index per access pattern:

GSIEntityPKSKServes
GSI1UserUSER_EMAIL#<email>USER#<userId>Login by email
GSI2ProjectUSER#<createdBy>PROJECT#<projectId>Projects by creator
GSI3TenantTENANT_LIST<name>#<tenantId>Admin tenant listing

Three entities, three cross-partition lookups, three indexes. Nothing wrong with it. It’s the right way to write a schema you’re still designing.

Now look at the partition key patterns: USER_EMAIL#, USER#, TENANT_LIST. They never collide. No value produced by one pattern can ever equal a value produced by another. That’s the entire precondition for overloading.

The overloaded version

All three move to gsi1, writing to the same gsi1pk and gsi1sk fields:

Entitygsi1pkgsi1skServes
UserUSER_EMAIL#<email>USER#<userId>Login by email
ProjectUSER#<createdBy>PROJECT#<projectId>Projects by creator
TenantTENANT_LIST<name>#<tenantId>Admin listing

One index. Same three access patterns. Half the write cost.

In ElectroDB, only the index name changes:

// byEmail: was gsi1, stays gsi1
byEmail: {
index: "gsi1",
pk: {
  field: "gsi1pk",
  composite: ["email"],
  template: "USER_EMAIL#${email}",
},
sk: {
  field: "gsi1sk",
  composite: ["userId"],
  template: "USER#${userId}",
},
},

Query code is unchanged. UserEntity.query.byEmail({ email }) still returns typed users, because ElectroDB scopes results by the entity identifier attributes it writes to every item.

Your infrastructure drops two GSI definitions. That part is not ElectroDB’s job - see the drift warning below.

The prefix collision rule

This is the part to get right, because getting it wrong fails silently.

No partition key value produced by one entity may ever equal a value produced by another entity on the same index.

If both UserEntity and ProjectEntity could produce gsi1pk = "USER#u_01", they land in the same index partition. Queries still return typed results, because ElectroDB filters by entity identifier. But you’re now reading and paying for the other entity’s items on every query, and the more items accumulate, the worse it gets.

Safe patterns:

  • Distinct type prefixes. USER_EMAIL# and USER# are distinct because the delimiter follows different tokens. USER# and USER_EMAIL# cannot produce equal strings.
  • Static sentinels. TENANT_LIST collides with nothing that has a #.

Dangerous patterns:

  • Bare identifiers. gsi1pk: "\${userId}" with no prefix. Any entity keyed on a raw ID risks collision with any other.
  • Overlapping prefixes. USER# and USERGROUP# are fine as full values, but if you ever begins_with("USER#") on the sort key you may match unintended items. Prefix uniqueness matters at every position you query on.
  • User-supplied values containing your delimiter. An email address can’t contain #, but a tenant name can. \${name}#\${tenantId} breaks if a tenant is named Acme#Corp. Sanitise on write or move variable-length user input to the end.

The rule of thumb: every overloaded key value should start with a token that identifies which access pattern produced it.

Overloaded and sparse together

Overloading and sparse indexes compose, and the combination is where the cost savings get dramatic.

An overloaded index only contains items that actually have gsi1pk set. Entities with no secondary index - SubscriptionEntity above - never write to it and cost nothing. Entities that write it conditionally only appear while the condition holds.

attributes: {
  // Present only while the order is in review
  reviewQueuedAt: { type: "string", required: false },
},
indexes: {
  reviewQueue: {
    index: "gsi1",
    pk: { field: "gsi1pk", composite: [], template: "REVIEW_QUEUE" },
    sk: { field: "gsi1sk", composite: ["reviewQueuedAt"] },
  },
},

ElectroDB omits the index keys entirely when an optional composite attribute is undefined. So gsi1 holds the login lookups, the creator lookups, the tenant list, and the current review queue - and nothing else. The index stays a fraction of the table’s size.

Projections are the real constraint

Here’s the trade-off that decides most overloading arguments, and it’s the one people discover after they’ve already collapsed their indexes.

Projection type is per-index, not per-entity. All entities sharing gsi1 share its projection. If the login lookup only needs userId (KEYS_ONLY would do), but the projects-by-creator query needs the full project record (ALL), the shared index must project ALL. The login lookup now carries every user attribute into the index, and you pay for it.

Quantifying it before you decide:

  • Overloading wins when entities have comparable attribute sizes, or when the index would be ALL anyway.
  • Separate indexes win when one access pattern needs KEYS_ONLY over a large, frequently-written entity and another needs ALL. Three narrow indexes can genuinely cost less than one wide one.

Same reasoning applies to per-index throughput. Under provisioned capacity, separate GSIs let you tune each independently. Under on-demand this doesn’t apply.

When not to overload

While you’re still designing. Dedicated indexes are easier to reason about and easier to change. Overload once the access patterns have stopped moving.

When projections genuinely differ. See above. Run the numbers rather than assuming overloading is always cheaper.

When different teams own different entities. A shared index is a shared dependency. Every entity added to gsi1 is a change that could affect every other entity on it. If the coordination cost is high, the cost saving may not be worth it.

When you have fewer than three GSIs. Collapsing two into one saves one write unit per item. The added subtlety probably isn’t worth it.

The infrastructure drift trap

ElectroDB defines how entities use indexes. It does not create them. Your GSIs live in CDK, SST, or Terraform.

Two failure modes, both worth guarding against:

Removing GSIs from IaC before deploying the entity change. Queries against gsi2 fail at runtime the moment the index is gone. Deploy the entity changes first, confirm nothing queries the old indexes, then remove them.

Removing them from IaC and forgetting they still hold data. Deleting a GSI is irreversible. Recreating it means a full backfill, which on a large table takes hours and consumes significant write capacity.

Sequence: deploy overloaded entities → verify no traffic on old indexes → remove from IaC → deploy.

Design This Visually → Coming Soon

Prefix collision safety is a property you have to verify by reading every key template in your schema at once and reasoning about whether any two could produce the same string. That’s exactly the kind of check a tool should do for you.

That’s what I’m building at singletable.dev: see every entity’s index keys side by side, get flagged on collisions, and export the ElectroDB definitions.

Join the waitlist →


Related: ElectroDB Single-Table Design - Services, Collections, and clustered indexes. SaaS Multi-Tenant pattern - the full schema these examples come from.

Tejovanth N

Tejovanth builds on DynamoDB in production: rasika.life, rekha.app, rrmstays. All single-table with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.