Skip to content
Chapter 42Lesson 7

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 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 its id and createdAt were filled in long before you read it, so the read shape includes them. Its inferred type matches typeof invoices.$inferSelect.
  • createInsertSchema(invoices) is the write shape: the row as it goes in. Now the columns with a database default or a $defaultFn become optional, because the database will fill them so the caller doesn’t have to. Database-generated columns are absent entirely. Its inferred type matches typeof invoices.$inferInsert.
  • createUpdateSchema(invoices) is the partial-write shape, for PATCH-style edits: every column optional, generated columns absent. Think of it as createInsertSchema(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:

db/schema.ts
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.

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.

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 (for text that’s a string schema) and lets you chain onto it. You do not rebuild from z.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 the jsonb pairing 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.

1 / 1

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.

Booting type-checker…
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 columnGenerated Zod
text, varcharz.string()
integer, serialz.number().int() with int32 bounds baked in (.min(-2147483648).max(2147483647))
numeric, decimalz.string(), arbitrary-precision, returned as a string
booleanz.boolean()
timestamp, timestamptz (date mode)z.date()
uuidz.string().uuid(), the v3-style chain, not z.uuid()
pgEnum(‘status’, options)z.enum(options) with the same options
jsonba wide recursive JSON union, effectively “any JSON”
custom / unknown typea 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:

  1. CHECK constraints are invisible. The table has CHECK (total >= 0), but the generated Zod will let -100 straight through, because Zod can’t see the database’s checks. That’s why you refined total with a .refine two 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.
  2. numeric is a string at 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.
  3. Nullable generates .nullable(), but a form usually wants .optional(). The generated schema turns a nullable column into .nullable(), which accepts null. But a text field a user can leave blank submits an empty string or nothing, not the JSON value null. 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 deliberately null wants .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;
// }

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.

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.object when the shape corresponds to no table. Session payloads, webhook envelopes, third-party API responses, internal RPC shapes, your searchParams: 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.

Generate from the table The shape is a database row
Hand-write a z.object The shape maps to no table
An invoice insert
A customer row
An audit-log row
A Better Auth session payload
A Stripe webhook envelope
A searchParams filter object

One 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.

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
A column rename or type change at the root forces every downstream consumer to update or fail to compile, turning the drift bug into a build error.

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.

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.

True. That’s exactly how the insert shape differs from the select shape. A column with a 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.

False. That’s the direct-schema override form: it replaces the column’s schema wholesale, and drizzle-zod does not re-apply nullability afterward, so a nullable column silently loses its .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().

True. Postgres 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.

False. A webhook payload corresponds to no table — its source of truth is Stripe’s API contract, not your database. Hand-write a 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.