← All weekly notes

11 min read

Building a simple Ethereum indexer in Go

Notes from building a focused Ethereum mainnet indexer with bounded concurrency, PostgreSQL storage, reorg recovery and a simple read API.

Go · Blockchain · Ethereum · PostgreSQL

A couple of weeks ago I started building ethindexer, a simple Go service for indexing recent Ethereum mainnet data.

The goal was not to create another archive node or full blockchain explorer. I wanted a focused project for revisiting the practical parts of indexing:

  • Fetching blocks concurrently without overwhelming the RPC provider.
  • Storing canonical blocks, transactions and raw EVM logs.
  • Recovering correctly when the chain reorganizes.
  • Keeping database updates atomic.
  • Exposing a simple read-only HTTP API.

I deliberately limited the first version to a recent window of at most 50 blocks. This narrow boundary made it possible to concentrate on correctness, lifecycle and failure handling before thinking about historical backfills.

The data flow

The service continuously selects an Ethereum head, fetches the required range and commits a canonical view to PostgreSQL.

Ethereum JSON-RPC        |        vselect latest, safe or finalized head        |        vbounded Go worker pool        |        +-- full blocks and transactions        +-- raw logs filtered by block hash        |        vvalidate block continuity        |        vatomic PostgreSQL update        |        vread-only HTTP API

Each fetched block becomes one bundle:

type BlockBundle struct {	Block        Block	Transactions []Transaction	Events       []Event}

Treating this as the storage unit keeps the block and everything derived from it together. A partial block should never become visible through the API.

Bounded concurrent fetching

Fetching blocks sequentially is simple but wastes time waiting for independent RPC calls. Fetching an unlimited number concurrently can overload both the node and the indexer.

The implementation uses a fixed number of workers:

jobs := make(chan fetchJob)bundles := make([]domain.BlockBundle, blockCount) for worker := 0; worker < concurrency; worker++ {	go func() {		for job := range jobs {			bundle, err := fetchBlock(ctx, job.blockNumber)			if err != nil {				cancel()				return			} 			bundles[job.index] = bundle		}	}()}

Workers fetch independently, but every result is written to its predetermined slice position. Once the workers finish, the completed range is validated in block-number order.

For each block the indexer performs:

  1. One full block request containing its transactions.
  2. One log request filtered by the exact block hash.

Using the block hash matters. The response can be validated against the block being indexed rather than depending only on a number that might have changed during a reorganization.

The fetcher also checks transaction chain IDs, derives senders, verifies log positions and rejects logs that do not belong to the expected transaction or block.

Canonical chains can change

The most interesting part of an indexer is not inserting the happy path. It is deciding what to do when the stored tip is no longer canonical.

On every synchronization, the service compares the selected chain head with the stored tip:

stored:  A -- B -- C -- Dchain:   A -- B -- X -- Y                  ^            common ancestor

If the hashes differ, it walks backwards inside the retained window to find the nearest common ancestor. The divergent suffix is replaced:

remove C and Dinsert X and Yupdate synchronization state

If no common ancestor exists in the retained window, the service reloads the complete current window. This is intentionally conservative. With only 50 retained blocks, a full window refresh is simpler and safer than pretending an older ancestor is available.

An unchanged head does no block fetching. A normal chain extension fetches only the new suffix.

One PostgreSQL transaction

A canonical update can delete replaced blocks, insert new blocks and update the synchronization checkpoint. Those actions must succeed or fail together.

The PostgreSQL adapter therefore applies them in one transaction:

begin  acquire transaction-scoped advisory lock  delete replaced and expired blocks  validate retained parent  insert blocks, transactions and logs  update sync_statecommit

Reading the schema as a data contract

The relationship between blocks, transactions and logs is expressed in PostgreSQL rather than left for the Go service to remember:

CREATE TABLE blocks (	number bigint PRIMARY KEY CHECK (number >= 0),	-- other block fields); CREATE TABLE transactions (	hash bytea PRIMARY KEY,	block_number bigint NOT NULL		REFERENCES blocks(number)		ON DELETE CASCADE,	transaction_index integer NOT NULL,	UNIQUE (block_number, transaction_index)); CREATE TABLE event_logs (	block_number bigint NOT NULL		REFERENCES blocks(number)		ON DELETE CASCADE,	transaction_hash bytea NOT NULL		REFERENCES transactions(hash)		ON DELETE CASCADE,	log_index integer NOT NULL,	PRIMARY KEY (block_number, log_index));

