Skip to content

Data

ORMs and schema migrations

Drizzle and Prisma: typed query building and migration tooling, so the schema, the SQL and the TypeScript types stay in step.

An ORM in a TypeScript project does two jobs, and the second one matters more than the first. It builds queries with types attached, so a renamed column is a compile error rather than a runtime surprise. And it owns the migration history, which is the artefact that decides whether a schema change at four on a Friday is boring or interesting.

How we use it

ClassProfile uses Drizzle over Neon's Postgres from a Cloudflare Worker. The schema files are the source of truth: the profiles schema declares headline, bio, avatar, current position, a skills array and structured work history and education, and those types travel through the monorepo into the React front end, so the form and the table cannot disagree about what a profile is. Drizzle suits a Worker because it compiles to plain SQL and ships as ordinary JavaScript.

Starterflare uses Prisma against Postgres 16, and its schema is the most developed thing in the repository: 916 lines, about 45 models, covering tenancy, profiles, the social graph, content, jobs, messaging and moderation. That is a credential and a warning at once. The data model was finished long before the interface, which by then was eleven pages and two shared components, and a schema that complete with no product on top of it is a sign the modelling was more enjoyable than the shipping.

What it costs you

Prisma generates a client and, depending on version and configuration, ships an engine alongside it. On a container that is fine. Where cold start time and bundle size are the budget, it is a real weight, and the workarounds move with the releases.

Drizzle is thinner and closer to SQL, which we prefer, and the price is that more of the query is yours to get right. Its relational query API has changed shape between versions more than once, so an upgrade is a read-the-notes exercise.

Both hide the query behind a pleasant method call, and a select inside a loop looks exactly as innocent as one that is not. Anything on a hot path deserves having its generated SQL read once, with EXPLAIN, before it goes live.

Migrations are the part that ages. Generated migrations are excellent until production has drifted from the schema file, at which point you are reconciling by hand with a tool that assumes you have not. And the ORM tends to be the dependency that demands attention first on a project you have stopped actively maintaining, which is one of the patterns in what breaks first when you stop maintaining a side project.

Neither one saves you from knowing SQL. The first window function, recursive CTE or index hint takes you out to raw queries anyway, so the ORM has to be one you can escape from cleanly.

When we would choose something else

For a Worker with a modest number of tables we often skip the ORM entirely and write prepared statements against SQLite and D1 with a plain migrations folder. Objectify runs a sharded multi-tenant store that way, and there is no ORM anywhere in its stack, because routing a query to one of ten databases is simpler when you are holding the SQL yourself. Where the tables are already exposed as an API by Supabase, a client library over PostgREST does the job and an ORM on top is a second opinion nobody asked for. Where the data model is genuinely large and relational, PostgreSQL with a mature ORM over it is the right pairing.

Where we have used it

Every build below lists this in its stack, so the claim is checkable.