It was a Tuesday night release, not even a weekend one, which somehow made it worse — nobody was braced for it. Our on-call phone lit up with a stack trace from the orders service: TypeError: Cannot read properties of null (reading 'toUpperCase'), thrown on a field our Prisma schema swore, in writing, could never be null. I spent the first twenty minutes assuming it was a bad deploy, because the type system had told me this code was safe. It wasn't a bad deploy. It was a three-week-old manual migration that nobody had told Prisma about.
That night is the reason I started questioning the whole premise of how we type database access in TypeScript backends. Not "is Prisma good or bad" — that argument is tired and mostly beside the point — but a narrower, more useful question: where does the type information actually come from, and what happens the instant that source of truth and the real database disagree? Once I started looking at it that way, a lot of ORM behavior that used to feel like magic started looking like a liability with a nice API on top.
This post walks through that incident, why it happened, and the shift we made afterward toward SQL-shaped query builders that pull their types from the live schema instead of a separate file that has to be kept in sync by hand. I'm not claiming this is the only correct way to run a data layer — plenty of teams run Prisma at scale without ever hitting this — but it's the fix that made sense for us, and the reasoning behind it is worth writing down.
The bug that started it
A support engineer had been asked to unblock a data backfill on the orders table. A handful of legacy rows had a required discount_code column with garbage placeholder values, and the fastest fix was to drop the NOT NULL constraint, null out the bad rows, and backfill the correct codes over the following week. It's a completely reasonable operational move. They ran it directly against the database with a one-off SQL script, because it was faster than going through our migration pipeline for what felt like a temporary patch.
Nobody updated schema.prisma. Nobody ran prisma migrate dev. The generated Prisma client kept right on declaring discountCode: string, no question mark, because as far as Prisma's tooling was concerned, nothing had changed. The database and the schema file had quietly diverged, and every developer on the team — myself included — kept writing code against a type that was no longer true.
Three weeks later, one of the newly-nulled rows flowed through a code path that called .toUpperCase() on that field without a null check, because why would you check something the compiler insists can't be null? That's the part that stuck with me: the crash wasn't caused by sloppy code. It was caused by code that correctly trusted its type signature.
Why generated types can quietly go stale
Schema-first ORMs like Prisma work by treating a dedicated schema file as the single source of truth, then generating a client from it. That's a genuinely good design when every change to the database goes through that file. The problem is that "every change" is doing a lot of load-bearing work in that sentence, and in any real organization, someone will eventually bypass it — a hotfix, a DBA running a script directly, a rollback that only happened at the database layer, a rake-task-equivalent that some other service owns.
The generation step itself isn't the flaw. The flaw is that there's no continuous, automatic check tying the generated types back to what's actually running in production at any given moment. You get a snapshot, frozen at the last time someone remembered to run the generator, and the type checker will happily vouch for that snapshot forever.
db push, plus whatever a data engineering team does independently — assume drift will happen eventually. The generated client has no visibility into anything that didn't go through its own CLI.Once I framed it that way, the appeal of a heavier ORM abstraction started to look thinner. It's not that Prisma's types are wrong on day one — they're usually exactly right on day one. It's that the guarantee degrades silently over time in a way that's invisible until something actually breaks in production, and by then the type checker has already told everyone it was fine.
Treating the query itself as the typed thing
The alternative we landed on wasn't "give up on types," it was moving the source of truth closer to the database itself. Tools like Drizzle and Kysely take a different stance: instead of a separate schema DSL that gets compiled into a client, you write a query builder whose shape maps directly onto SQL, and you generate (or hand-write) your table definitions from the database's actual introspected schema. Nullability, column types, and defaults come from what Postgres reports right now, not from a parallel file someone has to remember to touch.
Here's the Prisma code that actually shipped the bug — it reads perfectly reasonably in isolation, which is exactly the problem:
const order = await prisma.order.findUniqueOrThrow({
where: { id: orderId },
});
// schema.prisma still declares discountCode as `String` (non-null),
// even though the live column had DROP NOT NULL applied three weeks ago
const normalizedCode = order.discountCode.toUpperCase();
After the migration, we rebuilt that same read path with Drizzle, generating the table definitions with drizzle-kit introspect straight off the staging database rather than maintaining a hand-authored schema file:
const [order] = await db
.select({ discountCode: orders.discountCode })
.from(orders)
.where(eq(orders.id, orderId));
// orders.discountCode is inferred as `string | null` because
// that's literally what the column constraint says right now
const normalizedCode = order.discountCode?.toUpperCase() ?? null;
The second version isn't smarter code — a disciplined developer could have written the null check in the first version too. The difference is that the type checker actually forces the question the moment the column's real nullability changes, because there's no separate file standing between the compiler and the database. Regenerating the introspected schema is a CI step now, not a habit someone has to remember.
What you give up going this route
I don't want to oversell this. Moving from Prisma to a SQL-shaped query builder is a real tradeoff, not a strict upgrade, and the things you lose are exactly the things that made Prisma popular in the first place. Nested relation writes, the automatic N+1-avoiding include syntax, and the polished migration UX all get noticeably more manual once you're writing joins by hand.
Where it clearly pays off is in exactly the failure mode we hit: anything involving raw introspection accuracy, edge-runtime deployment (no native binary to worry about), and queries where you want to see precisely what SQL is going to run rather than trust a query planner underneath the abstraction. Where it costs you is developer velocity on CRUD-heavy features with deep object graphs, which is most of what a typical SaaS backend actually does day to day.
| Dimension | Schema-first ORM (Prisma-style) | SQL-shaped builder (Drizzle-style) |
|---|---|---|
| Source of type truth | A separate schema file, regenerated on demand | Live database introspection |
| Behavior on schema drift | Silent — stale types compile fine | Caught on next introspection/CI run |
| Nested relation writes | Built-in, ergonomic | Manual, more verbose |
| Migration tooling maturity | Polished, opinionated | Lighter, closer to raw SQL migrations |
| Visibility into generated SQL | Abstracted away by default | Query shape mirrors the SQL directly |
Neither column is objectively better. It's a bet on which failure mode you'd rather manage: an ergonomic abstraction that can drift from reality, or a thinner layer that's more honest but makes you do more of the SQL thinking yourself.
Rolling it out without a full rewrite
We didn't rip Prisma out of the whole codebase in one pass — that's the kind of rewrite that turns into a six-month distraction and a very annoyed engineering manager. Instead we treated it as a strangler-fig migration, bounded context by bounded context, starting with the service that had actually paged us.
The order of operations mattered more than the tooling choice itself. We picked one service, proved the approach worked under real load for a couple of sprints, and only then wrote down guidance for other teams — instead of mandating a library switch from a slide deck before anyone had actually run it against production traffic.
- Picked the orders service first, since it was the one with a proven history of schema drift and the freshest incident to justify the work.
- Set up automated introspection in CI so the Drizzle schema regenerates and fails the build if it diverges from the actual staging database.
- Migrated read paths before write paths, since reads are where stale nullability assumptions actually blew up for us.
- Left low-traffic admin CRUD screens on Prisma, since the ergonomic win there outweighed the drift risk for internal tooling nobody pages on.
- Wrote a short internal doc on when to reach for which tool, instead of mandating one library across every team, because forcing a single dogmatic answer here felt like trading one blind spot for another.
That last point mattered more than I expected going in. The teams that pushed back hardest weren't wrong to — some of their services genuinely benefit more from Prisma's relation ergonomics than they'd ever suffer from drift risk, because their schema almost never changes outside the migration pipeline.
What I'd actually check before doing this again
If I were starting a new service today, I wouldn't default to "always use a SQL-shaped builder" as a blanket rule. I'd ask a much narrower question first: how many independent paths exist for changing this specific database's schema? A single-team service with one migration pipeline and no external scripts touching it directly is genuinely fine on Prisma or any schema-first ORM — the drift scenario we hit needs multiple actors with independent access to actually occur.
The number of teams and scripts that can touch a table directly is a better predictor of this kind of incident than the size of the codebase or the seniority of the engineers writing it. We'd been treating it as a code-quality problem when it was really an access-control and process problem wearing a type-error costume.
What I do now, regardless of which library a given service uses, is add a lightweight CI check that diffs the live schema against whatever the application believes it looks like, and fails the build on any mismatch. That single check would have caught our incident three weeks before it ever reached a customer-facing endpoint, and it costs a lot less than a full migration to a different query layer.
The honest takeaway isn't "ORMs are bad" or "always hand-roll your SQL." It's that type safety around a database is a claim about the present moment, not a permanent guarantee, and it's worth knowing exactly which command has to run — and who's responsible for running it — before you let a green checkmark convince you a field can never be null.

Comments
No comments yet — be the first to share your thoughts.