Indexes
Drizzle-style indexes and constraints, enforced at runtime in the ORM
In this guide, we'll learn how to define indexes and constraints for your tables. You'll master default values, not-null and unique constraints, foreign keys with cascading actions, check constraints, and all three index types -- standard, search, and vector.
The ORM mirrors Drizzle's schema API where it makes sense, but enforces constraints at runtime (in ORM mutations), not inside a SQL database.
Important: Constraints are enforced only by ORM mutations (insert/update/delete). Direct ctx.db writes bypass them.
Constraints
Default
Use .default(value) to define a default for inserts. Defaults are applied by ORM inserts when the value is undefined. Explicit null is preserved.
Here's how to set a default view count:
import { convexTable, integer, text } from 'better-convex/orm';
export const posts = convexTable('posts', {
title: text().notNull(),
viewCount: integer().default(0),
});Not Null
Fields are nullable by default. Use .notNull() to require a value.
import { convexTable, text } from 'better-convex/orm';
export const users = convexTable('users', {
email: text().notNull(),
bio: text(),
});Unique
You can model uniqueness in a few Drizzle-style ways.
uniqueIndex()
Let's define a unique index on an email field:
import { convexTable, text, uniqueIndex } from 'better-convex/orm';
export const users = convexTable(
'users',
{
email: text().notNull(),
},
(t) => [uniqueIndex('users_email_unique').on(t.email)]
);unique()
For compound uniqueness, use unique('name').on(...) with multiple columns:
import { convexTable, text, unique } from 'better-convex/orm';
export const users = convexTable(
'users',
{
firstName: text().notNull(),
lastName: text().notNull(),
},
(t) => [unique('full_name').on(t.firstName, t.lastName)]
);You can use .nullsNotDistinct() for PostgreSQL parity when you want null to be treated as a value.
Important: Uniqueness is enforced at runtime and is best-effort within a mutation. Concurrent mutations can still race. For strict guarantees, serialize writes per key or use onConflictDoNothing() / retries.
Column .unique()
For single-column uniqueness, you can call .unique() directly on the column builder:
import { convexTable, text } from 'better-convex/orm';
export const users = convexTable('users', {
email: text().notNull().unique(),
});Foreign Keys
Foreign keys are enforced at runtime by the ORM mutation builders.
Column .references()
Here's how to reference another table's id:
import { convexTable, id, text } from 'better-convex/orm';
export const users = convexTable('users', {
email: text().notNull(),
});
export const posts = convexTable('posts', {
title: text().notNull(),
authorId: id('users').notNull().references(() => users.id),
});foreignKey()
For referencing non-id columns, use the foreignKey() builder:
import { convexTable, foreignKey, text } from 'better-convex/orm';
export const users = convexTable('users', {
slug: text().notNull().unique(),
});
export const memberships = convexTable(
'memberships',
{
userSlug: text().notNull(),
},
(t) => [
foreignKey({ columns: [t.userSlug], foreignColumns: [users.slug] }),
]
);Actions (onDelete / onUpdate)
The ORM supports Drizzle-style foreign key actions, enforced at runtime by ORM mutations.
Here's how to cascade deletes when a user is removed:
export const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users').notNull().references(() => users.id, {
onDelete: 'cascade',
}),
},
(t) => [index('by_author').on(t.authorId)]
);Tip: For non-id foreign keys, add an index on the referenced columns (parent table) so inserts/updates can validate quickly. For cascade / set null / set default, add an index on the referencing columns (child table). Without an index, the ORM will throw if it detects referencing rows.
Check Constraints
Use check(name, expression) to enforce invariants on inserts and updates.
Let's add age and email checks to a users table:
import { check, convexTable, gt, integer, isNotNull, text } from 'better-convex/orm';
export const users = convexTable(
'users',
{
age: integer(),
email: text(),
},
(t) => [
check('age_over_18', gt(t.age, 18)),
check('email_present', isNotNull(t.email)),
]
);Note: Checks follow SQL-like NULL semantics: they fail only when the expression evaluates to false. If it evaluates to unknown (because of null/undefined), it passes. Use .notNull() or isNotNull(...) for strict presence.
Indexes
index()
Add indexes for fields you filter or sort by.
import { convexTable, id, index, text } from 'better-convex/orm';
export const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users').notNull(),
},
(t) => [index('by_author').on(t.authorId)]
);searchIndex()
Search indexes mirror Convex's text search, with a Drizzle-style builder API.
Here's how to add a full-text search index on title, filtered by authorId:
import { convexTable, id, searchIndex, text } from 'better-convex/orm';
export const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users').notNull(),
},
(t) => [searchIndex('by_title').on(t.title).filter(t.authorId)]
);Tip: Use .staged() to stage an index for later activation.
vectorIndex()
Vector indexes mirror Convex's vector search API.
Let's define a vector index for embedding search filtered by author:
import { convexTable, id, vector, vectorIndex } from 'better-convex/orm';
export const posts = convexTable(
'posts',
{
authorId: id('users').notNull(),
embedding: vector(1536).notNull(),
},
(t) => [
vectorIndex('embedding_vec')
.on(t.embedding)
.dimensions(1536)
.filter(t.authorId),
]
);Drizzle Differences
A few things work differently from SQL-based Drizzle:
- Convex creates
idautomatically (noprimaryKey()builder) - Check constraints are enforced at runtime (ORM mutations only)
- SQL-only features (database-side cascades, triggers, raw SQL) are not applicable
You now know how to keep your data consistent with constraints and make queries fast with indexes. From unique fields to cascading deletes and vector search, the ORM gives you Drizzle's ergonomic API backed by Convex's runtime.