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
import { onchainTable } from "ponder";
export const myTable = onchainTable("my_table", (t) => ({
id: t.text().primaryKey(),
// ... columns
}));
// With constraints (composite PK, indexes):
export const myTable = onchainTable(
"my_table",
(t) => ({
chainId: t.integer().notNull(),
address: t.hex().notNull(),
balance: t.bigint().notNull(),
}),
(table) => ({
pk: primaryKey({ columns: [table.chainId, table.address] }),
addressIdx: index("address_idx").on(table.address),
})
);Rules:
snake_case, max 45 characters.primaryKey() or composite primaryKey())primaryKey and index from ponder for constraints| Type | TypeScript Type | SQL Type | Use For |
|---|---|---|---|
t.text() | string | TEXT | Strings, identifiers |
t.hex() | `0x${string}` | TEXT (hex-encoded) | Addresses, bytes, tx hashes |
t.bigint() | bigint | NUMERIC(78) | uint256, int256, token amounts |
t.integer() | number | INTEGER (4-byte) | Small numbers, chain IDs, timestamps |
t.real() | number | REAL (float) | Floating point values |
t.boolean() | boolean | BOOLEAN | Flags |
t.timestamp() | Date | TIMESTAMP | Dates/times |
t.json() | unknown | JSONB | Arbitrary JSON data |
Key choices:
t.hex() (lowercase, 0x-prefixed)t.bigint()t.integer() (unix seconds) or t.timestamp() if you want Date objectst.integer()t.text().primaryKey() // Primary key
t.hex().notNull() // NOT NULL constraint
t.text().array() // Array column (TEXT[])
t.integer().default(0) // Default value (static)
t.bigint().$default(() => 0n) // Default value (dynamic function)
t.json().$type<MyType>() // Override TypeScript typeimport { onchainEnum, onchainTable } from "ponder";
export const transferType = onchainEnum("transfer_type", [
"mint",
"burn",
"transfer",
]);
export const transfers = onchainTable("transfers", (t) => ({
id: t.text().primaryKey(),
type: transferType().notNull(),
// ...
}));Use when a single column isn't sufficient to uniquely identify a row:
import { onchainTable, primaryKey } from "ponder";
export const balances = onchainTable(
"balances",
(t) => ({
chainId: t.integer().notNull(),
account: t.hex().notNull(),
token: t.hex().notNull(),
balance: t.bigint().notNull(),
}),
(table) => ({
pk: primaryKey({ columns: [table.chainId, table.account, table.token] }),
})
);When to use composite PKs:
experimental_isolated (must include chainId)Add indexes on columns used in GraphQL filters or API WHERE clauses:
import { onchainTable, index } 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(),
}),
(table) => ({
fromIdx: index("from_idx").on(table.from),
toIdx: index("to_idx").on(table.to),
// Multi-column index:
timestampFromIdx: index("timestamp_from_idx").on(
table.timestamp,
table.from
),
})
);Define relationships between tables for GraphQL joins and relational queries:
import { onchainTable, relations } from "ponder";
export const accounts = onchainTable("accounts", (t) => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
}));
export const transfers = onchainTable("transfers", (t) => ({
id: t.text().primaryKey(),
from: t.hex().notNull(),
to: t.hex().notNull(),
amount: t.bigint().notNull(),
}));
// one() = belongs-to, many() = has-many
export const accountRelations = relations(accounts, ({ many }) => ({
sentTransfers: many(transfers, { relationName: "sender" }),
receivedTransfers: many(transfers, { relationName: "receiver" }),
}));
export const transferRelations = relations(transfers, ({ one }) => ({
sender: one(accounts, {
fields: [transfers.from],
references: [accounts.address],
relationName: "sender",
}),
receiver: one(accounts, {
fields: [transfers.to],
references: [accounts.address],
relationName: "receiver",
}),
}));relationName: Required when multiple relations reference the same table (like sender and receiver both pointing to accounts).
Use a join table:
export const tokenHolders = onchainTable(
"token_holders",
(t) => ({
token: t.hex().notNull(),
holder: t.hex().notNull(),
balance: t.bigint().notNull(),
}),
(table) => ({
pk: primaryKey({ columns: [table.token, table.holder] }),
})
);Views compute derived data at query time. They are not written to by indexing functions.
import { onchainTable, onchainView } from "ponder";
import { count, sum, desc, sql } from "ponder/drizzle";
export const transfers = onchainTable("transfers", (t) => ({
id: t.text().primaryKey(),
from: t.hex().notNull(),
to: t.hex().notNull(),
amount: t.bigint().notNull(),
}));
export const transferStats = onchainView("transfer_stats", (t) => ({
address: t.hex().primaryKey(),
totalSent: t.bigint(),
sendCount: t.integer(),
}));
// Define the view query in the schema file:
// The view is populated by Ponder at query time.Limitations of views:
// Operators (for API queries and views)
import { eq, ne, gt, gte, lt, lte, and, or, not, inArray } from "ponder/drizzle";
// Aggregations
import { count, sum, avg, min, max } from "ponder/drizzle";
// Sorting
import { desc, asc } from "ponder/drizzle";
// Raw SQL expressions
import { sql } from "ponder/drizzle";Complete schema for tracking an ERC-20 token:
import { onchainTable, onchainView, primaryKey, index, relations } from "ponder";
export const accounts = onchainTable("accounts", (t) => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
isHolder: t.boolean().notNull(),
lastUpdatedBlock: t.integer().notNull(),
}));
export const transfers = onchainTable(
"transfers",
(t) => ({
id: t.text().primaryKey(),
from: t.hex().notNull(),
to: t.hex().notNull(),
amount: t.bigint().notNull(),
blockNumber: t.integer().notNull(),
timestamp: t.integer().notNull(),
}),
(table) => ({
fromIdx: index("transfers_from_idx").on(table.from),
toIdx: index("transfers_to_idx").on(table.to),
timestampIdx: index("transfers_timestamp_idx").on(table.timestamp),
})
);
export const approvals = onchainTable(
"approvals",
(t) => ({
owner: t.hex().notNull(),
spender: t.hex().notNull(),
amount: t.bigint().notNull(),
}),
(table) => ({
pk: primaryKey({ columns: [table.owner, table.spender] }),
})
);
export const accountRelations = relations(accounts, ({ many }) => ({
sentTransfers: many(transfers, { relationName: "sender" }),
receivedTransfers: many(transfers, { relationName: "receiver" }),
}));
export const transferRelations = relations(transfers, ({ one }) => ({
sender: one(accounts, {
fields: [transfers.from],
references: [accounts.address],
relationName: "sender",
}),
receiver: one(accounts, {
fields: [transfers.to],
references: [accounts.address],
relationName: "receiver",
}),
}));