Skip to main content

Drizzle-Kit vs Atlas vs dbmate: DB Schema Migrations 2026

·PkgPulse Team

Drizzle-Kit vs Atlas vs dbmate: DB Schema Migrations 2026

TL;DR

Schema migrations are one of the riskiest operations in any production system — the wrong migration can corrupt data or take down your database. Drizzle-Kit is the migration toolchain for Drizzle ORM — TypeScript-first, generates SQL from your ORM schema definitions, with an interactive UI for reviewing changes. Atlas is schema-as-code from HashiCorp veterans — it diffs your desired schema against your actual database, detects drift, supports HCL or SQL schema files, and integrates with CI/CD for automated migration checks. dbmate is the simple SQL migration runner — language-agnostic, uses plain .sql files with -- migrate:up and -- migrate:down markers, zero magic. For TypeScript/Drizzle ORM projects: Drizzle-Kit. For schema drift detection and CI/CD gates: Atlas. For simple teams that just want SQL files with versioning: dbmate.

Key Takeaways

  • Drizzle-Kit generates migrations from TypeScript schemadrizzle-kit generate compares schema to previous state, produces SQL
  • Atlas detects schema drift — compares live DB to desired state; fails CI if they diverge
  • dbmate uses plain SQL files — no magic, no framework, any language can use it
  • Atlas supports multiple databases — PostgreSQL, MySQL, SQLite, SQL Server, ClickHouse
  • Drizzle-Kit introspects existing DBsdrizzle-kit introspect pulls schema from existing DB into TypeScript
  • Atlas can visualize schema — generates ERD diagrams from your schema
  • dbmate tracks versions in a schema_migrations table — same pattern as Rails/Flyway

The Migration Workflow Problem

Database migrations need to be:

1. Reversible (rollback on failure)
2. Versioned (ordered, tracked, non-repeating)
3. Reviewable (humans should see what's changing)
4. Safe in CI/CD (fail fast before reaching production)
5. Team-friendly (no conflicts when multiple devs migrate)

Each tool solves these differently:

Drizzle-Kit: TypeScript schema → auto-generate SQL diffs
Atlas: Desired state → diff against DB → apply safely
dbmate: Manual SQL files → versioned runner

Drizzle-Kit: TypeScript-Native Migrations

Drizzle-Kit is the companion CLI for Drizzle ORM. Define your schema in TypeScript, and Drizzle-Kit generates the migration SQL automatically.

Installation

npm install drizzle-orm drizzle-kit
npm install postgres  # or pg, mysql2, better-sqlite3

Schema Definition

// src/db/schema.ts
import { pgTable, uuid, text, integer, timestamp, boolean, index } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  role: text("role", { enum: ["admin", "user", "moderator"] }).notNull().default("user"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});

export const posts = pgTable("posts", {
  id: uuid("id").primaryKey().defaultRandom(),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: text("content"),
  authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
  published: boolean("published").notNull().default(false),
  publishedAt: timestamp("published_at", { withTimezone: true }),
  viewCount: integer("view_count").notNull().default(0),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
  authorIdx: index("posts_author_idx").on(table.authorId),
  publishedIdx: index("posts_published_idx").on(table.published, table.publishedAt),
}));

export const tags = pgTable("tags", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull().unique(),
  slug: text("slug").notNull().unique(),
});

export const postTags = pgTable("post_tags", {
  postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
  tagId: uuid("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }),
});

Drizzle Config

// drizzle.config.ts
import type { Config } from "drizzle-kit";

export default {
  schema: "./src/db/schema.ts",
  out: "./drizzle",           // Migration files output directory
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,               // Require explicit confirmation for destructive changes
} satisfies Config;

Generating and Applying Migrations

# Generate migration based on schema changes
npx drizzle-kit generate

# This creates:
# drizzle/0000_initial.sql  ← SQL migration file
# drizzle/meta/             ← Schema snapshots for diffing

# Open interactive UI to review migrations
npx drizzle-kit studio

# Apply pending migrations
npx drizzle-kit migrate

# Or apply in your app code
// src/db/index.ts — programmatic migration
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";

const sql = postgres(process.env.DATABASE_URL!);
export const db = drizzle(sql);

// Run migrations on startup (or in a separate migration script)
export async function runMigrations() {
  console.log("Running migrations...");
  await migrate(db, { migrationsFolder: "./drizzle" });
  console.log("Migrations complete.");
}

Generated Migration SQL

-- drizzle/0000_initial.sql (auto-generated by drizzle-kit)
CREATE TABLE IF NOT EXISTS "users" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
  "email" text NOT NULL,
  "name" text NOT NULL,
  "role" text DEFAULT 'user' NOT NULL,
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
  CONSTRAINT "users_email_unique" UNIQUE("email")
);

