Design your schema
Ponder's schema definition API is built on Drizzle, a modern TypeScript ORM.
To create a table, use the onchainTable
function exported by @ponder/core
and include column definitions.
import { onchainTable } from "@ponder/core";
export const pets = onchainTable("pets", (t) => ({
name: t.text().primaryKey(),
age: t.integer().notNull(),
}));
Remember to export table objects using export const
. Ponder will ignore
tables that are not exported.
Columns
Column types
The schema definition API supports most PostgreSQL data types. Here's a quick reference for the most commonly used data types. For a complete list, see the Drizzle documentation.
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
The bigint
column type can hold any EVM uint256
or int256
value.
import { onchainTable } from "@ponder/core";
export const accounts = onchainTable("accounts", (t) => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
}));
Ponder's bigint
type takes precedence over the Drizzle
bigint
type, which
is an 8-byte integer (too small for EVM uint256
or int256
values). To
create an 8-byte integer column, use the int8
alias.
hex
The hex
column type is useful for EVM address
, bytes
, or any other hex-encoded value.
import { onchainTable } from "@ponder/core";
export const accounts = onchainTable("accounts", (t) => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
}));
Enums
To define an enum, use the onchainEnum
function exported by @ponder/core
. Then, use the value returned by onchainEnum
as a column type. Under the hood, onchainEnum
uses a PostgreSQL enumerated type.
import { onchainEnum, onchainTable } from "@ponder/core";
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, not relationships between records.
import { onchainTable } from "@ponder/core";
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/core";
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/core";
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 before inserting a row into this table that does not include a value for this column.
import { onchainTable } from "@ponder/core";
import { generateId } from "../utils";
export const cats = onchainTable("cats", (t) => ({
name: t.text().primaryKey(),
age: t.integer().$default(() => generateId()),
}));
Relationships
Ponder uses Drizzle Relations to define relationships between tables. Here are some examples of how to define one-to-one, one-to-many, and many-to-many relationships. For more information, see the Drizzle Relations documentation.
Relations are useful to connect tables in the Query API (findMany
and
findFirst
) and the GraphQL API. However, relations do not create foreign
key constraints and won't stop you from inserting rows that violate
referential integrity.
One-to-one
Use the relations
function exported by @ponder/core
to define the relationships for a table.
To define a one-to-one relationship, use the one()
operator and specify which columns relate the two tables. In this example, each user has a profile and each profile belongs to one user.
import { onchainTable, relations } from "@ponder/core";
export const users = onchainTable("users", (t) => ({
id: t.text().primaryKey(),
}));
export const usersRelations = relations(users, ({ one }) => ({
profile: one(profiles, { fields: [users.id], references: [profiles.userId] }),
}));
export const profiles = onchainTable("profiles", (t) => ({
id: t.text().primaryKey(),
userId: t.text().notNull(),
age: t.integer().notNull(),
}));
Now that you've defined the relationship, the profile
field will become available in the Query API (findMany
and findFirst
) using the with
option.
import { users, profiles } from "../ponder.schema";
await db.insert(users).values({ id: "hunter42" });
await db.insert(profiles).values({ userId: "hunter42", age: 29 });
const user = await db.sql.query.users.findFirst({
where: eq(users.id, "hunter42"),
with: { profile: true },
});
console.log(user.profile.age);
// ^? { id: string; profile: { id: string; userId: string; age: number } }
One-to-many
To define a one-to-many relationship, use the one()
and many()
operators to define both sides of the relationship. In this example, each dog has one owner and each person can own many dogs.
import { onchainTable, relations } from "@ponder/core";
export const persons = onchainTable("persons", (t) => ({
name: t.text().primaryKey(),
}));
export const personsRelations = relations(persons, ({ many }) => ({
dogs: many(dogs),
}));
export const dogs = onchainTable("dogs", (t) => ({
petId: t.text().primaryKey(),
ownerName: t.text().notNull(),
}));
export const dogsRelations = relations(dogs, ({ one }) => ({
owner: one(persons, { fields: [dogs.ownerName], references: [persons.name] }),
}));
Now, any row inserted into the dogs
table with ownerName: "Bob"
will become available in Bob's dogs
field.
import { persons, dogs } from "../ponder.schema";
await db.insert(persons).values({ name: "Bob" });
await db.insert(dogs).values([
{ petId: "Chip", ownerName: "Bob" },
{ petId: "Spike", ownerName: "Bob" },
]);
const bob = await db.sql.query.persons.findFirst({
where: eq(persons.id, "Bob"),
with: { dogs: true },
});
console.log(bob.dogs);
// ^? { name: string; dogs: { petId: string; age: number }[] }
Note that in a one-to-many relationship, you cannot directly set the value of
the many
field. Instead, you must insert or update the related rows
individually.
Many-to-many
To define a many-to-many relationship, create a "join table" that relates the two tables you want to connect using two one-to-many relationships.
import { onchainTable, relations } from "@ponder/core";
export const users = onchainTable("users", (t) => ({
id: t.text().primaryKey(),
}));
export const usersRelations = relations(users, ({ many }) => ({
userTeams: many(userTeams),
}));
export const teams = onchainTable("teams", (t) => ({
id: t.text().primaryKey(),
mascot: t.text().notNull(),
}));
export const teamsRelations = relations(teams, ({ many }) => ({
userTeams: many(userTeams),
}));
export const userTeams = onchainTable(
"user_teams",
(t) => ({
userId: t.text().notNull(),
teamId: t.text().notNull(),
}),
// A composite primary key is often a good choice for a join table.
(table) => ({ pk: primaryKey({ columns: [table.userId, table.teamId] }) })
);
export const userTeamsRelations = relations(userTeams, ({ one }) => ({
user: one(users, { fields: [userTeams.userId], references: [users.id] }),
team: one(teams, { fields: [userTeams.teamId], references: [teams.id] }),
}));
Now, any row inserted into the userTeams
table will create a relationship between the user
and team
records. You can query for the relationship by nesting the with
option in the Query API.
import { users, teams, userTeams } from "../ponder.schema";
await db.insert(users).values([
{ id: "ron" }, { id: "harry" }, { id: "hermione" }
]);
await db.insert(teams).values([
{ id: "muggle", mascot: "dudley" },
{ id: "wizard", mascot: "hagrid" },
]);
await db.insert(userTeams).values([
{ userId: "ron", teamId: "wizard" },
{ userId: "harry", teamId: "wizard" },
{ userId: "hermione", teamId: "muggle" },
{ userId: "hermione", teamId: "wizard" },
]);
const hermione = await db.sql.query.users.findFirst({
where: eq(users.id, "hermione"),
with: { userTeams: { with: { team: true } } },
});
console.log(hermione.userTeams);
// ^? {
// id: string;
// userTeams: {
// userId: string;
// teamId: string;
// team: {
// id: string;
// mascot: string
// }
// }[]
// }
Relationships in GraphQL
Every relationship you define in ponder.schema.ts
will automatically become available in the GraphQL API, with one
relations creating singular fields and many
relations creating plural/connection fields.
The one-to-many example above corresponds to the following GraphQL query and result.
query {
person(id: "Bob") {
id
dogs {
id
}
}
}
{
"person": {
"id": "Bob",
"dogs": [
{ "id": "Chip" },
{ "id": "Spike" }
]
}
}
Tables
Primary key
Every table must have a primary key. To create a single-column primary key, use the .primaryKey()
modifier.
import { onchainTable } from "@ponder/core";
export const tokens = onchainTable("tokens", (t) => ({
id: t.bigint().primaryKey(),
}));
To create a composite primary key, use the primaryKey()
function exported by @ponder/core
. All components of the primary key constraint must be unique and not null.
import { onchainTable, primaryKey } from "@ponder/core";
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 exported by @ponder/core
. The following example creates an index on the persons.name
column to speed up search queries, and an index on the dogs.ownerId
column to speed up the persons.dogs
relational query.
import { onchainTable, relations, index } from "@ponder/core";
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.
To improve performance, database indexes are created after historical indexing is complete, just before the app becomes healthy.
Best practices
Composite primary keys
If a table has two or more columns that together form a unique identifier for a row, use a composite primary key.
Consider an allowances
table storing ERC20 token allowances. Each row in this table represents the allowance granted by one owner to one spender.
import { onchainTable, primaryKey } from "@ponder/core";
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/core";
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/core";
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/core";
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/core";
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/core";
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(),
}));