Tables
Ponder's schema definition API is built on Drizzle, a modern TypeScript ORM. To define a table, use the onchainTable
function and include column definitions.
import { onchainTable } from "ponder";
export const pets = onchainTable("pets", (t) => ({
name: t.text().primaryKey(),
age: t.integer().notNull(),
}));
Each table must be a named export from the ponder.schema.ts
file. The build step ignores tables that are not exported.
Column types
The schema definition API supports most PostgreSQL data types – here's a quick reference for the most common options. Read the Drizzle documentation for a complete list.
name | description | TypeScript type | SQL data type |
---|---|---|---|
text | UTF‐8 character sequence | string | TEXT |
integer | Signed 4‐byte integer | number | INTEGER |
real | Signed 4-byte floating‐point value | number | REAL |
boolean | true or false | boolean | BOOLEAN |
timestamp | Date and time value (no time zone) | Date | TIMESTAMP |
json | JSON object | any or custom | JSON |
Ponder also includes a few extra column types built specifically for EVM indexing.
name | description | TypeScript type | SQL data type |
---|---|---|---|
bigint | Large integer (holds uint256 and int256 ) | bigint | NUMERIC(78,0) |
hex | UTF‐8 character sequence with 0x prefix | 0x${string} | TEXT |
bigint
Use the bigint
column type to store EVM uint256
or int256
values.
import { onchainTable } from "ponder";
export const accounts = onchainTable("accounts", (t) => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
}));
hex
Use the hex
column type to store EVM address
, bytes
, or any other hex-encoded value.
import { onchainTable } from "ponder";
export const accounts = onchainTable("accounts", (t) => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
}));
Enums
To define an enum, use the onchainEnum
function. Then, use the value returned by onchainEnum
as a column type. Under the hood, onchainEnum
creates a PostgreSQL enumerated type.
import { onchainEnum, onchainTable } from "ponder";
export const color = onchainEnum("color", ["ORANGE", "BLACK"]);
export const cats = onchainTable("cats", (t) => ({
name: t.text().primaryKey(),
color: color("color"),
}));
Arrays
To define an array column, use the .array()
modifier. Arrays are a good fit for small one-dimensional collections. Don't use arrays for relationships between records.
import { onchainTable } from "ponder";
export const cats = onchainTable("cats", (t) => ({
name: t.text().primaryKey(),
vaccinations: t.text().array(), // ["rabies", "distemper", "parvo"] //
}));
Not null
To mark a column as not null, use the .notNull()
modifier. If you attempt to insert a row that does not include a value for a NOT NULL
column, the database will throw an error.
import { onchainTable } from "ponder";
export const cats = onchainTable("cats", (t) => ({
name: t.text().primaryKey(),
age: t.integer().notNull(),
}));
Default value
To set a default value for a column, use the .default()
modifier and pass a string, number, boolean, or null
.
import { onchainTable } from "ponder";
export const cats = onchainTable("cats", (t) => ({
name: t.text().primaryKey(),
livesRemaining: t.integer().default(9),
}));
Alternatively, use the .$default()
modifier to specify a JavaScript function that returns the default value. With this approach, the database driver calls the function to generate a default value for each row dynamically.
import { onchainTable } from "ponder";
import { generateId } from "../utils";
export const cats = onchainTable("cats", (t) => ({
name: t.text().primaryKey(),
age: t.integer().$default(() => generateId()),
}));
Primary key
Every table must have a primary key. To define a primary key on a single column, use the .primaryKey()
modifier.
import { onchainTable } from "ponder";
export const tokens = onchainTable("tokens", (t) => ({
id: t.bigint().primaryKey(),
}));
Composite primary key
To create a composite primary key, use the primaryKey()
function exported by ponder
. Each column that forms the primary key must be not null. Read more about composite primary keys.
import { onchainTable, primaryKey } from "ponder";
export const poolStates = onchainTable(
"pool_states",
(t) => ({
poolId: t.bigint().notNull(),
address: t.hex().notNull(),
balance: t.bigint().notNull(),
}),
(table) => ({
pk: primaryKey({ columns: [table.poolId, table.address] }),
})
);
Indexes
To create a database index, use the index()
function. This example defines B-tree indexes on the persons.name
column to support search queries, and on the dogs.ownerId
column to support the persons.dogs
relational query.
import { onchainTable, relations, index } from "ponder";
export const persons = onchainTable(
"persons",
(t) => ({
id: t.text().primaryKey(),
name: t.text(),
}),
(table) => ({
nameIdx: index().on(table.name),
})
);
export const personsRelations = relations(persons, ({ many }) => ({
dogs: many(dogs),
}));
export const dogs = onchainTable(
"dogs",
(t) => ({
id: t.text().primaryKey(),
ownerId: t.text().notNull(),
}),
(table) => ({
ownerIdx: index().on(table.ownerId),
})
);
export const dogsRelations = relations(dogs, ({ one }) => ({
owner: one(persons, { fields: [dogs.ownerId], references: [persons.id] }),
}));
The index()
function supports specifying multiple columns, ordering, and custom index types like GIN and GIST. Read more in the Drizzle and PostgreSQL documention.
Best practices
Primary keys
Select a primary key that matches the access pattern of your indexing logic. If a table has two or more columns that together form a unique identifier for a row, use a composite primary key – don't use a concatenated string.
import { onchainTable, primaryKey } from "ponder";
// ❌ Don't concatenate strings to form a primary key
export const allowances = onchainTable("allowances", (t) => ({
id: t.string().primaryKey(), // `${owner}_${spender}`
owner: t.hex(),
spender: t.hex(),
amount: t.bigint(),
}));
// ✅ Use a composite primary key
export const allowances = onchainTable(
"allowances",
(t) => ({
owner: t.hex(),
spender: t.hex(),
amount: t.bigint(),
}),
(table) => ({ pk: primaryKey({ columns: [table.owner, table.spender] }) })
);
Timestamps
Use the bigint
column type to store block timestamps using their EVM-native Unix timestamp representation. This maintains consistency with Viem's approach, and avoids error-prone timezone manipulation code.
import { onchainTable } from "ponder";
export const events = onchainTable("events", (t) => ({
id: t.text().primaryKey(),
timestamp: t.bigint(), // Unix timestamp in seconds
}));
If you strongly prefer working with JavaScript Date
objects, you can also use the timestamp
column type, but we recommend doing this conversion in the view layer.
import { onchainTable } from "ponder";
export const events = onchainTable("events", (t) => ({
id: t.text().primaryKey(),
timestamp: t.timestamp(), // JavaScript Date object
}));
Custom types
Use the .$type()
modifier to customize the TypeScript type for a column. Note that the .$type()
modifier does not validate data at runtime or in the database, it only enforces a TypeScript type.
import { onchainTable } from "ponder";
export const tokens = onchainTable("tokens", (t) => ({
id: t.bigint().primaryKey(),
metadata: t.json().$type<{ name: string; symbol: string; decimals: number }>(),
}));
camelCase
vs snake_case
Use camelCase
for TypeScript names and snake_case
for SQL names. This guideline applies to all database objects and properties, including tables, columns, relations, and indexes.
import { onchainTable } from "ponder";
export const registrationEvents = onchainTable(
"registration_events", // Use snake_case for the SQL table name
(t) => ({
createdAt: t.bigint(), // Drizzle automatically converts this to `created_at`
invitedBy: t.text("invited_by"), // Avoid manual case conversion for columns
// ...
})
);
Examples
ERC20
Here's a schema for a simple ERC20 app.
import { index, onchainTable, primaryKey } from "ponder";
export const account = onchainTable("account", (t) => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
isOwner: t.boolean().notNull(),
}));
export const allowance = onchainTable(
"allowance",
(t) => ({
owner: t.hex(),
spender: t.hex(),
amount: t.bigint().notNull(),
}),
(table) => ({
pk: primaryKey({ columns: [table.owner, table.spender] }),
})
);
export const transferEvent = onchainTable(
"transfer_event",
(t) => ({
id: t.text().primaryKey(),
amount: t.bigint().notNull(),
timestamp: t.integer().notNull(),
from: t.hex().notNull(),
to: t.hex().notNull(),
}),
(table) => ({
fromIdx: index().on(table.from),
})
);
export const approvalEvent = onchainTable("approval_event", (t) => ({
id: t.text().primaryKey(),
amount: t.bigint().notNull(),
timestamp: t.integer().notNull(),
owner: t.hex().notNull(),
spender: t.hex().notNull(),
}));