perations, estimate lock durations, and validate data movement strategies without risking production state. This reduces the error surface area by decoupling intent from execution.
Core Solution
This solution implements a type-safe, declarative database version control workflow using TypeScript, drizzle-orm, and drizzle-kit. This stack provides shared types between application and database, automated migration generation, and safe execution strategies.
Architecture Decisions
- Schema-as-Code: The database schema is defined in TypeScript. This ensures type safety, allows IDE autocomplete, and enables the compiler to catch schema errors before runtime.
- Declarative Diffing:
drizzle-kit compares the current database state against the schema definition and generates a migration file containing the necessary SQL. This eliminates manual diffing.
- Immutable Migrations: Generated migrations are versioned in Git. Once applied, they are never modified. This ensures reproducibility across all environments.
- Zero-Downtime Strategy: For production, the system employs the Expand/Contract pattern to handle breaking changes without locking tables or causing application errors.
Step-by-Step Implementation
1. Project Initialization
Install dependencies and initialize the configuration.
npm install drizzle-orm pg
npm install -D drizzle-kit typescript @types/pg tsx
npx drizzle-kit init
2. Schema Definition
Define the schema in src/db/schema.ts. This file serves as the single source of truth.
import { pgTable, varchar, timestamp, integer, serial } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
// Example of a versioned field for expand/contract
// status: varchar('status', { length: 50 }).default('active'),
});
export const orders = pgTable('orders', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id).notNull(),
total: integer('total').notNull(), // Stored in cents
status: varchar('status', { length: 50 }).default('pending'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
3. Configuration
Configure drizzle.config.ts to define the schema path and database connection.
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
4. Migration Generation and Application
Create scripts in package.json for common operations.
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check"
}
}
db:generate: Creates a new SQL migration file in the drizzle directory based on schema changes.
db:migrate: Applies pending migrations to the database.
db:push: Pushes schema changes directly (useful for development only; never in CI/Prod).
db:check: Validates schema against the database without applying changes.
5. Zero-Downtime Migration Pattern
For breaking changes, use the Expand/Contract pattern.
Scenario: Rename orders.status to order_status and change type from varchar to enum.
Phase 1: Expand
- Add new column
order_status with new type.
- Update application code to write to both columns.
- Backfill data from
status to order_status.
-- Migration 001_expand.sql
ALTER TABLE orders ADD COLUMN order_status VARCHAR(50) DEFAULT 'pending';
-- Application writes to both columns
-- Background job copies data: UPDATE orders SET order_status = status WHERE order_status IS NULL;
Phase 2: Contract
- Update application code to read/write only
order_status.
- Drop old
status column.
-- Migration 002_contract.sql
ALTER TABLE orders DROP COLUMN status;
This pattern ensures zero downtime by decoupling schema changes from application deployment.
6. CI/CD Integration
Integrate drift detection into the pipeline.
# .github/workflows/db-check.yml
name: Database Schema Check
on: [pull_request]
jobs:
schema-check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci
- name: Check for drift
run: npx drizzle-kit check
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
The drizzle-kit check command fails the build if the schema definition diverges from the database state, preventing drift from entering production.
Pitfall Guide
1. Ignoring Lock Contention
Mistake: Running ALTER TABLE with ADD COLUMN or CREATE INDEX on large tables without considering locks.
Impact: Table locks block writes, causing application timeouts and cascading failures.
Best Practice: Use CREATE INDEX CONCURRENTLY and ADD COLUMN ... DEFAULT ... carefully. For large tables, use online schema change tools or the expand/contract pattern to avoid long locks. Monitor pg_stat_activity for lock waits during migrations.
2. Manual Patches in Production
Mistake: Executing ad-hoc SQL fixes directly in production to resolve urgent issues.
Impact: Schema drift. The production schema no longer matches the versioned migrations, causing future deployments to fail or behave unpredictably.
Best Practice: Never execute unversioned SQL in production. Create a migration script, test it in staging, and deploy it. If an emergency fix is required, script it, version it, and deploy via the standard pipeline.
3. Missing Data Migrations
Mistake: Changing schema constraints (e.g., adding NOT NULL) without ensuring existing data complies.
Impact: Migration fails with constraint violation errors, leaving the database in a partially applied state.
Best Practice: Always include data validation and migration steps. If adding a constraint, first backfill data or set a default, then apply the constraint in a subsequent migration. Use transactions to ensure atomicity where possible, but be wary of long transactions.
4. Testing with Insufficient Data Volume
Mistake: Running migrations on small test datasets that do not reflect production volume.
Impact: Migrations that run in milliseconds locally take hours in production, causing timeouts and locks.
Best Practice: Use production-like data volumes in staging. Implement migration timeouts and dry-run capabilities. Profile migration execution time and resource usage before applying to production.
5. Lack of Rollback Strategy
Mistake: Assuming migrations are irreversible or failing to test rollback procedures.
Impact: Inability to recover from failed migrations, leading to extended downtime.
Best Practice: Write reversible migrations. Test rollback scripts in staging. Implement a "migrate down" strategy in your tooling. For critical systems, maintain a snapshot backup before major schema changes.
6. Hardcoding Secrets in Configuration
Mistake: Committing database credentials to the repository or environment files.
Impact: Security breach. Unauthorized access to database.
Best Practice: Use environment variables or secret management tools (e.g., AWS Secrets Manager, HashiCorp Vault). Never commit .env files. Use CI/CD secret injection for pipeline execution.
7. Skipping Backward Compatibility
Mistake: Deploying schema changes that break the current running application version.
Impact: Application crashes due to missing columns or type mismatches.
Best Practice: Follow the expand/contract pattern. Ensure schema changes are backward compatible with the currently deployed application version. Coordinate database and application deployments to minimize the window of incompatibility.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup / MVP | Declarative with drizzle-kit | Rapid iteration, type safety, low overhead. | Low |
| Enterprise / Legacy | Imperative with Liquibase or Flyway | Strict audit trails, support for complex legacy schemas. | High |
| Multi-Region / High Scale | Declarative + Online Schema Change Tool | Minimizes lock contention, supports large tables. | Medium |
| Regulated Industry | Versioned Scripts + Manual Approval | Compliance requirements, auditability. | High |
| Microservices | Service-owned schemas + Declarative | Decoupling, independent deployment. | Medium |
Configuration Template
Ready-to-use drizzle.config.ts for a production TypeScript project.
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
// Optional: Custom migration naming strategy
migrations: {
table: '__drizzle_migrations__',
schema: 'public',
},
// Optional: Breakpoints for transaction control
breakpoints: false,
});
Quick Start Guide
- Initialize: Run
npx drizzle-kit init and configure drizzle.config.ts with your database URL.
- Define Schema: Create
src/db/schema.ts and define your tables using drizzle-orm syntax.
- Generate Migration: Run
npm run db:generate to create the initial migration file.
- Apply Migration: Run
npm run db:migrate to apply the migration to your database.
- Verify: Run
npm run db:check to confirm the schema is in sync. Commit the migration file to Git.
Database version control transforms database management from a risky, manual process into a reliable, automated workflow. By adopting schema-as-code, leveraging declarative diffing, and implementing zero-downtime patterns, teams can achieve high velocity without compromising data integrity or availability.