Doctor
Report what DRZL cannot type or enforce in your schema, and what to do about each one.
Usage:
pnpm dlx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]npx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]yarn dlx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]bunx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]With no schema argument it reads the schema path out of your drzl.config.*, so a project that already has one needs only drzl doctor. A config without a schema key resolves through your drizzle-kit config, exactly as generate does.
Why this is not analyze
drzl analyze prints the whole Analysis as JSON. That is a description of your schema, and reading trouble out of it means knowing which fields mean trouble.
doctor is the other thing: the list of what will silently not work. Both failure modes it reports produce a generated file that exists, compiles and validates nothing:
- a column DRZL cannot type gets a validator that accepts any value
- a CHECK constraint DRZL will not translate is simply absent from the output
Example
npx @drzl/cli doctor src/db/schema.tsDRZL doctor src/db/schema.ts
postgres, 1 table, 11 columns, 12 CHECK constraints
Columns DRZL cannot type (2)
These get a validator that accepts any value.
- Column "balance" on table "accounts" has no known type (SQL type numeric(12,2)), so its
validator will accept any value.
- Column "credit" on table "accounts" has no known type (SQL type numeric(12,2)), so its
validator will accept any value.
A customType has no runtime shape to read. Declare it with .$type<T>() and turn on
typedColumns to give the validator the type.
CHECK constraints DRZL does not enforce (9)
Your database still enforces these. Nothing DRZL generates does.
- CHECK "age_or" on "accounts" is not translated: contains OR. Expression: age >= 18 OR age <=
65
- CHECK "email_re" on "accounts" is not translated: not a single comparison this version
understands. Expression: email ~ '^[a-z]+$'
Only constraints whose meaning is unambiguous are translated, because a validator enforcing
a guess rejects rows the database accepts. Your database still enforces this one; nothing
DRZL emits does.
- CHECK "tags_scalar" on "accounts" compares an array column "tags" against a scalar literal,
which does not describe it, so it is not translated. Expression: tags = '{}'
On an array column only cardinality(col) is read, since it is the one comparison that is
about the array rather than about an element.
11 findings in src/db/schema.ts. None of these stop DRZL generating; they are what it will not
check for you.A healthy schema says so, and says what was looked at, so a clean run cannot be confused with a command that failed to run:
DRZL doctor src/db/schema.ts
postgres, 3 tables, 6 columns, 0 CHECK constraints
Nothing to report.
Every column has a type DRZL can describe.
Every CHECK constraint is translated into the generated validators.
Every table has a primary key the generators can use.What it reports
| Section | What it means |
|---|---|
| Columns DRZL cannot type | The emitted validator accepts any value for this column |
| CHECK constraints DRZL does not enforce | The constraint is in your schema and in your database, and in no schema DRZL emits |
| Primary keys the generators cannot use | getById, update and delete are keyed on one column |
| Other findings | Everything else the analyzer said while reading the schema |
Four CHECK cases are distinguished, because they have different fixes:
- Not translated. The shared parser refused the expression and says why:
contains OR,right side is not a literal, and so on. See the skip list in Generators → Zod; the same parser serves all five validation generators. - Names a column the table does not have. Usually a typo, or a constraint that spans two tables.
- Compares an array or structured column against a scalar literal.
tags = '{}'on atext[]says nothing usable about the array, so it is skipped rather than guessed at. On an array column onlycardinality(col)is read. - Counts a column whose count JavaScript cannot take the way the database did.
octet_length(bin) <= 8on a MySQLvarbinary(8): the value arrives as a string produced by a lossy decode, so neither its characters nor their UTF-8 re-encoding is the number the server took. The same expression on atextor abyteacolumn is enforced, and is not listed.
A constraint DRZL does translate is not listed. age >= 18 folds into .gte(18) and start_date < end_date becomes an object-level refinement, and listing those would bury the ones that matter.
Other reports
Two flags replace the findings above with a different report over the same analysis. They are separate reports rather than extra sections, and passing both is an error rather than a run that silently picks one.
--constraints
What the database enforces that the generated schemas do not, and the reverse.
npx @drzl/cli doctor src/db/schema.ts --constraintsThe half worth your attention is the second one, because it can lose data. A Drizzle text(name, { enum: [...] }) column is a plain text column: the generated schema refuses anything outside the set, and a migration, a psql session or any other client writes past it. A native pgEnum carries the enum's type name as its SQL type and is enforced by the database, so the two are told apart and only the first is reported.
Add --sql to emit the closing statements alone, with no prose and no colour, for redirecting into a migration:
npx @drzl/cli doctor src/db/schema.ts --constraints --sql > migrations/0002_checks.sqlSQLite gets no statement: ALTER TABLE ... ADD CONSTRAINT is a syntax error there, so the gap carries the reason instead. Neither does unknown, because emitting DDL for a database nobody has named is the kind of guess that ends up in a migration.
Under --strict only the schema-only side counts. A primary key or a foreign key that no per-row validator can check is not a defect anyone can fix.
--policies
The row-level security policies each table carries, and what they refuse.
npx @drzl/cli doctor src/db/schema.ts --policiesDRZL row-level security src/db/schema.ts
postgres, 2 tables under RLS, 2 policies
These tables refuse the operation your generated code performs
A table under row-level security permits only what a policy grants. The generated service
still compiles and its return type still promises rows.
- audit_log everything
row-level security is on and no permissive policy grants anything, so every read returns
zero rows and every write is refused, for every role but the table's owner and any role
with BYPASSRLS
Close it: declare a policy, or drop the row-level security on this table
- posts insert
row-level security is on and no permissive policy grants INSERT, so every insert is
refused for every role but the table's owner and any role with BYPASSRLS
Close it: the policy "anyone_inserts" names INSERT but grants nothing; give it a WITH
CHECK expressionA table with row-level security on and nothing granting a command does not half-work: the read returns zero rows and the write raises new row violates row-level security policy. The generated service over it still compiles and its return type still promises rows, which is why this is worth a report of its own.
Three readings this deliberately does not make, each settled by running it against Postgres rather than by reading the declaration:
- A table with policies and no
.enableRLS()is not unprotected. Declaring any policy makesdrizzle-kitemitALTER TABLE ... ENABLE ROW LEVEL SECURITYon its own, so the flag says nothing about the running database and a report keyed on it would tell you your rules were inert while Postgres enforced them. - A write policy with no
WITH CHECKis not a hole. A loneFOR INSERTpolicy carrying noWITH CHECKrefuses every insert. It is reported as a door that is shut, not one left open. FOR UPDATEandFOR ALLwith aUSINGand noWITH CHECKare not defects at all. Both fall back to theUSINGexpression for the new row, so the most ordinary policy anybody writes is not flagged.
The report ends with the fact no schema change fixes: no generator emits policy awareness. A generated read path describes rows the caller may not be allowed to see, and a reader of the emitted types will believe otherwise. That is listed rather than counted as a finding, so --strict cannot fail a pipeline nobody can make pass.
Whether a policy applies to the role your application connects as is the one question the report cannot answer for you, so every policy's TO is printed.
Non-Postgres dialects have no row-level security to declare, and their tables are absent from this report rather than listed as having it switched off.
Exit codes
| Code | When |
|---|---|
0 | The schema was read. Findings may have been reported. |
1 | The schema could not be read at all: the file is missing, or importing it threw. |
2 | Findings were reported and --strict was passed. |
Zero by default is deliberate. A schema carrying a customType, or a CHECK this parser will not guess at, is normal and usable, and a doctor that failed every pipeline reading one would be switched off within a week. --strict is how you opt into a gate:
- run: npx @drzl/cli doctor --strictThis differs from analyze, which exits 2 on an error-level issue, because there error means "the JSON you asked for is not there". doctor always has a report to print.
--json
npx @drzl/cli doctor src/db/schema.ts --json{
"command": "doctor",
"exitCode": 0,
"schema": "src/db/schema.ts",
"dialect": "postgres",
"ok": false,
"counts": { "tables": 2, "columns": 5, "checks": 0, "findings": 2 },
"findings": [
{
"kind": "partial-primary-key",
"level": "warn",
"table": "composite",
"message": "Table \"composite\" has a composite primary key (a, b). ...",
"hint": "Treat the generated service as a starting point for this table and widen the key by hand."
}
]
}ok here means "there is nothing to report about your schema", which it has always meant. It is not a statement about whether the run worked: a report full of findings is a perfectly successful doctor. The run's own answer is exitCode, and that is why the shared envelope defines no ok of its own. See Output & exit codes.
kind is a stable identifier, so a CI step can count one category without matching on prose:
npx @drzl/cli doctor --json \
| node -e "const r=JSON.parse(require('fs').readFileSync(0,'utf8'));
const n=r.findings.filter(f=>f.kind==='unknown-column').length;
if (n) { console.error(n+' untypeable column(s)'); process.exit(1); }"Values are unknown-column, check-declined, check-unknown-column, check-not-scalar, check-uncountable, no-primary-key, partial-primary-key and analyzer.
Runnable config
doctor needs no config of its own. It reads the schema path out of the one you already have:
// drzl.config.ts
export default {
schema: 'src/db/schema.ts',
outDir: 'src/api',
analyzer: { includeRelations: true, validateConstraints: true },
generators: [{ kind: 'zod', path: 'src/validators/zod', typedColumns: true }],
} as const;npx @drzl/cli doctortypedColumns above is the fix doctor names for an untypeable column: it does not make the validator check the value, which nothing can do for a customType, but it recovers the declared TypeScript type so the call site is still narrowed. See Generators → Zod.
See also: Analyze · Explain · Generate · Output & exit codes
doctor reports the findings across every table and says nothing about a table that is fine. explain is the other direction: everything about one table, including the constraints it does enforce and the facts it read off each column, with the same silent failures called out at the bottom. Reach for doctor when the question is "what will not work in this schema", and for explain when it is "what did DRZL make of this table".