CREATE TABLE IF NOT EXISTS "posts" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
  "title" text NOT NULL,
  "slug" text NOT NULL,
  "content" text,
  "author_id" uuid NOT NULL,
  "published" boolean DEFAULT false NOT NULL,
  "published_at" timestamp with time zone,
  "view_count" integer DEFAULT 0 NOT NULL,
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
  CONSTRAINT "posts_slug_unique" UNIQUE("slug")
);

CREATE INDEX IF NOT EXISTS "posts_author_idx" ON "posts" ("author_id");
CREATE INDEX IF NOT EXISTS "posts_published_idx" ON "posts" ("published","published_at");

ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk"
  FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;

Adding a Column (Schema Evolution)

// Modify schema.ts — add a bio field to users
export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  bio: text("bio"),                           // ← Added
  avatarUrl: text("avatar_url"),              // ← Added
  role: text("role", { enum: ["admin", "user", "moderator"] }).notNull().default("user"),
  // ...
});
npx drizzle-kit generate
# Generates:
# drizzle/0001_add_user_bio.sql
-- drizzle/0001_add_user_bio.sql (auto-generated)
ALTER TABLE "users" ADD COLUMN "bio" text;
ALTER TABLE "users" ADD COLUMN "avatar_url" text;

Introspecting an Existing Database

# Pull TypeScript schema from existing database
npx drizzle-kit introspect
# Creates src/db/schema.ts from your live database

Atlas: Schema-as-Code with Drift Detection

Atlas takes a declarative approach — you define your desired schema state, and Atlas figures out the migration SQL needed to reach it. Its killer feature is drift detection for CI/CD.

Installation

# macOS
brew install ariga/tap/atlas

# Linux / CI
curl -sSf https://atlasgo.sh | sh

Schema Definition (HCL)

# schema.hcl — declarative schema definition

schema "public" {}

table "users" {
  schema = schema.public

  column "id" {
    type = uuid
    default = sql("gen_random_uuid()")
  }
  column "email" {
    type = text
    null = false
  }
  column "name" {
    type = text
    null = false
  }
  column "role" {
    type    = enum("admin", "user", "moderator")
    default = "user"
    null    = false
  }
  column "created_at" {
    type    = timestamptz
    default = sql("NOW()")
    null    = false
  }

  primary_key {
    columns = [column.id]
  }
  index "users_email_unique" {
    columns = [column.email]
    unique  = true
  }
}

table "posts" {
  schema = schema.public

  column "id" {
    type    = uuid
    default = sql("gen_random_uuid()")
  }
  column "title" {
    type = text
    null = false
  }
  column "slug" {
    type = text
    null = false
  }
  column "author_id" {
    type = uuid
    null = false
  }
  column "published" {
    type    = boolean
    default = false
    null    = false
  }

  primary_key {
    columns = [column.id]
  }
  foreign_key "posts_author_id_fk" {
    columns     = [column.author_id]
    ref_columns = [table.users.column.id]
    on_delete   = CASCADE
  }
  index "posts_slug_unique" {
    columns = [column.slug]
    unique  = true
  }
}

Alternatively: SQL Schema

-- schema.sql — Atlas also accepts plain SQL schema files

CREATE TABLE "users" (
  "id"         UUID        NOT NULL DEFAULT gen_random_uuid(),
  "email"      TEXT        NOT NULL,
  "name"       TEXT        NOT NULL,
  "role"       TEXT        NOT NULL DEFAULT 'user',
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT "users_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "users_email_unique" UNIQUE ("email")
);

