Schemas
For AI agents: see llms.txt for the complete documentation index. Markdown versions are available by adding .md to a page URL or requesting Accept: text/markdown.
A schema is a description of
- the tables in your Convex project
- the type of documents within your tables
While it is possible to use Convex without defining a schema, adding a
schema.ts file will ensure that the documents in your tables are the correct
type. If you're using
TypeScript, adding a schema will
also give you end-to-end type safety throughout your app.
We recommend beginning your project without a schema for rapid prototyping and then adding a schema once you've solidified your plan. To learn more see our Schema Philosophy.
Example: TypeScript and Schemas
Writing schemas
Schemas are defined in a schema.ts file in your convex/ directory and look
like:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
messages: defineTable({
body: v.string(),
user: v.id("users"),
}),
users: defineTable({
name: v.string(),
tokenIdentifier: v.string(),
}).index("by_token", ["tokenIdentifier"]),
});
This schema (which is based on our
users and auth example),
has 2 tables: messages and users. Each table is defined using the
defineTable function. Within each table,
the document type is defined using the validator builder,
v. In addition to the fields listed, Convex will also
automatically add _id and _creationTime fields. To learn more, see
System Fields.
While writing your schema, it can be helpful to consult the Convex Dashboard. The "View schema file" panel on the "Schema" page can generate a schema declaration based on the data in your tables.
Validators
The validator builder, v is used to define the type
of documents in each table. It has methods for each of
Convex's types:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
documents: defineTable({
id: v.id("documents"),
string: v.string(),
number: v.number(),
boolean: v.boolean(),
commitTs: v.commitTs(),
nestedObject: v.object({
property: v.string(),
}),
}),
});
It additionally allows you to define unions, optional property, string literals,
and more. Argument validation and schemas both use
the same validator builder, v.
Optional fields
You can describe optional fields by wrapping their type with v.optional(...):
defineTable({
optionalString: v.optional(v.string()),
optionalNumber: v.optional(v.number()),
});
This corresponds to marking fields as optional with ? in TypeScript.
Unions
You can describe fields that could be one of multiple types using v.union:
defineTable({
stringOrNumber: v.union(v.string(), v.number()),
});
If your table stores multiple different types of documents, you can use
v.union at the top level:
defineTable(
v.union(
v.object({
kind: v.literal("StringDocument"),
value: v.string(),
}),
v.object({
kind: v.literal("NumberDocument"),
value: v.number(),
}),
),
);
In this schema, documents either have a kind of "StringDocument" and a
string for their value:
{
"kind": "StringDocument",
"value": "abc"
}
or they have a kind of "NumberDocument" and a number for their value:
{
"kind": "NumberDocument",
"value": 123
}
Literals
Fields that are a constant can be expressed with v.literal:
defineTable({
oneTwoOrThree: v.union(
v.literal("one"),
v.literal("two"),
v.literal("three"),
),
});
Record objects
You can describe objects that map arbitrary keys to values with v.record:
defineTable({
simpleMapping: v.record(v.string(), v.boolean()),
});
You can use other types of string validators for the keys:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
userIdToValue: v.record(v.id("users"), v.boolean()),
},
handler: async ({ db }, { userIdToValue }) => {
//...
},
});
Notes:
- This type corresponds to the Record<K,V> type in TypeScript
- You cannot use string literals as a
recordkey - Using
v.string()as arecordkey validator will only allow ASCII characters
Any
Fields or documents that could take on any value can be represented with
v.any():
defineTable({
anyValue: v.any(),
});
This corresponds to the any type in TypeScript.
Function handles
Function handles can be stored in
fields validated with v.function():
jobs: defineTable({
input: v.string(),
onComplete: v.function({
fnType: "mutation",
args: { jobId: v.string(), result: v.string() },
returns: v.null(),
}),
}),
Schema validation only checks that the value is a well-formed function handle
string. fnType, args, and returns are used for TypeScript typechecking and
codegen but aren't checked when a document is written, and changing them will
not revalidate existing stored function handles. A stored handle might also
point to a function that no longer exists.
Options
These options are passed as part of the
options argument to
defineSchema.
schemaValidation: boolean
Whether Convex should validate at runtime that your documents match your schema.
By default, Convex will enforce that all new and existing documents match your schema.
You can disable schemaValidation by passing in schemaValidation: false:
defineSchema(
{
// Define tables here.
},
{
schemaValidation: false,
},
);
When schemaValidation is disabled, Convex will not validate that new or
existing documents match your schema. You'll still get schema-specific
TypeScript types, but there will be no validation at runtime that your documents
match those types.
strictTableNameTypes: boolean
Whether the TypeScript types should allow accessing tables not in the schema.
By default, the TypeScript table name types produced by your schema are strict.
That means that they will be a union of strings (ex. "messages" | "users") and
only support accessing tables explicitly listed in your schema.
Sometimes it's useful to only define part of your schema. For example, if you
are rapidly prototyping, it could be useful to try out a new table before adding
it your schema.ts file.
You can disable strictTableNameTypes by passing in
strictTableNameTypes: false:
defineSchema(
{
// Define tables here.
},
{
strictTableNameTypes: false,
},
);
When strictTableNameTypes is disabled, the TypeScript types will allow access
to tables not listed in the schema and their document type will be any.
Regardless of the value of strictTableNameTypes, your schema will only
validate documents in the tables listed in the schema. You can still create and
modify documents in other tables in JavaScript or on the dashboard (they just
won't be validated).
Schema validation
Schemas are pushed automatically in npx convex dev
and npx convex deploy.
The first push after a schema is added or modified will validate that all existing documents match the schema. If there are documents that fail validation, the push will fail.
After the schema is pushed, Convex will validate that all future document inserts and updates match the schema.
Schema validation is skipped if schemaValidation
is set to false.
Note that schemas only validate documents in the tables listed in the schema. You can still create and modify documents in other tables (they just won't be validated).
Staged validators
Staged validators let you validate a schema change in the background without delaying deployment. Use them when tightening a table's validator would require a long scan of existing documents. The current validator remains enforced on writes while validation runs. Once validation succeeds, you can promote the change without repeating the scan.
For example, say a table's value field is declared as
v.union(v.string(), v.number()) because older documents store "123" as a
string while newer ones store 123 as a number. You want to migrate the strings
to numbers and enforce value: v.number(). You also want to remove an optional
legacy field while keeping label unchanged.
Stage and validate the change
-
Update any functions that write documents to use the new field types, and migrate existing documents. For this example, convert string values to numbers and remove
legacyfrom every document. The current validator must continue to accept these writes. -
Keep the current validator in
defineTableand chain.staged()with only the fields you want to add, change, or remove:export default defineSchema({documents: defineTable({label: v.string(),value: v.union(v.string(), v.number()),legacy: v.optional(v.string()),}).staged({value: v.number(),legacy: undefined,}),});Fields omitted from
.staged()keep their current validators. Here, the staged validator keepslabel: v.string(), tightensvaluetov.number(), and removeslegacyby setting it toundefined. This will validate that no documents have that field. Usev.optional(...)to allow the field to be present or unset. Staging a removal does not delete data from existing documents.Each supplied validator replaces the entire field validator. To change a nested object, pass
nested: v.object({ ... })with the complete desired definition of that object, including any nested fields you want to keep. You can also pass a full schema validator, such asv.object({ ... })orv.union(...), to specify the entire staged document type. Use this form for tables whose current validator is a union. For a table defined withv.any(), a field map defines the complete staged object type: include every field you want that object to allow.Put all changes that require revalidating this table in
.staged()and keep its enforced validator unchanged, so the scan runs in the background. -
Run
npx convex deploy. Convex scans existing documents against the staged validator in the background and checks every new write against it. Writes continue to enforce the current validator. A write that passes the current validator but fails the staged validator succeeds and marks staged validation as failed, even if the background scan had already finished. -
Check progress with
npx convex deploy --dry-run. The CLI reports each staged table as validating, validated, or failed, with a progress count or the first validation error. Wait for the state to be validated before promoting. If validation fails, follow the retry steps below.
Promote a validated change
When you're ready to start enforcing the new validator, follow these steps.
-
Confirm the staged validation state with
npx convex deploy --dry-run. -
Update
defineTableto the complete staged document type and remove.staged():export default defineSchema({documents: defineTable({label: v.string(),value: v.number(),}),}); -
Run
npx convex deployagain. Convex reuses the completed staged validation, so this deploy skips the table scan and does not ask for confirmation to remove the staged validator. The new validator becomes enforced on writes.
You can also reuse completed staged validation when the new enforced validator
accepts every document the staged validator accepts, such as changing a staged
field from v.number() to v.union(v.number(), v.string()).
Retry a failed validation
If validation fails, follow these steps to resolve and retry a staged validation.
- Inspect the reported validation error. Each attempt reports only the first offending document it finds, so use a query or migration to find and fix all offending documents. Fix any functions that still write documents rejected by the staged validator.
- Keep the same
.staged()definition and deploy again. This retries validation from the beginning, scanning the whole table in the background. - Wait for the validation state to be validated, then follow the promotion steps above. Repeat the repair and retry steps if another document fails.
Promote before validation succeeds
If you want to stop the background validation and start enforcing immediately, follow these steps. Note: this is generally not recommended, as it may take a long time to complete.
- Update
defineTableto the desired validator and remove.staged(). - Deploy. Convex runs normal schema validation because the pending or failed staged result cannot prove that all documents match. If this requires a full table scan, the deploy blocks until that expensive scan finishes. Existing documents that fail the new validator cause the deploy to fail.
If staged validation is still running, the CLI asks for confirmation because
this push cancels that background validation and clears its progress. Pass
--allow-unstaging-schema to skip the prompt. If staged validation has failed,
the CLI proceeds without this prompt; schema validation can still require the
same full table scan.
Change or remove a staged validator
- Deploying the same staged validator preserves validation that is running or already validated. Deploying it again after a failure retries the scan.
- Changing a successfully validated
.staged()validator to a more permissive one preserves its validated state without another scan. For example, you can change a staged field fromv.number()tov.union(v.number(), v.string()). The replacement must accept every document the previous staged validator accepts. The CLI does not ask for confirmation because the completed validation is reused. - Other changes to
.staged()start a new validation. Changing a validator that is still running or has failed restarts the scan, including when making it more permissive. Each table can have at most one staged validator. - Removing a staged validator, or changing it without reusing its validation,
clears its validation progress. Staging it again later starts a new scan. The
CLI asks for confirmation before clearing running or validated progress; pass
--allow-unstaging-schemato skip the prompt. Promotion of a validated change follows the reuse rules above.
Circular references
You might want to define a schema with circular ID references like:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
preferencesId: v.id("preferences"),
}),
preferences: defineTable({
userId: v.id("users"),
}),
});
In this schema, documents in the users table contain a reference to documents
in preferences and vice versa.
Because schema validation enforces your schema on every db.insert,
db.replace, and db.patch call, creating circular references like this is not
possible.
The easiest way around this is to make one of the references nullable:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
preferencesId: v.id("preferences"),
}),
preferences: defineTable({
userId: v.union(v.id("users"), v.null()),
}),
});
This way you can create a preferences document first, then create a user document, then set the reference on the preferences document:
import { mutation } from "./_generated/server";
export default mutation({
handler: async (ctx) => {
const preferencesId = await ctx.db.insert("preferences", {});
const userId = await ctx.db.insert("users", { preferencesId });
await ctx.db.patch("preferences", preferencesId, { userId });
},
});
Let us know if you need better support for circular references.
TypeScript types
Once you've defined a schema, npx convex dev will
produce new versions of dataModel.d.ts and
server.d.ts with types based on your schema.
Doc<TableName>
The Doc TypeScript type from
dataModel.d.ts provides document types for all of
your tables. You can use these both when writing Convex functions and in your
React components:
import { Doc } from "../convex/_generated/dataModel";
function MessageView(props: { message: Doc<"messages"> }) {
...
}
If you need the type for a portion of a document, use the
Infer type helper.
query and mutation
The query and
mutation functions in
server.js have the same API as before but now provide
a db with more precise types. Functions like
db.insert(table, document)
now understand your schema. Additionally
database queries will now return the
correct document type (not any).