Mastering Drizzle ORM in TypeScript: Type-Safe Database Schema, Migrations, and Relational Queries with PostgreSQL

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Context & Why Drizzle ORM?

If you’ve spent any time with TypeScript backends, you’ve probably hit the same wall I did: your database layer and your TypeScript types live in two separate worlds. Write an interface, write a Prisma model, write a Zod schema — and when the database changes, you’re chasing down three places to update. Exhausting doesn’t cover it.

Drizzle ORM sidesteps this entirely. No separate schema language, no heavy runtime — your schema is your TypeScript. Define a table and you get the types for free. No code generation step, no mismatch between what the DB knows and what TypeScript knows.

I’ve been running this in production on a SaaS project handling around 150k daily queries. Six months in: zero runtime type surprises, predictable migration output, and queries that feel like writing TypeScript rather than SQL strings wrapped in template literals.

For teams that need SQL visibility without giving up type safety, Drizzle occupies a space nothing else quite fills. Drop to raw SQL whenever you need to — the ORM won’t fight you. That flexibility is genuinely rare.

Here’s what makes it stand out:

  • Schema-first with TypeScript — your table definitions are just TypeScript objects
  • drizzle-kit — a CLI tool that generates and runs SQL migrations from your schema diff
  • Relational query API — declare relations once, then query nested data without writing JOINs manually
  • Thin runtime — no magic, no hidden queries, predictable performance

Installation

Start with a fresh TypeScript project, or drop into an existing one. You need Node.js 18+ and a running PostgreSQL instance.

Install dependencies

npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg tsx typescript
  • drizzle-orm — the main library
  • pg — PostgreSQL driver (Drizzle supports multiple drivers)
  • drizzle-kit — the migration CLI
  • tsx — lets you run TypeScript files directly during development

Initialize TypeScript

npx tsc --init

Make sure your tsconfig.json has at least these settings:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "strict": true,
    "outDir": "./dist"
  }
}

Configuration

Step 1 — Define your database connection

Create src/db/index.ts to hold the connection and export the db instance:

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const db = drizzle(pool, { schema });

Store your connection string in a .env file:

DATABASE_URL=postgresql://user:password@localhost:5432/mydb

Step 2 — Write your schema

Open (or create) src/db/schema.ts. Table definitions are plain TypeScript — no decorators, no special syntax:

import { pgTable, serial, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').defaultNow(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  body: text('body').notNull(),
  authorId: integer('author_id').notNull().references(() => users.id),
  createdAt: timestamp('created_at').defaultNow(),
});

Relations come next. Declare them so Drizzle knows how to join tables without you spelling it out every time:

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

No string magic, no manual type annotations. TypeScript infers the full shape from column definitions alone.

Step 3 — Configure drizzle-kit

Add a drizzle.config.ts at the project root:

import type { Config } from 'drizzle-kit';

export default {
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
} satisfies Config;

Wire up the scripts in package.json:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio",
    "db:push": "drizzle-kit push"
  }
}

Step 4 — Generate and run migrations

Once your schema is ready, generate the SQL migration:

npm run db:generate

Drizzle diffs your schema against the last known state and writes a SQL file under ./drizzle/. Open it. You’ll see clean, readable SQL — no abstractions hiding what’s actually happening.

Then apply it:

npm run db:migrate

During development, db:push skips the migration file step and applies changes directly to the database. Useful for fast iteration when you don’t need a migration history yet.

Step 5 — Write type-safe queries

With the schema wired up, every query carries full TypeScript inference. Create src/index.ts:

import 'dotenv/config';
import { db } from './db';
import { users, posts } from './db/schema';
import { eq } from 'drizzle-orm';

// Insert a user
const [newUser] = await db.insert(users).values({
  name: 'Alice',
  email: '[email protected]',
}).returning();

console.log(newUser.id); // TypeScript knows this is a number

// Insert a post linked to that user
await db.insert(posts).values({
  title: 'My First Post',
  body: 'Hello from Drizzle ORM',
  authorId: newUser.id,
});

// Query with relation (no manual JOIN needed)
const result = await db.query.users.findFirst({
  where: eq(users.id, newUser.id),
  with: {
    posts: true,
  },
});

console.log(result?.posts); // TypeScript infers the full shape

Run it:

npx tsx src/index.ts

The with API in db.query.* uses the relations you declared earlier. Under the hood, Drizzle generates an efficient JOIN — you get nested objects back instead of flat rows.

Step 6 — Filter and select specific columns

Fetching every column is rarely what you want. Drizzle’s select API makes partial selects type-safe too:

const titles = await db
  .select({ title: posts.title, author: users.name })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(eq(users.name, 'Alice'));

// titles is typed as { title: string; author: string }[]

No casting, no as any. The return type narrows automatically to exactly what you selected.

Verification & Monitoring

Drizzle Studio — visual DB explorer

Drizzle ships a browser-based UI you can spin up locally:

npm run db:studio

Open https://local.drizzle.studio. Browse rows, inspect tables, run queries manually — handy during development to confirm that migrations landed correctly.

Check migration history

Applied migrations are tracked in a __drizzle_migrations table. Query it to see what’s been applied:

psql $DATABASE_URL -c "SELECT * FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 10;"

Each file in ./drizzle/ gets a matching entry once it runs. Missing from the table? It hasn’t been applied yet.

Log SQL queries during development

Pass a logger to drizzle() to see the actual SQL being executed:

export const db = drizzle(pool, {
  schema,
  logger: true, // prints all SQL to stdout
});

Turn it off in production. This is the fastest way to catch N+1 issues early — if you see the same query repeating in a loop, you need to push it outside or batch it.

Handling schema changes safely

Update a column type or add a constraint? Run db:generate again. Drizzle diffs against the previous snapshot and writes an incremental migration file.

Read the generated SQL before running db:migrate — especially for column drops or type changes that could cause data loss:

# Preview what changed before applying
npm run db:generate
cat drizzle/0001_*.sql  # read the generated SQL
npm run db:migrate      # apply after review

For production deploys, run migrations in your CI/CD pipeline before starting the application — not on application startup. This keeps deploys atomic and avoids race conditions when scaling horizontally.

Quick sanity check script

A simple health check to confirm the DB connection and schema are in sync:

// src/db/health.ts
import { db } from '.';
import { sql } from 'drizzle-orm';

export async function checkDb() {
  const result = await db.execute(sql`SELECT 1 AS ok`);
  console.log('DB connected:', result.rows[0]);
}

Run it after each deploy to confirm connectivity before serving traffic.

Where to Go Next

Once this setup is running, the obvious next moves are adding transactions (db.transaction()), tuning your connection pool size to match your actual load, and exploring Drizzle’s prepared statements for high-frequency queries. The official docs cover all of these thoroughly.

What struck me most after going through this setup: the gap between the database and TypeScript gets genuinely small. Change a column in the schema and TypeScript immediately flags every query that relies on it — before you touch the database. That alone has caught several bugs that would have only surfaced in production with a more traditional setup.

Share: