DynamoDB Single-Table Design: Too Many GSIs?
DynamoDB allows 20 GSIs per table. Most single-table designs need 2–5. If you’re approaching the limit, or just paying for more GSIs than you should, the problem is usually one of three things: access patterns that could share a GSI, GSIs created for rare queries that don’t justify the cost, or overloaded key structure that hasn’t been applied consistently.
The real limit isn’t 20, it’s cost
The 20 GSI limit is rarely what bites you. The actual constraint is cost and write amplification. Every GSI on a table receives a write for every item that has its key attributes. Five GSIs on a high-write table means 5x the write cost. Twenty GSIs on a table with 10K writes/second means 200K GSI write units/second.
If you’re building a schema and reaching for a new GSI every time you have a new query, step back. Most access patterns can be served by 3–5 well-designed GSIs.
Fix 1: Overload existing GSIs
The most effective GSI reduction technique is GSI overloading. If two query patterns need the same sort order but different partition keys, they can sometimes share one GSI by using the same GSI PK and SK attributes but populating them differently per entity type.
GSI1: gsi1pk / gsi1sk
User entity: gsi1pk = STATUS#active, gsi1sk = USER#<ulid>
Order entity: gsi1pk = STATUS#pending, gsi1sk = ORDER#<ulid>
Subscription: gsi1pk = STATUS#cancelled, gsi1sk = SUB#<ulid>
All three entities write to GSI1 with a status-based partition key and a type-prefixed sort key. Querying GSI1(pk=STATUS#active) returns only User records because only User records write that partition key value. One GSI serves three entity-specific queries.
The SaaS Multi-Tenant pattern shows GSI overloading across 4 entity types with 2 GSIs, resolving 8 access patterns.
Fix 2: Use sparse indexes for rare queries
A sparse index only writes rows when a specific attribute exists on the item. Use this for access patterns like “show me all items in the ‘flagged’ state”, where only flagged items have the GSI attribute set.
// Only write to GSI1 when flaggedAt is set
gsi1pk: {
field: 'gsi1pk',
composite: ['flaggedAt'], // only populated when flaggedAt exists
template: 'FLAGGED',
get: (item) => item.flaggedAt ? 'FLAGGED' : undefined,
}
Items without flaggedAt don’t appear in the GSI at all. This serves the “show all flagged items” query without adding a dedicated GSI. It also costs nothing for the 99% of items that are not flagged.
See sparse indexes for the full pattern with soft deletes, queue-like access, and active-only views.
Fix 3: Rethink access patterns that don’t need a GSI
Not all queries need a GSI. Some patterns can use:
Sort key range queries. If you only need items within a date range and the date is already your sort key, Query(pk=X, sk between A and B) doesn’t need a GSI. Check if the data you need is already in the right partition.
Begins-with filtering. Query(pk=X, sk begins_with PREFIX#) filters by entity type within a partition without a GSI.
Application-layer filtering. If a query returns a small number of items and you only need a subset, filter in application code. Querying 100 items and filtering to the 5 that match a condition is often more efficient than maintaining a GSI for that 5.
Batch gets. If you always know the IDs of the items you need, BatchGetItem across N primary keys is O(1) reads per item with no GSI required.
Fix 4: Merge access patterns onto one GSI with composite sort keys
Two access patterns that differ only by filter can often share a GSI with a composite sort key:
| Access Pattern | Without sharing | With shared GSI |
|---|---|---|
| Orders by status | GSI: STATUS#<status> / ORDER#<id> | Same GSI |
| Orders by status + customer | GSI: STATUS#<status> / CUST#<cid>#ORDER#<id> | Same GSI, filter by sk begins_with |
The second access pattern uses sk begins_with CUST#<customerId># to filter within the status partition. One GSI, two access patterns.
The rasika.life case
rasika.life started with 6 GSIs on the Event entity. One of the pain points that motivated SingleTable. After applying overloading and sparse indexes, 4 of those GSIs were eliminated. The remaining 2 serve all the access patterns that the 6 did, at a third of the GSI write cost.
The lesson: GSIs should be the last resort, not the first tool. Sort key structure, key templates, and sparse indexes cover most access patterns without reaching for a new global index.
The GSI vs sort key decision guide covers when each approach is right. The SaaS Multi-Tenant pattern shows GSI overloading across multiple entity types in practice.