Drizzle-Kit vs Atlas vs dbmate: DB Schema Migrations 2026
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 schema —
drizzle-kit generatecompares 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 DBs —
drizzle-kit introspectpulls schema from existing DB into TypeScript - Atlas can visualize schema — generates ERD diagrams from your schema
- dbmate tracks versions in a
schema_migrationstable — 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
| Feature | Drizzle-Kit | Atlas | dbmate |
|---|---|---|---|
| Language | TypeScript | HCL / SQL | Any (CLI) |
| Migration generation | ✅ Auto from ORM schema | ✅ Auto diff | ❌ Manual SQL |
| Drift detection | ❌ | ✅ | ❌ |
| ORM integration | ✅ Drizzle ORM | ❌ | ❌ |
| Plain SQL files | Generated SQL | ✅ | ✅ |
| Rollback | SQL files | ✅ | ✅ explicit |
| CI/CD lint | ❌ | ✅ destructive check | ❌ |
| Schema viz (ERD) | ❌ | ✅ | ❌ |
| DB introspection | ✅ | ✅ | ❌ |
| Multi-DB support | PG, MySQL, SQLite, LibSQL | PG, MySQL, SQLite, MSSQL, ClickHouse | PG, MySQL, SQLite, MSSQL |
| Dev dependency size | Medium | Standalone binary | Standalone binary |
| Learning curve | Low (if using Drizzle) | Medium | Very low |
| GitHub stars | 27k (Drizzle ORM) | 6.4k | 3.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/downSQL,schema_migrationstable, 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.