This line carries several independent rules:

block_number bigint NOT NULL REFERENCES blocks(number) ON DELETE CASCADE
  • bigint stores the block number as a signed 64-bit integer. The separate CHECK (number >= 0) on blocks rules out values that are valid integers but invalid block heights.
  • NOT NULL says that every transaction must identify a block. NULL cannot be used to bypass the relationship.
  • REFERENCES blocks(number) creates a foreign key. A transaction's block_number must match an existing blocks.number value. PostgreSQL permits this reference because blocks.number is a primary key and therefore unique.
  • ON DELETE CASCADE defines what happens when the parent block is deleted: PostgreSQL deletes the dependent transaction rows too.

In plain language, the database contract is: a transaction cannot exist without its block, and a log cannot exist without its block and transaction.

What PostgreSQL does on insert

Suppose the service tries to store a transaction for block 21:

INSERT INTO transactions (hash, block_number, transaction_index)VALUES ('...', 21, 0);

PostgreSQL checks whether blocks.number = 21 exists. If it does not, the statement fails with a foreign-key violation. This prevents an RPC bug, partial write or incorrect insertion order from creating an orphan transaction.

The parent block is inserted first, followed by its transactions and logs. If any constraint fails, the canonical-update transaction does not commit. Readers therefore continue to see the previous complete chain view rather than a partially written replacement.

What PostgreSQL does during a reorg delete

Imagine that stored blocks 101 and 102 belong to an old fork. The application only needs to delete the old block suffix:

DELETE FROM blocks WHERE number >= 101;

For every deleted block, PostgreSQL follows the foreign-key relationships:

delete block 101  ├── delete its transactions  │     └── delete logs owned by those transactions  └── delete logs that reference block 101 delete block 102  └── repeat the same process

The cascade is not a background cleanup job and it is not a Go callback. It is enforced by PostgreSQL as part of the same statement and transaction. If the transaction rolls back, the parent and all cascaded child deletions roll back together.

This ownership model is why cascade is appropriate here. A transaction stored by this indexer has no meaning after its canonical block is removed. By contrast, I would not use cascade between independent business entities where deleting one should not silently destroy the other.

ON DELETE CASCADE is also different from DROP TABLE ... CASCADE. The former removes rows that depend on a deleted parent row. The latter removes database objects or constraints that depend on a dropped schema object.

The indexing detail that is easy to miss

PostgreSQL automatically indexes blocks.number because it is the primary key. A foreign key does not automatically create an index on the child column, even though parent deletion must locate matching children.

This schema already has useful child-side indexes through its composite keys:

transactions UNIQUE (block_number, transaction_index)event_logs  PRIMARY KEY (block_number, log_index)event_logs  INDEX (transaction_hash, log_index)

Because the referenced value is the first column in each index, PostgreSQL can efficiently find the dependent rows during a cascade. This matters when a reorganization replaces several blocks containing many transactions and logs.

Why I chose a PostgreSQL advisory lock

A database transaction makes one update atomic, but it does not by itself express the stronger application rule that only one indexer instance should replace the canonical chain at a time. Two instances could observe the same old tip, independently calculate different updates and then race to write them.

The store takes a transaction-level advisory lock before reading or changing canonical state:

const canonicalWriterAdvisoryLockID = int64(0x455448494e4458) // "ETHINDX" tx, err := pool.BeginTx(ctx, pgx.TxOptions{})if err != nil {	return err}defer func() { _ = tx.Rollback(ctx) }() _, err = tx.Exec(	ctx,	`SELECT pg_advisory_xact_lock($1)`,	canonicalWriterAdvisoryLockID,)if err != nil {	return err} // Delete the old suffix, insert the replacement and update sync_state.return tx.Commit(ctx)

An advisory lock does not lock a particular row or table. The application gives PostgreSQL an integer key—in this case a stable value representing the canonical Ethereum index—and PostgreSQL treats that key as an application-defined exclusive resource.

