Intermediate7 min read

Why a DynamoDB GSI Is Eventually Consistent

You write an item, immediately query a Global Secondary Index for it, and get nothing back — even though the write succeeded and a base-table GetItem returns the item fine.

Nothing is broken. You've hit the most surprising property of GSIs: every read of a GSI is eventually consistent. There's a brief window after a write where the index hasn't caught up yet.

Are DynamoDB GSIs eventually consistent?

Yes — every read of a Global Secondary Index is eventually consistent, with no way to opt out. Your write commits to the base table first, then propagates asynchronously to the index, so a issued right after a write can return stale or missing rows. DynamoDB offers no ConsistentRead flag for a GSI.

  • A GSI is a separate, asynchronously replicated table — your write commits to the base table first, then propagates to the index.
  • No ConsistentRead flag exists for a GSI. Unlike the base table, you can't force a strong read to close the gap.
  • Read-your-own-writes from the base table, not the GSI. You already hold the right after a write.
  • Enforce uniqueness with a conditional write, not a GSI query. The propagation gap turns a "is this taken?" check into a race.

The symptom: a sign-up that "can't find itself"

Take a Members table for a user-accounts service. The base table is keyed by an internal id, but users log in by email, so there's an email-lookup GSI:

Members (base table)
PKSKemaildisplayName
ACC#a1f9cPROFILEada@northwind.testAda L.
EmailIndex (GSI)
GSI1PKGSI1SK
ada@northwind.testACC#a1f9c

The sign-up flow does two things back to back: PutItem the new member, then Query EmailIndex WHERE GSI1PK = "ada@northwind.test" to check no one else claimed that address and to load the profile.

Run those two calls a few milliseconds apart and the Query can return zero items. Do it again a second later and the row is there. The write didn't fail — the index just hadn't been updated yet.

Why this happens: GSIs are replicated asynchronously

A GSI is a separate, internally managed table with its own partitions and its own key schema. It is not maintained inside the same transaction as your base-table write.

When you PutItem, DynamoDB durably commits to the base table, acknowledges your write, and then asynchronously propagates the change to each GSI. The AWS GSI documentation states it plainly: GSIs support eventually consistent reads only.

The propagation delay between a base-table write and the index update is usually a fraction of a second — but it is not guaranteed and not bounded under load. Designing as if it were bounded is the trap.

This is not a bug; it's the original Dynamo design trade-off. The 2007 Amazon Dynamo paper chose availability and partition tolerance over strong consistency.

GSIs inherit that lineage. Loose coupling is what lets the index scale and stay writable independently of the base table.

EmailIndexBase tableAppEmailIndexBase tableAppasync propagationPutItem (new member)200 OKQuery by email0 items (stale)replicate changeQuery by email1 item (caught up)

The gap between the 200 OK and "replicate change" is the window where your index read is stale. There is no consistent-read flag that closes it.

Unlike the base table — where you pass ConsistentRead = true to force a GetItem/Query — a GSI flatly rejects that option.

An LSI can be read strongly because it shares the base table's partitions; see GSI vs LSI for why that distinction exists.

A subtler trap: stale old values, not just missing new ones

The missing-row case is the obvious one. The quieter bug is reading a stale previous value.

Say Ada changes her email from ada@northwind.test to ada.l@northwind.test. The base table updates atomically, but for a moment the GSI can still return the old index entry.

A lookup against the new value misses, while the abandoned value still resolves.

Worse: if you query the GSI and write back based on what you read, you can act on a value that no longer exists. Treat any GSI read as a snapshot that may lag reality.

Design around it — don't fight it

The propagation window is real, so the fix is architectural, not a retry knob you toggle. Four patterns, roughly in order of preference:

  1. Read your own writes from the base table. Right after a write you already hold the primary key (ACC#a1f9c), so do a strongly consistent GetItem on the base table instead of querying the GSI.

    The GSI is for the other access pattern — "I have an email, find the account" — not for confirming the write you just made.

  2. Enforce uniqueness with a guard item, not the GSI. Never trust a GSI query to prove an email is unclaimed — the propagation gap makes that a race two simultaneous sign-ups can both lose.

    Instead, write a dedicated uniqueness item keyed on the email itself (PK = "EMAIL#ada@northwind.test") inside a TransactWriteItems with a ConditionExpression of attribute_not_exists(PK).

    Strongly consistent base-table conditions, applied atomically, are what actually enforce uniqueness.

    TransactWriteItems:
      - Put member item    (PK = ACC#a1f9c, SK = PROFILE)
      - Put uniqueness item (PK = EMAIL#ada@northwind.test)
          ConditionExpression: attribute_not_exists(PK)

    If a second sign-up races for the same address, its condition fails and the whole is rejected — no GSI, no propagation delay, no double-claim.

    Build and preview that attribute_not_exists condition with the DynamoDB Expression Builder before you wire it into code.

  3. Tolerate the lag in the UX. When the GSI read genuinely is the right tool (login by email for an existing user), the window is sub-second and harmless — an established account propagated long ago.

    Reserve the strongly consistent base-table path for the read-after-write moment only.

  4. Re-query, don't assume. If a workflow must observe a brand-new item through the GSI, treat an empty result as "not yet visible," not "does not exist," and re-query after a short backoff.

    But prefer patterns 1 and 2, which remove the guesswork entirely.

See the propagation gap yourself

The fastest way to build intuition is to watch it happen. In DynoTable you put an item into the base table and immediately query the GSI in a second tab.

On a loaded table you'll occasionally catch the index trailing the base data, then watch it converge on the next refresh.

Seeing the lag with your own data makes the "read your own writes from the base table" rule stick far better than any diagram.

Pitfalls and next steps

  • Don't gate logic on a GSI read-after-write. Uniqueness checks, "did my write land" confirmations, and read-modify-write loops belong on the strongly consistent base table.
  • Don't reach for ConsistentRead on a GSI — it isn't allowed and will error.
  • Don't model an access pattern as a GSI when the base key already answers it. Serve a read from the primary key and you skip the propagation window entirely.

Picking the right key shape is the whole game in single-table design; knowing when a Query beats a Scan keeps you off the index in the first place (Query vs Scan).

Build and test your uniqueness ConditionExpression in the DynamoDB Expression Builder. Then try DynoTable to watch base-table writes propagate to a GSI in real time, and design your keys so the eventual-consistency window never bites you.

Updated