Database Migrations with Drizzle ORM in TypeScript
Back to blog
drizzleormdatabasemigrationstypescript

Database Migrations with Drizzle ORM in TypeScript

Comprehensive guide to managing database schema migrations using Drizzle ORM, including version control, rollbacks, and production deployment strategies.

February 20, 2025
5 min read

Database Migrations with Drizzle ORM in TypeScript

Drizzle ORM provides a type-safe, SQL-first approach to database migrations. This guide covers schema definition, migration generation, version control, and production deployment.

Why Drizzle ORM?

Drizzle combines the flexibility of SQL with type safety of TypeScript. Its migration system generates SQL statements automatically while maintaining full control over database changes. Perfect for teams that want explicit, reviewable migrations.

Prerequisites

  • Node.js 18+ with TypeScript
  • PostgreSQL 12+ (or MySQL, SQLite)
  • Git for version control
  • Drizzle CLI installed

Step 1: Install Drizzle ORM and Dependencies

npm install drizzle-orm pg dotenv
npm install -D drizzle-kit typescript ts-node tsx

For MySQL:

npm install mysql2

For SQLite:

npm install better-sqlite3

Step 2: Configure Drizzle Project

Create drizzle.config.ts:

import type { Config } from 'drizzle-kit';
import * as dotenv from 'dotenv';

dotenv.config();

export default {
  schema: './src/db/schema.ts',
  out: './src/db/migrations',
  driver: 'pg',
  dbCredentials: {
    connectionString: process.env.DATABASE_URL || '',
  },
  verbose: true,
  strict: true,
} satisfies Config;

Create .env:

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

Step 3: Define Database Schema

Create src/db/schema.ts:

import {
  pgTable,
  serial,
  varchar,
  text,
  integer,
  boolean,
  timestamp,
  decimal,
  uniqueIndex,
  index,
  foreignKey,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { relations } from 'drizzle-orm';

// Users table
export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    email: varchar('email', { length: 255 }).unique().notNull(),
    username: varchar('username', { length: 100 }).unique().notNull(),
    passwordHash: varchar('password_hash', { length: 255 }).notNull(),
    firstName: varchar('first_name', { length: 100 }),
    lastName: varchar('last_name', { length: 100 }),
    bio: text('bio'),
    isVerified: boolean('is_verified').default(false).notNull(),
    isActive: boolean('is_active').default(true).notNull(),
    role: varchar('role', { length: 50 }).default('user').notNull(),
    lastLoginAt: timestamp('last_login_at'),
    createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
    updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
  },
  (table) => ({
    emailIdx: uniqueIndex('idx_users_email').on(table.email),
    usernameIdx: index('idx_users_username').on(table.username),
    isActiveIdx: index('idx_users_is_active').on(table.isActive),
  })
);

// Posts table
export const posts = pgTable(
  'posts',
  {
    id: serial('id').primaryKey(),
    userId: integer('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: varchar('title', { length: 255 }).notNull(),
    slug: varchar('slug', { length: 255 }).unique().notNull(),
    content: text('content').notNull(),
    excerpt: text('excerpt'),
    published: boolean('published').default(false).notNull(),
    viewCount: integer('view_count').default(0).notNull(),
    createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
    updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
    publishedAt: timestamp('published_at'),
  },
  (table) => ({
    userIdIdx: index('idx_posts_user_id').on(table.userId),
    slugIdx: index('idx_posts_slug').on(table.slug),
    publishedIdx: index('idx_posts_published').on(table.published),
    createdAtIdx: index('idx_posts_created_at').on(table.createdAt),
  })
);

// Comments table
export const comments = pgTable(
  'comments',
  {
    id: serial('id').primaryKey(),
    postId: integer('post_id')
      .notNull()
      .references(() => posts.id, { onDelete: 'cascade' }),
    userId: integer('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    content: text('content').notNull(),
    likes: integer('likes').default(0).notNull(),
    createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
    updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
  },
  (table) => ({
    postIdIdx: index('idx_comments_post_id').on(table.postId),
    userIdIdx: index('idx_comments_user_id').on(table.userId),
  })
);

// Tags table
export const tags = pgTable(
  'tags',
  {
    id: serial('id').primaryKey(),
    name: varchar('name', { length: 100 }).unique().notNull(),
    slug: varchar('slug', { length: 100 }).unique().notNull(),
    createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
  },
  (table) => ({
    slugIdx: index('idx_tags_slug').on(table.slug),
  })
);

// Posts to Tags junction table
export const postsTags = pgTable(
  'posts_tags',
  {
    postId: integer('post_id')
      .notNull()
      .references(() => posts.id, { onDelete: 'cascade' }),
    tagId: integer('tag_id')
      .notNull()
      .references(() => tags.id, { onDelete: 'cascade' }),
  },
  (table) => ({
    pk: table.primaryKey({ columns: [table.postId, table.tagId] }),
  })
);

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

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.userId],
    references: [users.id],
  }),
  comments: many(comments),
  tags: many(postsTags),
}));

export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
  author: one(users, {
    fields: [comments.userId],
    references: [users.id],
  }),
}));

Step 4: Generate Initial Migration

Generate migration files from schema:

npx drizzle-kit generate:pg

This creates migration files in src/db/migrations/ with SQL statements.

Example migration output (0000_initial.sql):

CREATE TABLE "users" (
  "id" SERIAL PRIMARY KEY,
  "email" VARCHAR(255) UNIQUE NOT NULL,
  "username" VARCHAR(100) UNIQUE NOT NULL,
  "password_hash" VARCHAR(255) NOT NULL,
  ...
);

CREATE INDEX "idx_users_email" on "users" ("email");

Step 5: Run Migrations

