← Full-Stack: Zero to Depth
Full-stack DevFull-Stack: Zero to Depth

The Data Layer: Serverless Postgres, Drizzle, and Migrations That Don't Page You

Part 6 of the Zero to Depth series: where Postgres lives in 2026, the Drizzle query layer, connection physics from the edge, and the expand-contract migration pattern.

PostgresDrizzleDatabaseMigrationsNeonServerless

Everything in this stack so far has been stateless — edge isolates, server components, server actions: code that starts, answers, and dies. Statelessness is what makes them scale. It also means every one of them outsources the one thing they cannot do to the same place: the database. The data layer is the single stateful component in the architecture, and it quietly decides your system’s ceiling — for correctness, for latency, and for how painful your next hundred deploys will be.

Three decisions define this layer in 2026: where the database lives, what talks to it, and how its schema changes without waking anyone up.

Postgres won; the question is where it lives

The boring headline first: for relational workloads in this stack, Postgres is the default, full stop. The interesting question is the shape of the Postgres you rent:

  • Neon — serverless Postgres (now part of Databricks): storage separated from compute, so compute scales to zero when idle and a hobby project costs effectively nothing. Its signature feature is branching — copy-on-write database forks in seconds, so every pull request gets an isolated database with real data. Its neon-http driver answers the edge problem below.
  • Supabase — Postgres plus a full backend suite (Auth, Storage, Realtime), with Row Level Security as the standout: authorization rules enforced inside the database, referenced directly as auth.uid() in policies.
  • A regular managed Postgres (RDS, Cloud SQL, self-hosted) — still the right answer for most B2B SaaS: one region, predictable, cheapest per unit of work.

“Edge database” means three different things, and vendors blur them deliberately: globally-replicated reads (Neon read replicas: writes still go to one primary), local-first sync (Turso — SQLite-per-tenant, embedded replicas syncing to a primary; its 2026 direction is explicit push/pull sync), and Workers-native bindings (Cloudflare D1 — SQLite as env.DB, single-digit-ms reads inside Cloudflare’s network, but read-optimized and capped per database). The honest filter: with users on five continents and read-heavy traffic, these earn their complexity. For a regional B2B product, a normal Postgres in one region beats all three on simplicity and cost.

The query layer: Drizzle by default

The 2026 verdict from Part 1 holds and has only hardened: Drizzle ORM for new projects. The reasons are structural, not fashionable:

  • The schema is plain TypeScript — types are inferred live, with no generate step to forget, break in Docker builds, or leave stale.
  • Queries read like the SQL they produce, so there is no magic to debug at 2 a.m., and raw SQL is a first-class citizen, not an escape hatch.
  • It is ~7.4 KB gzipped with zero runtime dependencies, and it runs natively on edge runtimes — including Cloudflare Workers D1 and Bun’s SQLite — where a binary engine cannot go.
  • Benchmarks put it within noise of the raw driver (~2.3 ms vs 2.1 ms p95 on indexed reads), because that is all it is: SQL construction plus types.

Prisma 7 (November 2025) answered credibly: the Rust engine is gone, the client shrank ~90%, and its relation API remains the more expressive one for deeply nested reads — with the deepest documentation and community in the category. It stays the reasonable pick for teams who already know it or have messy relational models. The one true anti-pattern: two ORMs in one repo. Pick one schema source of truth.

The compounding win is drizzle-zod: one schema.ts generates SQL migrations (via drizzle-kit), compile-time types, and runtime validators for the boundary from Part 5. Database shape, type safety, and input validation can never drift apart, because they are the same file.

Connections are the physics problem

Postgres forks a process per connection and is comfortable with hundreds, not thousands, of them. Now do the arithmetic: an app on three hundred edge locations, each spawning connections per request, plus autoscaling serverless functions — you exhaust the database before breakfast. This is the constraint everything in this section exists to solve, and the two paths that solve it:

  1. HTTP drivers for the edge. Neon’s neon-http (and similar stateless drivers) speak to the database over HTTP — no persistent connection at all, which is exactly what a stateless isolate needs. The rules that trip everyone: use the HTTP dialect (drizzle-orm/neon-http, not node-postgres), and keep transactions short or use single-statement queries, since there is no session to hold them.
  2. Poolers for region servers. PgBouncer, pgcat, or Supavisor multiplex thousands of client connections onto dozens of real ones. Serverless Postgres vendors ship one built in — and the single most common production error is using the wrong connection string: app traffic goes through the pooled string (-pooler in the host); migrations go through the direct, unpooled one, because prepared statements and session-level features do not survive transaction-mode pooling.

For a plain Node server, postgres.js with a modest pool is the current default driver. The old pg package is a legacy signal in new code.

Migrations that don’t page you

Every experienced team has the same scar: an ALTER TABLE ... DROP COLUMN on a live, large table takes an ACCESS EXCLUSIVE lock, and a column rename becomes a 47-minute outage at 2 a.m. The pattern that eliminates this entire failure class is expand-and-contract (Martin Fowler’s ParallelChange): decompose any breaking change into three independently deployable, reversible phases.

  1. Expand — only ever add: the new column, the new table, the new index (created CONCURRENTLY on big tables so it does not lock writes). Nothing is dropped or renamed. Deploy.
  2. Migrate — backfill historical data in batches of 1,000–10,000 rows with deliberate pauses between batches (I/O saturation is how backfills take sites down), while the application dual-writes to old and new so the new column never falls behind. Then flip reads to the new column. Deploy.
  3. Contract — once the old path is provably dead, drop the old column. On modern Postgres this is a metadata-only operation: milliseconds, any time of day. Deploy.

Tooling is honest about its limits: drizzle-kit generate diffs your TypeScript schema into timestamped SQL files (review them — generated SQL occasionally needs hand-tuning), drizzle-kit push skips files entirely for prototyping, and neither it nor Prisma Migrate handles rollbacks for you — reversibility is something you design in, via the pattern above, not something the tool gives back. For tables in the hundreds of millions of rows, reach for the heavy machinery (pg_repack, gh-ost) and a database specialist, in that order.

Practice, then Part 7

  1. Create a free serverless Postgres, and run the same Drizzle query twice: once through the pooled connection string from a long-lived local server, once through the HTTP driver from an edge function. Measure the cold-start difference yourself.
  2. Write one table in schema.ts, generate the migration with drizzle-kit, derive the insert validator with drizzle-zod, and wire it into a Part 5-style endpoint. Notice there was one definition, not three.
  3. Take a rename you have been putting off and write it as three expand-and-contract PRs. Ship phase one today — it changes nothing user-visible, which is the point.

Part 7 leaves the machine room for the launchpad: deployment targets and what they actually cost, CI/CD that catches what your laptop forgives, observability that tells you before your users do, and rollback discipline for the day everything goes wrong anyway.

guest@swangnice:~$