Skip to main content
The schema is your app’s data contract. It’s a single TypeScript file (convex/schema.ts) that defines every table, every field, and every index. Convex enforces it at the database level — writes that don’t match the schema are rejected. vly generates and updates the schema automatically as you add features. You can also edit it by hand for fine-grained control.

The basics

convex/schema.ts
That’s the whole file for a simple two-table app. defineSchema takes a map from table name to defineTable call. Each defineTable defines the fields.

Field validators

Use the v validator namespace to declare each field’s type: A more realistic example with all of these:
convex/schema.ts

Indexes

Indexes make queries fast. Add them with .index() on the table:
convex/schema.ts
A few rules of thumb:

Index every field you filter or sort by

.withIndex() queries are O(log n). Without an index, queries fall back to .filter() which is O(n) — slow on large tables.

Compound indexes follow query order

["ownerId", "completed"] accelerates queries that filter by ownerId (or ownerId + completed). It does not accelerate filtering by completed alone.

Index naming convention

by_<field> for single-column. by_<a>_and_<b> for compound. Consistency helps query authors find the right index fast.

Don't over-index

Each index slows down writes slightly. Add them when a query is slow, not preemptively.

Search indexes

For full-text search, add a search index:
Then query with .withSearchIndex(). See Full-text search. For semantic / vector search, add a vector index:
See Vector search.

Built-in fields

Convex adds two fields to every document automatically: These are auto-set on insert and can be queried like any other field. _creationTime is indexable; sorting by it gives you newest-first ordering for free.

Foreign keys

Use v.id("tableName") for references between tables:
This is enforced — you can only insert a task with an ownerId that points to an existing users row. The TypeScript type Id<"users"> is also branded, so you can’t accidentally pass a tasks ID where a users ID is expected.

How vly handles schema evolution

When you ask vly to add a field:
vly:
  1. Updates the schema file with the new validator.
  2. Generates a migration script that backfills existing rows to the default.
  3. Updates affected queries and mutations to handle the new field.
  4. Updates UI components to display / edit the field.
  5. Runs the migration in the next deploy.
You see the migration in the build output, and it’s reversible from version history.

Hand-editing patterns

When you want fine control:
Convex’s schema validators are type-level; for runtime business rules, validate inside mutations.

Common pitfalls

.filter() walks every row. On a 100k-row table, this is multiple seconds. Always check for .withIndex() opportunities.
Loses type safety. The TypeScript inference downstream becomes any, which infects every consumer. Use a v.union(...) of literals instead, or an explicit v.object({...}).
Whenever you query where teamId = X, you need by_team on teamId. Foreign keys are not auto-indexed.
If two branches add different fields to the same table, merging is messy. vly uses version history for snapshotting, but for big schema changes, prefer to land them sequentially.

Next

Queries

Read patterns: indexes, filters, ordering, pagination.

Mutations

Transactional writes.

Indexes

Deep dive on index design.
Last modified on April 18, 2026