If instance A already holds this key, instance B waits at pg_advisory_xact_lock. It does not start rewriting the canonical tables. When A commits or rolls back, PostgreSQL automatically releases the lock and B can continue. A crashed process cannot leave this transaction-level lock permanently held: ending its database session aborts the transaction and releases the lock.

indexer A ── begin ── acquire ETHINDX ── replace chain ── commitindexer B ── begin ── waits............. acquire ETHINDX ── re-check stateAPI reader ───────────────────────────── reads committed data normally

The second writer must still re-read and validate database state after acquiring the lock. The lock orders writers; it does not make an earlier plan remain correct while that writer was waiting.

The word advisory is important. PostgreSQL records and coordinates the lock, but it does not know that the key represents the canonical chain. Every cooperating writer must explicitly acquire the same key. A script that directly modifies these tables without taking the lock is not automatically blocked by it. Foreign keys, checks and the transaction therefore remain essential correctness boundaries.

Why not use another locking option?

OptionWhy it was not the best fit here
Go sync.MutexIt coordinates goroutines in one process only. A second container or indexer process has a different mutex.
SELECT ... FOR UPDATERow locks are excellent when a known row represents the resource. On the first synchronization, however, sync_state has no row to lock. The logical canonical chain also spans several tables rather than one row.
LOCK TABLEIt is broader than the rule being protected. The indexer wants one canonical writer, not a general lock over every use of the table.
SERIALIZABLE isolationIt can detect dangerous concurrent transactions and abort one, but the application must recognize serialization failures and retry the entire update. An explicit logical lock communicates the single-writer rule more directly.
Unique constraints aloneThey reject particular duplicate keys, but only after writers have raced. They do not protect the multi-step delete, insert and checkpoint operation as one logical ownership decision.
Redis or another distributed lockIt adds another service and introduces lease-expiry and failure-ordering questions. The protected data already lives in PostgreSQL, which can bind the lock lifetime to the same transaction.

A session-level advisory lock was also unnecessary. Session locks remain held until explicitly unlocked or until the database session ends, even if a transaction rolls back. pg_advisory_xact_lock has the safer lifetime for this operation: it belongs to exactly one canonical-update transaction and cannot accidentally leak into a pooled connection's next use.

The advisory lock does not replace the transaction. They solve different problems:

advisory lock  → decides which cooperating writer goes firsttransaction    → makes that writer's complete update atomicconstraints    → reject structurally invalid data

Ordinary API reads do not acquire the advisory key, so the advisory lock itself does not block them. PostgreSQL's transaction visibility ensures readers see committed state rather than the writer's half-finished replacement.

Foreign keys use cascading deletes, so removing a block also removes its transactions and logs. Inserts are sent in bounded batches of at most 1,000 statements to avoid constructing an unbounded database batch.

A deliberately simple API

The HTTP layer uses Chi and exposes only the reads supported by the stored model:

GET /healthzGET /readyzGET /v1/blocks/{number}GET /v1/transactions/{hash}GET /v1/addresses/{address}/events

Event queries are ordered by canonical position and use cursor pagination. The event endpoint indexes the emitting contract address; it does not attempt to interpret addresses encoded inside topics or data.

The server adds request IDs, panic recovery and request deadlines. Readiness checks PostgreSQL, while liveness avoids external dependencies.

What I kept intentionally out

The first version does not include:

  • Historical indexing beyond the retained window.
  • Transaction receipts or execution status.
  • Internal calls and execution traces.
  • ABI decoding.
  • Prometheus, OpenTelemetry, Redis or Kubernetes.

These are useful features, but adding all of them immediately would hide the core lesson: an indexer must maintain a correct canonical view while RPC calls, database operations and chain history can all change or fail.

What I learned

The project reinforced a few principles:

  1. Bound the expensive resource. Cheap goroutines do not make Ethereum RPC capacity unlimited.
  2. Validate relationships, not only responses. Block numbers, hashes, parent hashes, transaction positions and log ownership must agree.
  3. Make canonical replacement atomic. Readers should see either the old chain view or the new one, never half of each.
  4. Design reorganization handling early. Reorg recovery is part of normal indexing, not an optional error path.
  5. Start with a narrow product boundary. A recent-window indexer can still exercise concurrency, persistence, API design and graceful shutdown without becoming an explorer.

The repository is available at github.com/bhakiyakalimuthu/ethindexer. The initial commit was created on 24 August 2026.