T17 · Indexers
What happens to your data after a chain reorg?
- Capabilities practiced
- BuilderOnchain Literacy
- Hands on
- Write a minimal indexer that can rerun from any block height and produce exactly the same results.
- AI Lab
- Have AI write reorganization rollback logic. Construct fork data yourself to check whether anything remains undeleted.
A Real-World Problem
Your indexer has run for two weeks without a red dashboard indicator: no crashes, errors, missing blocks, or latency above three seconds.
One morning a user reports receiving a “Received 500” notification last night, but the funds are not in their wallet.
You find the database row. It has a transaction hash, block number, amount, and timestamp. You paste its transaction hash into a block explorer:
“Transaction not found.”
You check the block number. A block exists at that height, but its contents differ entirely from the block in your database. It has no such transaction.
The indexer neither crashed nor skipped a block. It faithfully recorded a history that later ceased to exist and notified a user based on it.
T1 warned that this can happen. T16 had you store block_hash. Now put it to work.
The problem is sharp: the chain’s recent history can be rewritten, but your database does not rewrite itself. What connects the two safely?
Thought Experiment
A noticeboard stands across a river. You copy it through binoculars. Its rule: the newest notices can be torn down and replaced at any time, while older notices underneath stay fixed.
There are three ways to copy it.
First: remember only “which notice did I reach?” Your notebook says “copied through 1000”; next time, start at 1001.
If notices 996–1000 were replaced, you never know. Five notices that never existed remain among thousands of real ones in your book, with no marker to identify them.
Second: record each notice’s fingerprint and the fingerprint it claims for the previous notice. Your notebook contains a linked chain. Before copying notice 1001, compare its claimed predecessor with your fingerprint for 1000.
A mismatch means history changed. You can determine that from your own side of the river immediately, without asking anyone’s opinion.
Third: copy only notices buried far below the newest ones. You will rarely be wrong, but you will always lag reality.
The second approach hides another question: you know 1001 fails to connect, but not how many notices were replaced. Perhaps only 1000, perhaps everything after 996.
The solution is to compare backward: check notebook 1000 against the current noticeboard 1000, then 999, then 998, until the first match. That matching notice is the fork point; everything after it is dirty.
This thought experiment is the chapter’s full skeleton. The rest turns it into reliable code.
Your Decision
Choose a reorg strategy for your indexer.
What Happens
Compare the strategies:
| Latency | Requires reorg detection? | Recovery cost | Complexity | |
|---|---|---|---|---|
| Deep blocks only | High | Mostly no | None | Lowest |
| Head plus full rebuild | Low | Yes | Very high | Medium |
| Head plus fork-point rollback | Low | Yes | Low | High |
| Two-track confirmation | Lowest | Yes | Low | Highest |
The second column matters: all except the first depend on detecting reorgs. Without detection, even elegant rollback code never runs. That is why T16 stored block hashes.
First conclusion:
A reorg is normal chain behavior, not an exceptional outage. Make it an ordinary indexer code path.
Second, and more important for the opening complaint:
The hard part of a reorg is not deleting data. It is undoing effects that have already escaped your database.
Notifications, emails, and payments have no delete statement. Reorg handling has two parts: inside the database, precisely delete and recompute; outside it, the only defense is not acting before blocks are deep enough.
The two parts use different mechanisms. Mixing them produced the opening notification.
Build a Model
The Main Loop
Start with readable pseudocode for the whole indexer:
loop:
head = rpc.blockNumber()
cp = db.readCheckpoint(chainId) # Height + block hash
if not isStillOnChain(cp): # 1. Before advancing, check that our position is still canonical
rollbackTo(findForkPoint(cp)) # One transaction
continue
from = cp.number + 1 # 2. Range for this pass
to = min(cp.number + batchSize, head)
if from > to: sleep(pollInterval); continue
for n in from..to: # 3. One transaction per block
block = rpc.getBlockHeader(n)
if block.parentHash != lastHash: break # Head changed during fetch; recheck on next pass
logs = rpc.getLogs(n, n, filters)
db.transaction:
insertBlock(block)
insertLogs(logs) # Idempotent through primary key
enqueueOutbox(logs, n) # Store side effects; do not send them yet
writeCheckpoint(n, block.hash) # In the same transaction as data
lastHash = block.hashFive decisions are embedded here. Consider them in turn.
Detect Reorgs in Two Places
First, while advancing: the next block’s parentHash must equal the checkpoint’s last_block_hash. A mismatch signals a reorg.
This catches most cases but has a blind spot: a deeper earlier reorg after you have already passed that point, or an RPC service switching between nodes with different head lag, can leave a superficially continuous parentHash.
Second, periodically rescan: fetch hashes of the last D indexed blocks from blocks and compare each against the current onchain block at that height. Make D a multiple of the deepest reorg you have observed.
The scan is cheap and catches the cases the first check misses, which are precisely the hardest to diagnose.
Find the Fork Point by Walking Backward
findForkPoint(cp):
n = cp.number
while n > cp.number - maxRollbackDepth:
onchain = rpc.getBlockHeader(n).hash
local = db.blockHash(chainId, n)
if local is null: return n # Missing locally, so treat this as the boundary
if onchain == local: return n # First matching height = fork point
n = n - 1
raise DeepReorgError(n)Three key details:
The fork point is the first matching height, not the first mismatching one. That word decides whether deletion uses > or >=. An off-by-one error leaves a dirty block or removes a good one. Neither necessarily throws an error.
Set a maximum rollback depth. Without it, a faulty RPC connection to another network or a half-synced node could make the indexer walk back to genesis and delete everything. That failure mode has happened in real systems.
A deeper mismatch requires human attention. Stop and alert rather than deleting on your own. This is one of the few indexer conditions that should wake someone up.
Linear or binary search? Use linear search. Most reorgs are only a few blocks deep. A few extra RPC calls buy simple, auditable boundary logic. Binary search is premature here.
Roll Back in One Transaction
Use the three tables from T16 and add an outbox for pending effects:
begin;
-- 0. Capture affected holders before deletion; afterward their identities are lost.
create temp table touched_holders on commit drop as
select distinct holder from (
select from_addr as holder from transfer_events
where chain_id = $1 and block_number > $fork
union
select to_addr as holder from transfer_events
where chain_id = $1 and block_number > $fork
) x;
-- 1. Delete fact and block rows above the fork point.
delete from transfer_events where chain_id = $1 and block_number > $fork;
delete from blocks where chain_id = $1 and block_number > $fork;
-- 2. Delete effects not yet sent.
delete from outbox
where chain_id = $1 and block_number > $fork and delivered_at is null;
-- 3. Recalculate derived balances for affected holders.
delete from token_balances b
where b.chain_id = $1
and exists (select 1 from touched_holders t where t.holder = b.holder);
insert into token_balances (chain_id, contract, holder, balance, updated_at_block)
select ... from transfer_events
where chain_id = $1 and to_addr in (select holder from touched_holders)
or chain_id = $1 and from_addr in (select holder from touched_holders)
group by chain_id, contract, holder;
-- 4. Move the checkpoint back to the fork point.
update indexer_checkpoints
set last_block_number = $fork, last_block_hash = $forkHash
where chain_id = $1;
commit;All five steps belong in one transaction. Crash after any one step outside an atomic transaction and the database may contradict itself without an obvious error.
Step 0 is easy to put in the wrong place: collect affected addresses before deleting their event rows. Do it afterward and the recomputation set is empty, leaving derived balances stale.
Two Checkpoint Ordering Mistakes
Remember this rule above all others:
Mistake one: commit checkpoint before writing data
Crash between steps → checkpoint says “done,” but data was never written
→ permanent missing data with no automatic detector
Mistake two: write data, then commit checkpoint in another transaction
Crash between steps → data exists, checkpoint did not move
→ restart redoes the range; idempotent insertion blocks duplicates → harmless
Correct: commit data and checkpoint in one transactionIf one transaction is impossible, update the checkpoint last. Repeating work is safer than silently skipping it.
This turns a crash from a correctness problem into a performance problem. It depends on T16 and its on conflict do nothing: idempotency is the foundation of resuming, not an optional optimization.
Backfill and Head Tracking: Same Code, Different Ranges
Historical backfill and live tracking share processing logic. They differ in who determines the range. Live tracking advances from checkpoint plus one serially and checks reorgs each pass. Backfill takes ranges from a work table, can run shards in parallel, and need not worry about stable historical blocks reorganizing.
Parallel backfill has a trap: progress cannot be one number.
Suppose five shards cover 1–100, 101–200, 201–300, 301–400, and 401–500. Shards 1, 2, and 4 finish while shard 3 fails. If progress is the highest completed height, it displays 400. The gap of 100 blocks disappears from monitoring and may never be filled.
Use a range table with a row for each work unit, status, and retry count:
create table backfill_ranges (
chain_id integer not null,
from_block bigint not null,
to_block bigint not null,
status text not null, -- pending / running / done / failed
attempts integer not null default 0,
primary key (chain_id, from_block)
);Then backfill is complete when no row has a status other than done. One SQL query answers that more reliably than “how far did we get?”
Side Effects: Wait Until a Safe Height
The opening notification went wrong here.
Do not perform irreversible actions directly inside the block-processing transaction. Split them into two steps:
create table outbox (
id bigserial primary key,
chain_id integer not null,
block_number bigint not null, -- Used to decide whether the event is deep enough
event_key text not null, -- Onchain coordinate used by senders for deduplication
payload jsonb not null,
delivered_at timestamptz,
unique (chain_id, event_key)
);First, save “what to send” in the same database transaction as the event, but do not send yet. If the transaction fails, the outbox row vanishes. If a reorg removes the block, the pending row is deleted with it.
Second, a separate dispatcher sends only eligible rows:
select * from outbox
where chain_id = $1
and delivered_at is null
and block_number <= $safeHeight -- Only events deep enough
order by block_number, id
limit 500;safeHeight is where confirmation depth takes effect. An event does not leave your database before it is buried deeply enough.
That closes the issue from T1: a notification for a nonexistent transfer remains unsent in the outbox until a reorg removes it with delete.
Confirmation depth is not one global constant. Configure it by business risk:
| Use | Example depth policy |
|---|---|
| Display “processing” | 0; show immediately |
| Lists, statistics, charts | Shallow; occasional movement is acceptable |
| Push notifications and email | Medium; sent messages cannot be recalled |
| Rewards, accounting, settlement | Deep, with reconciliation |
Different actions on the same chain should use different depths. Put them in configuration rather than scattering constants through code.
Acceptance: An Automatable Standard
This chapter has a strict testable standard:
Replay from any height and obtain exactly the same final result, byte for byte.
Use the checksum query at the end of T16. Record the checksum, delete data above a chosen height, move the checkpoint back, replay, and calculate again. The values must match.
That turns “is my indexer correct?” into an automated yes/no test. If it cannot pass, its replay or reorg logic is wrong even if no user has yet noticed.
What It Is Called
The loop that advances by height and fetches blocks and logs in order.
It looks like the heart of the indexer but is its simplest part. The hard parts surround it: checkpoints, reorgs, idempotency, and side effects.
Parses onchain logs into structured records and stores them as the fact table from T16.
An onchain-coordinate primary key rejects repeated insertion and provides idempotency.
Recent blocks are replaced by another branch. Blocks you processed may no longer belong to the canonical chain.
It is routine behavior, not necessarily a fault. Detect it by hash comparison and roll back to the fork point. Height-only systems cannot see it.
The first height whose stored hash matches the current onchain hash while walking backward. Every later row is dirty.
It is the first match, not the first mismatch. That boundary determines > versus >= in deletion. Get it wrong and you can leave dirty rows or remove good ones without an error.
A cursor storing both the last processed height and its block hash.
One rule: commit it in the same transaction as data; if impossible, commit it after data. Moving it first loses data permanently; moving it last may merely repeat idempotent work.
The process of filling historical blocks. It shares processing code with live tracking but gets its ranges from another source.
For parallel backfill, progress must be a range table rather than one number. A maximum completed height hides gaps.
Records irreversible side effects in the database before a separate process sends them.
It ensures side-effect intent and event data commit together, while a reorg can still remove unsent intent. Dispatching only after sufficient block depth is how confirmation policy becomes operational.
Hands-on
Stay on testnet and read only. This chapter requires no signing, broadcasting, or spending.
Three automatable acceptance criteria:
Check 1: replay the same blocks any number of times; checksum stays fixed
Check 2: simulate a reorg, roll back and replay; checksum returns to its prior value
Check 3: kill the process 20 times while indexing; after each restart the checksum agreesReuse three tables from T16 and add two.
Bring over transfer_events, blocks, and indexer_checkpoints. Add this chapter’s outbox and backfill_ranges.
For every data row, check that you can answer which transaction produced it, its block height, and whether its block remains canonical.
Write the main loop without reorg handling first.
Implement the pseudocode above, temporarily skipping isStillOnChain.
Get these pieces right together:
- One transaction per block
- Ingest events with on conflict do nothing
- Commit checkpoint with data in the same transaction
- Save block headers in blocks, including parent_hashProcess 500 blocks and record the checksum from the query at the end of T16.
Check 1: replay from an arbitrary height.
-- Choose height N, delete everything after it, then move checkpoint back.
delete from transfer_events where chain_id = $1 and block_number > $N;
delete from blocks where chain_id = $1 and block_number > $N;
update indexer_checkpoints set last_block_number = $N,
last_block_hash = (select block_hash from blocks
where chain_id = $1 and block_number = $N)
where chain_id = $1;Restart the indexer, run back to the original height, and compute the checksum again.
The two checksums must be identical. Stop here if they are not. Try three different values of N.
Implement reorg detection and backward fork-point search.
Add isStillOnChain and findForkPoint, including the maximum depth and an alert and stop when exceeded.
Mind the fork-point boundary: return the first height with a matching hash and delete where block_number > fork. The next step tests that boundary.
Check 2: create a fake reorg instead of waiting for one.
-- Alter one byte of a block hash, simulating a block that is no longer canonical.
update blocks
set block_hash = decode('00' || encode(block_hash, 'hex'), 'hex')
where chain_id = $1 and block_number = $N - 3;Also add a few fake transfer events above that height to represent dirty rows.
Restart. Correct behavior is to detect discontinuity, walk back to the fork point (in this setup, N - 4), delete later rows, and reindex.
The final checksum must equal the value recorded in step two. Fake events must disappear.
Then run a boundary experiment: change deletion from > to >= and see how the checksum changes. Seeing the off-by-one error once is more useful than memorizing the rule.
Check 3: kill the process at random times. Start the indexer, force-stop and restart it twenty times. After each restart verify:
- Does the checksum match the control run?
- Does checkpoint height match the maximum height in blocks?
- Does checkpoint ever say it reached N while N's data is absent?The last case means checkpoint commit order is wrong. This reproduces ordinary operations events: restart, deployment, out-of-memory kill, or machine failure.
Add outbox and verify side effects stay inside until safe.
Write notification intent with each event in the same transaction. A dispatcher selects only rows at or below the safe height, then sets delivered_at after sending.
Repeat check 2. For rolled-back heights, count outbox rows whose delivered_at is not null. There must be zero. That prevents the opening user complaint.
Follow the chain head for a week and record real reorgs.
Set confirmation depth to zero on a fast-block testnet. For every detected reorg, record time, fork-point height, and depth.
After a week, examine the depth distribution. Your own observations can inform the confirmation depth needed by your business better than blindly copying a number from an article.
AI Lab
Ask in three steps; the third is the deeper challenge.
Step one:
I have a fact table keyed by chain_id + block_number + log_index,
a block table storing block_hash and parent_hash, and a checkpoint table with height
and hash. Write complete reorg detection and rollback logic, including fork-point search.
Provide pseudocode and SQL.
Step two:
My database contains blocks 1000 through 1010. A reorg replaces 1006–1010 with five
new blocks; 1005 and earlier remain. Under your logic, explain in order:
1. Where will processing block 1011 detect the problem?
2. Which heights does findForkPoint compare, and what does it return?
3. Write the deletion condition and say exactly which heights it removes.
4. What changes if I use >= instead of >, and what are the consequences?
Step three:
Before the reorg, my system sent notifications, emails, and rewards for events in
1006–1010. They cannot simply be deleted. Design a way to prevent this, and explain
when your design could still fail.Models often handle step one well, because reorg rollback has been explained countless times. Watch three details: hash versus height, whether a maximum rollback depth exists, and where the transaction boundary is drawn.
Step two is arithmetic, and models make more mistakes on step-by-step concrete numbers than you may expect. The most common mistake is an off-by-one fork point: it claims findForkPoint returns 1006, then deletes with > 1006, leaving dirty block 1006 behind. The mistake is difficult to spot in review yet permanent in the data.
Step three is the real value of this Lab. It asks where the boundary lies: what can you roll back, and what cannot be undone? A good answer proposes an outbox and confirmation depth, and explicitly names the cases where that design can still fail. If it does, ask a follow-up: “Your confirmation depth is 12, but a reorg goes 30 blocks deep. What happens to notifications already sent?” There is no purely technical answer; the response is a business decision. Recognizing that distinction separates writing code from taking responsibility for a system.
You must do the last part yourself: construct the fork data from lab step five, run the generated code, and compare checksums. T16 made the same point in its AI Lab: these bugs look reasonable in review and reveal themselves only when real data goes through the code.
Verify these points yourself after using AI
- Does it detect reorgs by block hashes, not merely heights? Height-only detection can never find replacement at the same height
- Does it compare backward to find the fork point rather than assume a fixed reorg depth?
- Is there a maximum rollback depth, and does exceeding it stop and alert rather than continue deleting?
- Does deletion use greater than the fork point, not greater than or equal? Compare checksums from both on constructed data
- Does it repair derived tables? If recalculating, does it collect affected holders before deleting facts?
- Are checkpoint updates and deletion in one transaction?
- What happens to notifications and emails already sent and impossible to retract?
- Does it handle a lagging RPC node or disagreement between RPC nodes?
- Run its logic on a constructed fork and compare checksums rather than trusting its prose
Real-World Case
The opening case was foreshadowed in T1.
An indexer sends a notification immediately when a block appears. After a reorg, the transfer never makes it into canonical history. The user still has a notification about it.
The damage is not one message; the user may no longer trust any notification. An outbox and safe-height check prevent it.
An indexer records “processed through block 1000.” After a reorg, it continues at 1001. No parentHash check exists.
Events from five replaced blocks stay among canonical events, with no query marking them as dirty. Three months later, reconciliation fails. The worst part is that the database cannot identify the wrong rows.
This continues the final case in T16: a stored block hash is inexpensive insurance.
To “reduce transaction size,” someone updates the checkpoint in a separate transaction before writing events. It works until a deployment restart lands between them. The checkpoint moved, but event rows did not; all events in that block are permanently missed.
No alert, log, or ordinary query necessarily finds them, because the system thinks the block was processed. Repeat work rather than skip work.
Five shards run backfill in parallel, tracking progress as the maximum completed height. One shard stops after RPC rate limiting. Monitoring sees only the maximum height and reports success.
A hundred missing blocks remain undiscovered until users notice absent records weeks later.
Parallel progress is not one number. Track ranges and define completion as “no row has a status other than done.” One SQL query answers it.
Change One Variable
The task gets easier: blocks beyond that bound cannot change, so maximum rollback and safe height can have a principled value.
The head can still reorganize, so detection and rollback remain necessary. You know how deep “deep enough” is instead of guessing. Chains define finality differently; configure it per chain rather than hard-coding one value.
The new node has not reached your checkpoint height. If “cannot find this block” is treated as “hash mismatch,” the indexer may roll back ten perfectly good blocks.
Treat “remote head is below my checkpoint” as a separate state: wait rather than roll back. Being ahead and being wrong are different.
The correct code behavior is stop, alert, and do not continue deleting. Beyond that bound, you cannot tell a deep reorg from connecting to the wrong network.
The business must answer something code cannot: what happens to notifications and rewards already delivered? Set that policy before launch rather than improvising during the event.
It is faster, but the resulting batch may span two branches if the head changes during the request. Logs include their block hash; compare it with each corresponding block in your table and discard and refetch a batch that disagrees.
More generally, do not assume a batch request is internally consistent. Larger near-head ranges are more likely to cross a reorg. Shrink batches near the head and use larger ones for old stable history.
Takeaway Questions
What problem does it solve? This chapter safely stores a history that can be rewritten in a database that cannot rewrite itself automatically. Fetching data is not the hard part; a scanner is an afternoon’s loop. Reorgs, checkpoints, and idempotency reveal their difficulty only when something goes wrong.
Who bears the risk? If an index is wrong, users see false balances or receive false notifications and rewards, perhaps for months. The characteristic is silence: nothing crashes while it drifts from the chain. Acceptance must be an automated checksum assertion, not “the dashboard looks fine.”
Who loses when AI is wrong? An AI answer can explain fork points correctly yet place one wrong symbol in code, leaving a dirty block forever. Review alone rarely exposes that; constructed data and execution do. The person operating the indexer owns the result.
Chapter Check
A height does not uniquely identify a block. After a reorg, a different block can occupy the same height.
A height-only checkpoint cannot answer whether the block it processed is still canonical. It simply moves forward while dirty rows remain, with no way to label them.
Store both height and block hash in the checkpoint, and parent_hash in the block table so continuity can be verified.
The first matching height while walking backward. It is clean; heights after it are dirty. Delete strictly greater heights.
Using greater than or equal deletes one clean block. Returning the first mismatch and using strictly greater than leaves one dirty block. The errors point in opposite directions and both can be silent.
Test by constructing a fork, running both conditions, and comparing checksums.
A crash can occur between any two statements, with different outcomes.
Checkpoint first: after a crash, the system believes a block was processed though its data is missing: permanent silent loss. Checkpoint last: after a crash, it repeats the range; idempotent ingestion discards duplicates. Work is repeated but the result is right.
Use one transaction, or put checkpoint last. Prefer replay to missing data. Primary-key idempotency is the foundation of this recovery.
Notifications, email, and payments have no delete statement. Dirty database rows can be removed; sent messages cannot be recalled.
An outbox commits intent with data and lets reorg rollback remove intent not yet sent. The dispatcher sends only rows whose blocks are deep enough.
Confirmation depth should depend on business action: a display may accept zero, while rewards should wait much longer.
There is no prescribed answer. Check:
- Replaying the same blocks never changes the checksum; assert it in SQL.
- Delete after an arbitrary height and replay; repeat with three heights and compare checksums.
- Alter one stored block hash to simulate a reorg; rollback and replay restore the checksum.
- Kill the process twenty times at random; checkpoints and data stay consistent after restart.
- No outbox row from a rolled-back height was delivered.
- Parallel backfill completes only when every range is done, not when a maximum height advances.
- Exceeding the rollback depth stops and alerts rather than deleting farther back.
- A remote head below the checkpoint causes waiting, not rollback.
The first five can be automated. That turns indexer correctness from a vague impression into a test that passes or fails.
Key idea
The hard part of an indexer is never fetching; it is handling reorgs, resuming after interruption, and ensuring idempotency.