drizzle-zod: one source of truth
Use drizzle-zod to generate insert, select, and update validators straight from a Drizzle table, so the database stays the single source of truth for your validation schemas.
Right now your db/schema.ts declares the invoices table: every column, every type, every nullability flag, every default. You built that over the last five chapters. Somewhere in /lib, you have also been hand-writing a createInvoiceSchema as a z.object, naming the same columns a second time so a Server Action can validate its input. Put those two declarations side by side and they say the same thing twice. The day someone makes notes non-nullable in the table, or renames a column, the hand-written schema keeps accepting the old shape, because nothing connects the two declarations and so nothing complains. You find out in production, at runtime, when a write fails against a constraint your validator never knew about. So why are you maintaining both?
This lesson turns around something the whole chapter has been circling. Every previous lesson taught Zod as a thing you write. The idea here is that when the entity is a database row, you don’t write the schema at all: you generate it from the table and refine the API’s rules on top. In Derive, don’t duplicate you met that idea with a hand-written base schema as the single source. This is the same idea one layer up, with the database as the single source. By the end you will generate insert, select, and update validators straight from a table, layer your refinements over them, and know the exact boundary where you should not generate and should hand-write a z.object instead.
The three generators
Section titled “The three generators”The table already declares every column. So what does drizzle-zod hand you back, and why is it three schemas instead of one?
The reason is that one table has three boundaries, and they disagree about which columns are required. The package gives you a generator for each, and each one’s inferred type lines up with a $infer* type you already met when you built the data layer: the row and insert types the table gives you for free.
createSelectSchema(invoices)is the read shape: the row as it comes back from the database. Every non-nullable column is required, nullable columns are.nullable(), and the one that surprises people is that columns with defaults are still required. The row already exists, and itsidandcreatedAtwere filled in long before you read it, so the read shape includes them. Its inferred type matchestypeof invoices.$inferSelect.createInsertSchema(invoices)is the write shape: the row as it goes in. Now the columns with a database default or a$defaultFnbecome optional, because the database will fill them so the caller doesn’t have to. Database-generated columns are absent entirely. Its inferred type matchestypeof invoices.$inferInsert.createUpdateSchema(invoices)is the partial-write shape, for PATCH-style edits: every column optional, generated columns absent. Think of it ascreateInsertSchema(invoices).partial(), but cleaner at the call site.
The way select and insert differ on defaulted columns is the whole reason there are three generators, so let’s make it concrete. Here is the slice of the invoices table the rest of the lesson works from, the same table you built earlier, trimmed to the columns that matter here:
export const invoices = pgTable('invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid().notNull(), createdBy: uuid().notNull(), number: text().notNull(), status: invoiceStatus().notNull().default('draft'), total: numeric({ precision: 12, scale: 2 }).notNull(), notes: text(), ...timestamps, // createdAt, defaulted to now()});invoiceStatus is the pgEnum('invoice_status', [...]) from the schema file, and notes is the only nullable column. Imports are omitted.
Watch what happens to id and createdAt, the two defaulted columns, as you move across the three generators. They go from required, to optional, to optional: required when you read a row back, since it already has them; optional when you insert, since the database supplies them; optional again when you patch. That single flip from required to optional is the thing one schema could never express, which is why there are three.
const invoiceRowSchema = createSelectSchema(invoices);type InvoiceRow = z.infer<typeof invoiceRowSchema>;// { id: string; organizationId: string; createdBy: string;// number: string; status: 'draft' | ...; total: string;// notes: string | null; createdAt: Date }Validate a row coming back. Every column is present, including id and createdAt, because a row that already exists has them. Matches invoices.$inferSelect.
const invoiceInsertSchema = createInsertSchema(invoices);type InvoiceInsert = z.infer<typeof invoiceInsertSchema>;// { id?: string; organizationId: string; createdBy: string;// number: string; status?: 'draft' | ...; total: string;// notes?: string | null; createdAt?: Date }Validate a row going in. The defaulted columns (id, status, createdAt) are now optional, because the database fills them. organizationId and createdBy stay required, since they’re notNull with no default. Matches invoices.$inferInsert, and this is the generator you reach for most.
const invoiceUpdateSchema = createUpdateSchema(invoices);type InvoiceUpdate = z.infer<typeof invoiceUpdateSchema>;// { id?: string; organizationId?: string; createdBy?: string;// number?: string; status?: 'draft' | ...; total?: string;// notes?: string | null; createdAt?: Date }Validate a patch. Now every column is optional, so a caller can send just the fields they’re changing. number and total were required in insert and are optional here too. Roughly createInsertSchema(invoices).partial(), but cleaner at the call site.
Reaching for the wrong generator is the first place people slip. If you validate an insert with createSelectSchema, you force the caller to send an id and a createdAt they have no business supplying, because the select shape requires them. Match the generator to the boundary: row coming back, row going in, patch.
Refining on top of the generated base
Section titled “Refining on top of the generated base”The generated insert schema accepts any text at all for number, and any numeric string for total. But your API wants number capped at fifty characters and total to be non-negative. The column types don’t carry those rules: text is just text, and the table doesn’t know about the fifty-character cap. So where do the extra rules go?
They go on top of the generated base, and you have two distinct tools for putting them there. Keep the two tools separate in your head, because confusing them is where the sharpest bug in this lesson hides.
The first tool is the override map , the second argument to the generator. It hands you per-column rules that fold into the base as it’s being built. It comes in two forms, and the difference between them is the thing to watch:
- The callback form,
{ number: (schema) => schema.min(1).max(50) }, receives the column’s already-generated base schema (fortextthat’s a string schema) and lets you chain onto it. You do not rebuild fromz.string(): the string schema is handed to you and you add.min,.max,.refine. After your callback runs, drizzle-zod re-applies the column’s nullability and optionality around your result, so you can’t accidentally drop them. This is the safe default. - The direct-schema form,
{ payload: someSchema }, replaces the column’s schema wholesale, and this is the trap: drizzle-zod does not re-apply nullability afterward. Hand a plain schema to a nullable column this way and its.nullable()silently vanishes. Reach for this form only when you genuinely mean to supply the entire shape, as in thejsonbpairing later in this lesson, and re-add.nullable()or.optional()yourself if the column had it.
Use the callback form when you’re adding rules, and the direct-schema form only when you’re replacing the whole thing and have taken ownership of nullability. That one distinction saves you a class of bug that compiles clean and fails in production.
The second tool you already own: .omit, .pick, .extend, .partial, the composition algebra from Derive, don’t duplicate, applied to the generated schema instead of a hand-written one. The move you’ll make constantly is .omit, because an action sets some columns itself, from session and auth, never from the user. The organizationId comes from the current org, the createdBy from the signed-in user, and the id and createdAt from the database. None of those belong in the input contract a user fills out, so you strip them, and what remains is exactly the user-supplied subset.
Here is the canonical shape, the createInvoiceInputSchema that the invoice-creating action in the next chapter will validate against. Walk it one part at a time:
const createInvoiceInputSchema = createInsertSchema(invoices, { number: (schema) => schema.min(1).max(50), total: (schema) => schema.refine((n) => Number(n) >= 0, { error: 'Total must be non-negative' }),}).omit({ id: true, organizationId: true, createdBy: true, createdAt: true });
type CreateInvoiceInput = z.infer<typeof createInvoiceInputSchema>;Start from the generated insert base, not a hand-written z.object. The table is the source; everything below is refinement on top of it.
const createInvoiceInputSchema = createInsertSchema(invoices, { number: (schema) => schema.min(1).max(50), total: (schema) => schema.refine((n) => Number(n) >= 0, { error: 'Total must be non-negative' }),}).omit({ id: true, organizationId: true, createdBy: true, createdAt: true });
type CreateInvoiceInput = z.infer<typeof createInvoiceInputSchema>;The callback form. schema arrives as the string schema drizzle-zod generated for the text column, and you chain .min(1).max(50) straight onto it rather than rebuild from z.string(). Green marks the safe form: nullability is preserved around your chain.
const createInvoiceInputSchema = createInsertSchema(invoices, { number: (schema) => schema.min(1).max(50), total: (schema) => schema.refine((n) => Number(n) >= 0, { error: 'Total must be non-negative' }),}).omit({ id: true, organizationId: true, createdBy: true, createdAt: true });
type CreateInvoiceInput = z.infer<typeof createInvoiceInputSchema>;numeric arrives as a string, not a number (the next section explains why), so the refine converts it with Number(...) before checking the floor. The table’s CHECK (total >= 0) constraint is invisible to Zod, which is why you re-state the rule here.
const createInvoiceInputSchema = createInsertSchema(invoices, { number: (schema) => schema.min(1).max(50), total: (schema) => schema.refine((n) => Number(n) >= 0, { error: 'Total must be non-negative' }),}).omit({ id: true, organizationId: true, createdBy: true, createdAt: true });
type CreateInvoiceInput = z.infer<typeof createInvoiceInputSchema>;Strip the columns the action sets server-side: id and createdAt from the database, organizationId from the session, createdBy from auth. What’s left is the user-supplied input contract, nothing the user shouldn’t be allowed to set.
const createInvoiceInputSchema = createInsertSchema(invoices, { number: (schema) => schema.min(1).max(50), total: (schema) => schema.refine((n) => Number(n) >= 0, { error: 'Total must be non-negative' }),}).omit({ id: true, organizationId: true, createdBy: true, createdAt: true });
type CreateInvoiceInput = z.infer<typeof createInvoiceInputSchema>;One z.infer and you have the parameter type the Server Action will accept. The schema and the type both came from a single declaration, and that declaration tracked the table. We stop here, since the action that consumes this is the next chapter’s job.
Hold onto this division of labor, because it’s the habit the section exists to build: the generated base covers the database’s constraints (types, nullability, which columns are generated), and you refine the API’s additional constraints on top. Length caps, format rules tighter than text, and the non-negativity the column type can’t express are yours to add. Notice what is not here, though. Rules like “this invoice number must be unique within the org” or “this customer must exist” aren’t refinements; they’re database lookups, and a schema can’t do a lookup. They live in the action body, after the parse, and the next chapter draws that line formally. The schema validates shape, while cross-resource truth is checked where database access belongs.
Now make the habit muscle memory. In the exercise below, a base schema is already written for you and a createInvoiceInputSchema is half-built, missing the length cap on number and the .omit. Your job is to refine on top of the base, not rewrite it. Watch the ^? query on CreateInvoiceInput as you add the .omit: the moment it lands, organizationId disappears from the inferred type.
Two things are missing from createInvoiceInputSchema: a max length of 50 on `number`, and an `.omit` dropping the columns the server sets — `id`, `organizationId`, `createdBy`, `createdAt`. Refine the provided base, don't rewrite it. Watch the `^?` query: `organizationId` should vanish from CreateInvoiceInput once your `.omit` lands.
| Test scenario | Value | |
|---|---|---|
| valid full input | {"number":"INV-1001","status":"draft","total":"120.00","n… | |
| 60-char number rejected | {"number":"INV-000000000000000000000000000000000000000000… | |
| extra organizationId still parses | {"number":"INV-2002","organizationId":"org_123","total":"… | |
| zero total at the boundary | {"number":"INV-3003","total":"0.00"} | |
What drizzle-zod infers, and where it stops
Section titled “What drizzle-zod infers, and where it stops”Pass that numeric money column through createInsertSchema and look at the inferred type for total. It’s string, not number. Bug, or correct? And while we’re here, what does every Postgres type actually turn into?
It’s correct, and that’s the point worth leading with. Postgres numeric is arbitrary-precision: it can hold values a JavaScript number (a 64-bit float) would round off. Drizzle returns numerics as strings precisely to avoid that loss of precision, so the generated Zod type is z.string(). The money never becomes a number on the schema. You convert it at the boundary, with a decimal library like decimal.js, when you actually need to do arithmetic, and you set up that numeric-as-string discipline back when you built the columns. The schema’s job is to validate the string; the math is a separate concern at a separate layer.
Here’s the full mapping for the types a SaaS schema actually reaches for:
| Postgres column | Generated Zod |
|---|---|
text, varchar | z.string() |
integer, serial | z.number().int() with int32 bounds baked in (.min(-2147483648).max(2147483647)) |
numeric, decimal | z.string(), arbitrary-precision, returned as a string |
boolean | z.boolean() |
timestamp, timestamptz (date mode) | z.date() |
uuid | z.string().uuid(), the v3-style chain, not z.uuid() |
pgEnum(‘status’, options) | z.enum(options) with the same options |
jsonb | a wide recursive JSON union, effectively “any JSON” |
| custom / unknown type | a permissive shape; needs an explicit override |
A couple of those deserve a note. integer comes back as z.number().int() with the 32-bit signed int32 bounds already baked in, so the min and max come for free and you don’t write them. pgEnum is the clean one: it generates a z.enum with exactly the enum’s options, so the generated enum is the contract, with nothing to refine. And uuid is the awkward one: drizzle-zod emits the v3-style z.string().uuid() chain, not the top-level z.uuid() this course reaches for everywhere else. That’s a genuine inconsistency, because the tool doesn’t follow the course’s habit of keeping format builders top-level. You can override it to z.uuid(), but remember the catch: the direct-schema override form drops nullability, so unless the column is non-nullable you usually just leave the generated z.string().uuid() alone. It validates the same UUIDs; only the chain style differs.
That’s what generation gives you. The part that takes judgment is knowing the three places it stops and what stays your responsibility:
CHECKconstraints are invisible. The table hasCHECK (total >= 0), but the generated Zod will let-100straight through, because Zod can’t see the database’s checks. That’s why you refinedtotalwith a.refinetwo sections ago. The check is the database’s backstop; the Zod schema is the guard at the edge of the app. They’re two different layers defending the same rule, and they don’t share information, so you state the rule in both.numericis astringat the type level. Covered above: do the money conversion at the boundary with a decimal library, never on the schema. It’s worth repeating because it’s the one that bites hardest in real billing code.- Nullable generates
.nullable(), but a form usually wants.optional(). The generated schema turns a nullable column into.nullable(), which acceptsnull. But a text field a user can leave blank submits an empty string or nothing, not the JSON valuenull. When the shape is feeding a form, you flip it in the override:notes: (schema) => schema.optional(). The rule of thumb is the one from earlier in the chapter: an absent-or-blank form field wants.optional(), while a domain value that is deliberatelynullwants.nullable().
To make the surprise concrete, here is what the inferred insert type actually looks like for the invoices table, with the two surprising lines marked:
type InvoiceInsert = z.infer<typeof invoiceInsertSchema>;// {// id?: string; // uuid → string, not a typed id// organizationId: string;// createdBy: string;// number: string;// status?: 'draft' | 'sent' | 'paid' | 'overdue';// total: string; // numeric → string, NOT number// notes?: string | null;// createdAt?: Date;// }Pairing a jsonb column with its schema
Section titled “Pairing a jsonb column with its schema”You have an events table, an audit trail, with a jsonb payload column. Run it through createInsertSchema and payload comes back as that wide “any JSON” union: an object, an array, a string, a number, anything. That’s useless as a contract, because every consumer that reads a payload would have to narrow it from scratch. So how do you make the column and the validation share one real shape?
This is where the lesson’s title becomes literal: two sources, one truth. The move is to write the payload’s Zod schema once, right next to the table, and feed it to both sides.
export const eventPayloadSchema = z.object({ kind: z.enum(['invoice.created', 'invoice.sent', 'invoice.paid']), actorId: z.uuid(), meta: z.record(z.string(), z.unknown()),});Declare the payload’s shape one time, in the same file as the events table. This single schema is about to feed three different surfaces. If your events are tagged variants, this becomes a z.discriminatedUnion on kind, reusing the discriminated union from earlier in the chapter.
export const events = pgTable('events', { id: uuid().primaryKey().default(sql`uuidv7()`), payload: jsonb() .$type<z.infer<typeof eventPayloadSchema>>() .notNull(),});Pass the schema’s inferred type to the column’s $type<...>, the TS-type claim on a jsonb column you set up when you built the schema. Now Drizzle’s row type for payload is the same shape as the Zod schema. That’s one side done.
const eventInsertSchema = createInsertSchema(events, { payload: eventPayloadSchema,});type EventInsert = z.infer<typeof eventInsertSchema>;Pass the same schema as the override here, and the insert validation matches too. One declaration now drives the column’s TS type, the insert validation, and the inferred input type: three surfaces, one truth. This is the direct-schema override form, which means the next paragraph’s warning applies.
The jsonb column is the one place the database’s type system genuinely can’t see inside a value: to Postgres it’s an opaque blob of JSON. The Zod schema is what gives that blob a shape, and using it as both the $type and the override means that shape is declared exactly once. Change the payload’s fields and both the database’s TS type and the runtime validation move together.
This is exactly where the trap shows up in real code, so the warning belongs right here, not in a footnote:
When the generated schema is the wrong reach
Section titled “When the generated schema is the wrong reach”One boundary keeps this approach from running away with you. Your app also validates a Better Auth session payload, a Stripe webhook envelope , and the JSON some third-party API returns. None of those is a row in your database. Do you generate, or hand-write?
Hand-write. The rule is a clean binary:
- Generate when the shape is a row. The validation should track the table, since drift is the problem you’re avoiding and the table is the source of truth. Invoice inserts, customer rows, and audit-log rows all get generated.
- Hand-write a
z.objectwhen the shape corresponds to no table. Session payloads, webhook envelopes, third-party API responses, internal RPC shapes, yoursearchParams: these already have a source of truth, the upstream system’s contract, and it isn’t your database. Generating them from a table would invent a relationship that doesn’t exist.
The point to internalize is that mixing the two in one file is the correct pattern, not a smell. A real webhook-handling Server Action might safeParse a hand-written webhookEnvelopeSchema to validate what Stripe sent, and then, after pulling the data out, build an invoice row with the generated createInvoiceInputSchema. The two schemas coexist because each one’s source matches its boundary: the envelope’s source is Stripe’s API, and the invoice’s source is your table.
Sort these to lock the rule in as recall, not just recognition:
Each of these is a shape your app validates. Sort it by where its source of truth lives — a Drizzle table you generate the schema from, or an upstream contract that isn't your database and you hand-write a z.object for. Drag each item into the bucket it belongs to, then press Check.
searchParams filter objectOne power tool is worth naming before moving on, for the day the default generators aren’t enough. When your project extends Zod itself, for instance to wire schemas into API documentation later in the course, createSchemaFactory({ zodInstance: customZ }) hands you generators bound to your custom Zod instance instead of the stock one. It also takes a coerce config: createSchemaFactory({ coerce: { date: true } }) makes the generators emit z.coerce.date() for date columns automatically, which ties back to the FormData coercion from the previous lesson. You’ll rarely reach for either in everyday line-of-business code, but now you know the seam is there.
The source-of-truth chain
Section titled “The source-of-truth chain”Zoom all the way out. When a column in db/schema.ts changes, where does that change ripple to, and what turns drift from a silent runtime surprise into a compile error you can’t ignore?
Follow the chain. The database schema is the root. drizzle-zod projects that root into base Zod schemas. You refine your API’s rules on top. z.infer turns the result into TypeScript types. Server Actions consume those typed inputs. And the form’s name attributes line up with the schema’s keys. Every link is derived from the one before it, so when you rename or retype that root column, the change can’t stay quietly contained. It forces every downstream link to either update or stop compiling.
%%{init: {'themeCSS': '.nodeLabel { font-size: 22px; } .edgeLabel .edgeLabel { font-size: 18px; } .node.code .nodeLabel { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }'} }%%
flowchart LR
schema["<b>db/schema.ts</b><br/>invoices table"]
base["drizzle-zod<br/>base schemas"]
refined["refine +<br/><code>.omit</code>"]
types["<code>z.infer</code><br/>types"]
action["Server Action<br/>inputs"]
form["form<br/><code>name</code> keys"]
schema -- generates --> base
base -- your rules --> refined
refined -- z.infer --> types
types -- consumed by --> action
action -- match --> form
class base,refined,types,action,form code
class schema root
classDef root fill:#fef3c7,stroke:#f59e0b,color:#111,stroke-width:2.5px
classDef code fill:#1e293b,stroke:#94a3b8,color:#f8fafc That’s the payoff the chapter has been building to. It means the rest of this unit is cheap to write: every action’s input contract becomes a three-line derivation from a table (createInsertSchema, a refinement or two, an .omit) instead of a hand-maintained schema running in parallel with the database and quietly falling out of step with it. The chapter opened with the thesis that every untrusted boundary gets parsed against a schema before any other code looks at it. For row-shaped data, those schemas are now free: the database already wrote them, and drizzle-zod just hands them to you.
Quick recall
Section titled “Quick recall”Four statements that hit the traps most likely to catch you later. Decide whether each is true or false:
Decide whether each statement holds for `drizzle-zod` + Zod 4. Mark each statement True or False.
createInsertSchema(invoices) makes a column with a database default (like id or createdAt) optional, because the database fills it.
default or $defaultFn is filled by the database on insert, so createInsertSchema marks it optional — the caller doesn’t have to supply it. createSelectSchema keeps those same columns required, because a row coming back already has them.Passing a Zod schema directly in the override map — createInsertSchema(events, { payload: eventPayloadSchema }) — keeps the column’s nullability.
.nullable(). Only the callback form, { payload: (schema) => schema… }, has its nullability re-wrapped for you. With the direct form you own it — re-add it yourself: payload: eventPayloadSchema.nullable().A numeric (money) column generates a z.string(), not a z.number().
numeric is arbitrary-precision and Drizzle returns it as a string to avoid the float lossiness a JavaScript number would introduce — so the generated Zod type is a string too. You validate the string here and convert it with a decimal library at the boundary, never on the schema.You should generate the validation schema for a Stripe webhook payload from a Drizzle table.
z.object for it. You generate only when the shape is a row (invoice inserts, customer rows, audit-log rows); generating a non-row shape from a table invents a relationship that doesn’t exist.