Create migration runner (src/db/migrate.ts):

import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import path from 'path';
import * as dotenv from 'dotenv';

dotenv.config();

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

  const db = drizzle(pool);

  console.log('Running migrations...');

  await migrate(db, {
    migrationsFolder: path.join(__dirname, './migrations'),
  });

  console.log('Migrations completed');
  await pool.end();
};

runMigrations().catch((err) => {
  console.error('Migration failed:', err);
  process.exit(1);
});

Add to package.json:

{
  "scripts": {
    "db:migrate": "ts-node src/db/migrate.ts",
    "db:generate": "drizzle-kit generate:pg",
    "db:drop": "drizzle-kit drop"
  }
}

Run migrations:

npm run db:migrate

Step 6: Create Database Client

Create src/db/index.ts:

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

dotenv.config();

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

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

// Graceful shutdown
process.on('SIGINT', async () => {
  console.log('Closing database connection...');
  await pool.end();
  process.exit(0);
});

export { pool };

Step 7: Use Database in Application

Example query (src/services/userService.ts):

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

export async function getUserWithPosts(userId: number) {
  return await db.query.users.findFirst({
    where: eq(users.id, userId),
    with: {
      posts: {
        orderBy: desc(posts.createdAt),
      },
    },
  });
}

export async function createUser(userData: {
  email: string;
  username: string;
  passwordHash: string;
}) {
  const result = await db.insert(users).values(userData).returning();
  return result[0];
}

export async function updateUserLastLogin(userId: number) {
  await db
    .update(users)
    .set({ lastLoginAt: new Date() })
    .where(eq(users.id, userId));
}

export async function getPublishedPosts(limit: number = 10) {
  return await db.query.posts.findMany({
    where: eq(posts.published, true),
    orderBy: desc(posts.createdAt),
    limit,
    with: {
      author: true,
      comments: {
        limit: 3,
      },
    },
  });
}

Step 8: Schema Modifications

When you need to modify the schema, update schema.ts then generate migration:

Add New Column

// In schema.ts - add to users table
avatar: varchar('avatar', { length: 255 }),

Generate migration:

npm run db:generate

Review generated migration, then run:

npm run db:migrate

Rename Column

Drizzle requires explicit renaming:

// Old column name
oldColumnName: varchar('old_name'),

// Update to
columnNameRenamed: varchar('column_name_renamed'),

In migration file, add manual SQL:

ALTER TABLE "users" RENAME COLUMN "old_name" TO "column_name_renamed";

Add Foreign Key

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  authorId: integer('author_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  // ...
});

Step 9: Production Migration Strategy

Create safe migration script (scripts/migrate-production.sh):

#!/bin/bash

set -e

echo "Starting production migration..."
echo "Database: $DATABASE_URL"

# Create backup
echo "Creating backup..."
pg_dump $DATABASE_URL > backup-$(date +%Y%m%d-%H%M%S).sql

# Run migrations
echo "Running migrations..."
npm run db:migrate

echo "Migration completed successfully"

Make executable:

chmod +x scripts/migrate-production.sh

Step 10: Seed Database

Create seed script (src/db/seed.ts):

import { db } from './index';
import { users, tags, posts } from './schema';
import * as dotenv from 'dotenv';

dotenv.config();

async function seed() {
  console.log('Seeding database...');

  // Create users
  const createdUsers = await db
    .insert(users)
    .values([
      {
        email: 'alice@example.com',
        username: 'alice',
        passwordHash: '$2a$10$...',
        firstName: 'Alice',
      },
      {
        email: 'bob@example.com',
        username: 'bob',
        passwordHash: '$2a$10$...',
        firstName: 'Bob',
      },
    ])
    .returning();

  // Create tags
  const createdTags = await db
    .insert(tags)
    .values([
      { name: 'TypeScript', slug: 'typescript' },
      { name: 'Node.js', slug: 'nodejs' },
      { name: 'Databases', slug: 'databases' },
    ])
    .returning();

  console.log('Database seeded successfully');
}

seed().catch((err) => {
  console.error('Seed failed:', err);
  process.exit(1);
});

Add to package.json:

{
  "scripts": {
    "db:seed": "ts-node src/db/seed.ts"
  }
}
Migration TypePurposeRisk Level
Add ColumnExtend schema with new fieldsLow
Drop ColumnRemove unused fieldsHigh
Rename ColumnUpdate field namesMedium
Create IndexImprove query performanceLow
Add Foreign KeyEstablish relationshipsMedium

Best Practices

Always Review Migrations: Before applying to production

# Preview migration SQL
cat src/db/migrations/0001_migrations.sql

Test on Staging First: Always test migrations on staging environment

DATABASE_URL=postgresql://staging-db npm run db:migrate

Backup Before Major Changes: For production deployments

pg_dump $DATABASE_URL > backup.sql

Use Transactions: Ensure atomic operations

await db.transaction(async (tx) => {
  await tx.update(users).set({ role: 'admin' }).where(...);
  await tx.insert(auditLog).values(...);
});

Version Control: Commit migrations alongside code changes

git add src/db/schema.ts src/db/migrations/
git commit -m "feat: add user profiles table"

Troubleshooting

Migration Already Applied Error:

# Check migration status
npx drizzle-kit generate:pg --casing camelCase

# Reset if needed (caution!)
npx drizzle-kit drop

Type Errors in Queries:

Ensure schema exports are updated:

npm run db:generate

Connection Pooling Issues:

Verify connection string:

psql $DATABASE_URL -c "SELECT 1"

Useful Resources

Conclusion

Drizzle ORM provides a type-safe, SQL-first migration system perfect for production applications. Combine with version control, testing, and backup strategies for safe, auditable database evolution.