Build EVM blockchain data indexers using Ponder (ponder.sh) - an open-source TypeScript framework for indexing smart contract events, transactions, and traces into custom database schemas with type-safe APIs. Use when the user mentions ponder, blockchain/EVM indexing, onchain data pipelines, subgraph replacement, or wants to index smart contract events into a queryable database.
98
99%
Does it follow best practices?
Impact
98%
1.25xAverage score across 5 eval scenarios
Passed
No known issues
Ponder is an open-source TypeScript framework that indexes EVM blockchain data into Postgres with type-safe APIs. It replaces subgraphs with 10-15x faster indexing, hot reloading, and zero-codegen type safety.
Architecture flow:
ponder.config.ts (what to index) -> ponder.schema.ts (where to store) -> src/*.ts (how to transform) -> src/api/index.ts (how to query)
Stack: v0.16.x, Drizzle ORM, Hono for HTTP, viem for Ethereum.
pnpm create ponder # also: npm, yarn, bun{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true
}
}Must exist at project root. Run ponder codegen to generate it.
PONDER_RPC_URL_<CHAIN_ID> - RPC endpoint per chain (e.g., PONDER_RPC_URL_1 for mainnet)DATABASE_URL - Postgres connection string (production)DATABASE_SCHEMA - Schema isolation name (production)ponder.config.ts:
import { createConfig } from "ponder";
import { http } from "viem";
import { ERC20Abi } from "./abis/ERC20Abi";
export default createConfig({
chains: {
mainnet: { id: 1, rpc: process.env.PONDER_RPC_URL_1 },
},
contracts: {
USDC: {
abi: ERC20Abi,
chain: "mainnet",
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
startBlock: 6082465,
},
},
});ponder.schema.ts:
import { onchainTable } from "ponder";
export const transfers = onchainTable("transfers", (t) => ({
id: t.text().primaryKey(),
from: t.hex().notNull(),
to: t.hex().notNull(),
amount: t.bigint().notNull(),
timestamp: t.integer().notNull(),
}));src/index.ts:
import { ponder } from "ponder:registry";
import { transfers } from "ponder:schema";
ponder.on("USDC:Transfer", async ({ event, context }) => {
await context.db.insert(transfers).values({
id: event.id,
from: event.args.from,
to: event.args.to,
amount: event.args.value,
timestamp: Number(event.block.timestamp),
});
});src/api/index.ts:
import { Hono } from "hono";
import { db } from "ponder:api";
import { graphql } from "ponder";
import * as schema from "ponder:schema";
const app = new Hono();
app.use("/graphql", graphql({ db, schema }));
export default app;| Command | Purpose |
|---|---|
ponder dev | Development with hot reload, PGlite, port 42069 |
ponder start --schema <name> | Production with Postgres |
ponder serve --schema <name> | API only (no indexing), for horizontal scaling |
ponder codegen | Regenerate types and ponder-env.d.ts |
ponder db list | List schemas in database |
ponder db prune | Remove unused schemas |
Exit codes: 75 = retryable (safe to auto-restart), 1 = fatal (check logs).
| Mode | When to Use |
|---|---|
multichain (default) | Per-chain ordering, low latency. Most projects. |
omnichain | Cross-chain consistency needed (bridges, aggregators, cross-chain protocols). |
experimental_isolated | Max performance. Requires chainId in ALL table primary keys. |
onchainTable): Data written by indexing functions. Source of truth.onchainView): Aggregations/rollups computed at query time. No writes, no store API.context.db): Always prefer in indexing functions. 100-1000x faster (batched writes).db.sql): Only for complex multi-table updates that Store API cannot express.factory(): Contract instances created dynamically at runtime (Uniswap pools, token clones).address: Known addresses at config time. Use array for multiple fixed addresses.includeCallTraces: true): Only when you need function inputs/outputs not emitted as events. Requires debug_traceBlockByNumber RPC support.Only enable when you need gasUsed, logs from other contracts, or tx status. Adds significant RPC overhead.
ponder dev uses it automatically).as const assertion for type inference:
export const MyAbi = [...] as const; // REQUIREDponder-env.d.ts must exist and be current. Run ponder codegen after config changes.startBlock to the contract deployment block, NOT 0. Setting 0 scans entire chain history (hours vs seconds).disableCache: true in chain config.snake_case, max 45 characters.${owner}-${spender}).
USE: primaryKey({ columns: [table.owner, table.spender] }).t.hex() for addresses and byte data, t.bigint() for uint256/int256.ponder:registry, ponder:schema, ponder:api. NOT regular file paths.context.db (indexing functions, read-write) vs db from ponder:api (API routes, read-only).db from ponder:api is read-only.onConflictDoUpdate callback receives the EXISTING row, not the values you passed to .values().context.client.readContract automatically scopes reads to the current event's block number.event.log.address to get which child contract emitted the event.context.chain.id to distinguish chains.event.id is a 75-digit string, globally unique across chains.ponder start requires --schema flag (or DATABASE_SCHEMA env var).experimental_isolated ordering requires chainId in ALL table primary keys.Load the appropriate reference based on the task:
| Task | Reference |
|---|---|
| Configure chains, contracts, factory, accounts, blocks | references/config.md |
| Define tables, columns, relations, views, enums | references/schema.md |
| Write event handlers, DB operations, contract reads | references/indexing.md |
| Set up GraphQL, SQL over HTTP, custom API routes | references/api.md |
| Build frontend with @ponder/client, React, Next.js, tRPC | references/frontend.md |
| Deploy, scale, debug, monitor, configure RPC | references/production.md |
| Need a complete working example to start from | references/recipes.md |
pnpm create ponder and select template.env.local as PONDER_RPC_URL_<CHAIN_ID>ponder.config.ts with contract address, ABI, and startBlockponder.schema.tssrc/index.ts, then run ponder devas const assertionponder.config.ts (abi, chain, address, startBlock)ponder.schema.ts for the data you want to storesrc/ using ponder.on("ContractName:EventName", ...)parseAbiItem from viem to define the creation eventfactory() in config: address: factory({ address, event, parameter })event.log.address to identify the child contractsrc/api/index.tsdb from ponder:api and schema from ponder:schemaapp.get("/path", ...)) with Drizzle queries on db.sqlponder.config.tschainId if using experimental_isolatedcontext.chain.id in handlers when chain-specific logic is neededDATABASE_URL and choose a DATABASE_SCHEMA nameponder start --schema <name>/health (always 200), /ready (503 during backfill, 200 when caught up)--views-schema pattern