CREATE TABLE "posts" (
  "id"         UUID        NOT NULL DEFAULT gen_random_uuid(),
  "title"      TEXT        NOT NULL,
  "slug"       TEXT        NOT NULL,
  "author_id"  UUID        NOT NULL,
  "published"  BOOLEAN     NOT NULL DEFAULT FALSE,
  CONSTRAINT "posts_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "posts_slug_unique" UNIQUE ("slug"),
  CONSTRAINT "posts_author_id_fk" FOREIGN KEY ("author_id")
    REFERENCES "users" ("id") ON DELETE CASCADE
);

Atlas Config

# atlas.hcl

variable "database_url" {
  type    = string
  default = getenv("DATABASE_URL")
}

env "local" {
  src = "file://schema.hcl"
  url = var.database_url
  dev = "docker://postgres/16/dev?search_path=public"  # Ephemeral dev DB for diffing
}

env "production" {
  src = "file://schema.hcl"
  url = var.database_url
}

Plan and Apply Migrations

# Show what SQL Atlas will execute (dry run)
atlas schema apply --env local --dry-run

# Apply schema changes
atlas schema apply --env local

# Check for drift — fail if DB doesn't match schema
atlas schema diff --env local
# exits 0 if no drift, 1 if drift detected

# Generate a versioned migration file
atlas migrate diff add_user_bio \
  --dir "file://migrations" \
  --to "file://schema.hcl" \
  --dev-url "docker://postgres/16/dev"

# Apply versioned migrations
atlas migrate apply --env local

CI/CD Drift Detection

# .github/workflows/schema-check.yml
name: Schema Check

on:
  pull_request:
    paths:
      - "schema.hcl"
      - "migrations/**"

jobs:
  check-schema:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s

    steps:
      - uses: actions/checkout@v4

      - name: Install Atlas
        uses: ariga/setup-atlas@v0

      - name: Check for schema drift
        run: |
          atlas migrate validate \
            --dir "file://migrations" \
            --dev-url "postgres://postgres:postgres@localhost:5432/testdb"

      - name: Lint migrations (destructive check)
        run: |
          atlas migrate lint \
            --dir "file://migrations" \
            --dev-url "postgres://postgres:postgres@localhost:5432/testdb" \
            --latest 1

Schema Visualization

# Generate ERD diagram from your schema
atlas schema inspect \
  --url "postgres://..." \
  --format "{{ mermaid . }}"

# Output: Mermaid diagram of your tables and relationships

dbmate: Simple SQL Migration Runner

dbmate is a zero-magic migration runner — plain SQL files, versioned by timestamp, with -- migrate:up and -- migrate:down sections. Works with any language/framework.

Installation

# macOS
brew install dbmate

# Linux / CI
curl -fsSL -o /usr/local/bin/dbmate \
  https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-amd64
chmod +x /usr/local/bin/dbmate

# Or via npm (useful for Node projects)
npm install --save-dev dbmate

Creating Migrations

# Create a new migration file
dbmate new create_users_table

# Creates: db/migrations/20260309120000_create_users_table.sql
-- db/migrations/20260309120000_create_users_table.sql

-- migrate:up
CREATE TABLE users (
  id         UUID        NOT NULL DEFAULT gen_random_uuid(),
  email      TEXT        NOT NULL,
  name       TEXT        NOT NULL,
  role       TEXT        NOT NULL DEFAULT 'user',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT users_pkey PRIMARY KEY (id),
  CONSTRAINT users_email_unique UNIQUE (email)
);

CREATE INDEX users_email_idx ON users (email);

-- migrate:down
DROP TABLE IF EXISTS users;
-- db/migrations/20260309120001_create_posts_table.sql

-- migrate:up
CREATE TABLE posts (
  id          UUID        NOT NULL DEFAULT gen_random_uuid(),
  title       TEXT        NOT NULL,
  slug        TEXT        NOT NULL,
  content     TEXT,
  author_id   UUID        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  published   BOOLEAN     NOT NULL DEFAULT FALSE,
  view_count  INTEGER     NOT NULL DEFAULT 0,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT posts_pkey PRIMARY KEY (id),
  CONSTRAINT posts_slug_unique UNIQUE (slug)
);

