PostgreSQL Drizzle Backend Database

PostgreSQL + Drizzle ORM in Production: Lessons Learned

We've shipped multiple production applications using Drizzle ORM with PostgreSQL. Here's what we've learned about schema design, migrations, and query performance.

Rohid
Rohid
Founder & CEO
· July 10, 2024 · 9 min read

We switched from Prisma to Drizzle ORM on new projects about a year ago. After shipping several production applications, we have strong opinions about where Drizzle shines, where it requires care, and what PostgreSQL features it exposes beautifully.

Why We Switched

Prisma is excellent. We still use it on projects where it’s already established. But Drizzle fits our mental model better for new projects:

  1. SQL-first: Drizzle’s query builder maps closely to SQL. You write Drizzle, you know what SQL runs.
  2. No Prisma Client generation step: No build step, no generated client to cache/invalidate, no prisma generate in CI.
  3. TypeScript-native: Types come from your schema definition, not generated code.
  4. Raw SQL ergonomics: When you need raw SQL, it composes cleanly with Drizzle queries.

Schema Definition

Drizzle schemas are TypeScript. This is significant — your schema is code, not a DSL, which means you can use all of TypeScript’s expressive power.

import { pgTable, uuid, varchar, timestamp, index } from "drizzle-orm/pg-core";

export const shipments = pgTable(
  "shipments",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    trackingNumber: varchar("tracking_number", { length: 32 })
      .notNull()
      .unique(),
    status: varchar("status", { length: 20 }).notNull().default("pending"),
    carrierId: uuid("carrier_id").references(() => carriers.id),
    createdAt: timestamp("created_at").notNull().defaultNow(),
    updatedAt: timestamp("updated_at").notNull().defaultNow(),
  },
  (t) => [
    index("shipments_status_idx").on(t.status),
    index("shipments_carrier_id_idx").on(t.carrierId),
  ],
);

Note the index definitions inline with the table — this is one of our favorite Drizzle patterns. Indexes are co-located with the schema they’re part of, not scattered across migration files.

Migrations in Production

Drizzle’s migration story is simpler than Prisma’s. drizzle-kit generate produces plain SQL migration files. You review them. You apply them. No magic.

Our production migration process:

  1. drizzle-kit generate to produce the SQL
  2. Review the SQL — always. Never blindly apply generated migrations.
  3. Test on a staging database with production-scale data
  4. Apply with drizzle-kit migrate during a low-traffic window

For zero-downtime schema changes (adding columns, creating indexes concurrently), we write the migrations manually. Drizzle doesn’t prevent this — your migration file is SQL, so you can use CREATE INDEX CONCURRENTLY or any other Postgres-specific construct.

Query Performance Patterns

The N+1 Problem is Still Your Problem

Drizzle doesn’t automatically batch queries. The same N+1 pitfalls from raw SQL apply. We use Drizzle’s with for query composition and explicitly design our query access patterns before writing the first line.

// Fetch shipments with their carrier in one query
const shipments = await db.query.shipments.findMany({
  with: {
    carrier: true,
    trackingEvents: {
      orderBy: (events, { desc }) => [desc(events.occurredAt)],
      limit: 1,
    },
  },
  where: eq(shipments.status, "in_transit"),
});

Materialized Views

For the FinFlow analytics dashboard, we use PostgreSQL materialized views for pre-aggregated metrics. Drizzle can query views like tables — just define them in your schema:

export const dailyVolumeSummary = pgView("daily_volume_summary").as((qb) =>
  qb
    .select({
      date: sql<Date>`date_trunc('day', occurred_at)`,
      totalVolume: sql<number>`sum(amount)`,
      transactionCount: sql<number>`count(*)`,
    })
    .from(transactions)
    .groupBy(sql`date_trunc('day', occurred_at)`),
);

Connection Pooling

In serverless environments (Vercel, Cloudflare Workers), never use a standard pg connection pool. Each function invocation creates a new connection, and PostgreSQL’s max connection limit becomes the binding constraint.

We use @neondatabase/serverless or PgBouncer depending on the infrastructure. Neon’s HTTP driver is remarkably clean for serverless:

import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

What We’ve Learned

Review every generated migration. drizzle-kit generate is smart but not infallible. We’ve caught cases where a column rename was interpreted as drop + add, which would have destroyed data.

Use database-level constraints. Drizzle makes it easy to add notNull(), unique(), and foreign key references. Use them. Database constraints are your last line of defense against application bugs.

Measure query performance in staging. We run EXPLAIN ANALYZE on every significant query before production. Drizzle’s toSQL() method makes this easy:

const query = db
  .select()
  .from(shipments)
  .where(eq(shipments.status, "pending"));
console.log(query.toSQL()); // { sql: "...", params: [...] }

Conclusion

Drizzle ORM is our current default for new Node.js + PostgreSQL projects. It has rough edges (documentation gaps, occasional API surprises), but its fundamentals — SQL-first design, TypeScript-native types, and migration files you control — align well with how we think about database work.

The main caveat: if your team isn’t comfortable with SQL, Drizzle may not be the right choice. It exposes you to SQL, which is the point, but requires you to understand what you’re doing. If you want more magic, Prisma is still excellent.

Ready to build something great?

Tell us about your project. We'll get back to you within one business day to schedule a free discovery call.