CREATE INDEX posts_author_idx ON posts (author_id);
CREATE INDEX posts_published_idx ON posts (published, created_at);

-- migrate:down
DROP TABLE IF EXISTS posts;

Running Migrations

# Set database URL
export DATABASE_URL="postgres://user:pass@localhost:5432/myapp"

# Apply all pending migrations
dbmate up

# Rollback one migration
dbmate down

# Apply exactly one migration
dbmate up --one

# Show migration status
dbmate status

# Show current schema dump
dbmate dump

# Rollback and reapply latest migration (for development)
dbmate rollback && dbmate up --one

dbmate in package.json

{
  "scripts": {
    "db:migrate": "dbmate up",
    "db:rollback": "dbmate down",
    "db:status": "dbmate status",
    "db:new": "dbmate new",
    "db:reset": "dbmate drop && dbmate create && dbmate up"
  }
}

Using dbmate in CI/CD

# .github/workflows/test.yml
- name: Run migrations
  env:
    DATABASE_URL: postgres://postgres:postgres@localhost:5432/testdb
  run: |
    curl -fsSL -o /usr/local/bin/dbmate \
      https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-amd64
    chmod +x /usr/local/bin/dbmate
    dbmate up

dbmate Config File

# .dbmate (project root)
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
MIGRATIONS_DIR=db/migrations
SCHEMA_FILE=db/schema.sql  # Kept up-to-date after each migration

Feature Comparison

FeatureDrizzle-KitAtlasdbmate
LanguageTypeScriptHCL / SQLAny (CLI)
Migration generation✅ Auto from ORM schema✅ Auto diff❌ Manual SQL
Drift detection
ORM integration✅ Drizzle ORM
Plain SQL filesGenerated SQL
RollbackSQL files✅ explicit
CI/CD lint✅ destructive check
Schema viz (ERD)
DB introspection
Multi-DB supportPG, MySQL, SQLite, LibSQLPG, MySQL, SQLite, MSSQL, ClickHousePG, MySQL, SQLite, MSSQL
Dev dependency sizeMediumStandalone binaryStandalone binary
Learning curveLow (if using Drizzle)MediumVery low
GitHub stars27k (Drizzle ORM)6.4k3.1k

When to Use Each

Choose Drizzle-Kit if:

  • You're already using Drizzle ORM — it's the only migration tool that auto-generates from TypeScript schema definitions
  • You want TypeScript types and migrations from a single source of truth
  • Database introspection of an existing DB into TypeScript is needed
  • You want an interactive UI (drizzle-kit studio) to visualize and edit your schema

Choose Atlas if:

  • Schema drift detection in CI/CD is a requirement (fail PRs if DB doesn't match schema)
  • Destructive migration linting (catch column drops/table truncates before production)
  • You work with multiple database engines and want a consistent migration tool
  • ERD visualization of your schema is useful for the team
  • Your team isn't on Drizzle ORM but wants declarative schema management

Choose dbmate if:

  • Your team already writes SQL and prefers full control over migration files
  • Language-agnostic tooling is important (Go/Python/Ruby services sharing the same DB)
  • Simplicity is paramount — up/down SQL, schema_migrations table, done
  • You're migrating from Rails or any other framework with a similar migration pattern

Methodology

Data sourced from official Drizzle-Kit documentation (orm.drizzle.team/kit-docs), Atlas documentation (atlasgo.io), dbmate GitHub repository and documentation, GitHub star counts as of February 2026, and community discussions in the Drizzle Discord and the Atlas community Slack. Migration tool comparisons from the Node.js database ecosystem articles on PlanetScale and Neon blogs.


Related: Drizzle ORM vs Prisma vs TypeORM for the ORM layer that sits above these migration tools, or Neon vs Supabase Postgres vs Tembo for the PostgreSQL hosting platforms these migrations run against.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.