Database Schema Versioning: Flyway vs Liquibase vs Prisma Migrate in Production
Compare Flyway, Liquibase, and Prisma Migrate for production database schema versioning. Covers branching strategies, zero-downtime migrations, rollback patterns, and CI/CD integration.
Schema migrations are the most dangerous operation in production engineering. A bad migration can lock tables, corrupt data, or bring down your application โ and unlike a bad code deploy, you can't always just roll back. The tool you choose shapes how your team thinks about schema changes, and each of the major options makes very different tradeoffs.
This post compares Flyway, Liquibase, and Prisma Migrate across the dimensions that matter for production teams: migration authoring, branching support, rollback strategy, CI/CD integration, and zero-downtime patterns.
Tool Overview
| Flyway | Liquibase | Prisma Migrate | |
|---|---|---|---|
| Primary language | SQL (native) | XML/YAML/SQL | Prisma Schema DSL |
| Migration authoring | SQL files | Changesets (XML/YAML/SQL) | Auto-generated from schema diff |
| Rollback | Manual undo scripts | Rollback changesets | Manual + shadow DB |
| Branching support | File naming convention | Context/labels | Limited (linear history) |
| Best for | SQL-first teams, complex DB logic | Enterprise, multi-DB, compliance | TypeScript/Node.js full-stack teams |
| License | Community (free) + Teams ($) | Community (free) + Pro ($) | Free (MIT) |
1. Flyway
Flyway is the simplest tool: numbered SQL files applied in order. Its strength is that your migrations are plain SQL โ readable, portable, and debuggable without tooling.
File Naming Convention
db/migrations/
โโโ V1__create_users.sql
โโโ V2__add_users_email_index.sql
โโโ V3__create_accounts.sql
โโโ V3.1__add_accounts_plan.sql # Hotfix in same sprint
โโโ V4__create_subscriptions.sql
โโโ R__refresh_billing_view.sql # Repeatable migration (re-runs on change)
โโโ U4__undo_create_subscriptions.sql # Undo script (Flyway Teams only)
Version format: V{major}.{minor}.{patch}__{description}.sql
Rule: Never edit a migration once applied. Create a new one.
Flyway Configuration
# flyway.toml
[flyway]
url = "jdbc:postgresql://localhost:5432/myapp"
user = "${FLYWAY_DB_USER}"
password = "${FLYWAY_DB_PASSWORD}"
locations = ["filesystem:db/migrations"]
table = "flyway_schema_history"
baselineOnMigrate = false
validateOnMigrate = true
outOfOrder = false # Fail if applied versions have gaps
cleanDisabled = true # Never allow flyway clean in production
Docker Compose for Local Dev
# docker-compose.yml
services:
flyway:
image: flyway/flyway:10
command: migrate
environment:
FLYWAY_URL: jdbc:postgresql://postgres:5432/myapp
FLYWAY_USER: postgres
FLYWAY_PASSWORD: postgres
FLYWAY_LOCATIONS: filesystem:/flyway/sql
volumes:
- ./db/migrations:/flyway/sql
depends_on:
postgres:
condition: service_healthy
Production Migration Script
#!/usr/bin/env bash
# scripts/migrate.sh โ runs before app deploy
set -euo pipefail
echo "Running Flyway migrations..."
flyway \
-url="jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}" \
-user="${DB_USER}" \
-password="${DB_PASSWORD}" \
-locations="filesystem:db/migrations" \
-outOfOrder=false \
-validateOnMigrate=true \
migrate
echo "โ
Migrations complete"
flyway info # Print migration history
Branching with Flyway
Flyway is linear โ it doesn't natively handle parallel feature branches. The community convention is timestamp-based versioning:
V20261103120000__add_billing_portal.sql # Feature A (merged first)
V20261103143000__add_audit_log.sql # Feature B (merged second)
This avoids conflicts between feature branches since timestamps are naturally ordered.
# Helper to generate a new migration file with timestamp
new-migration() {
local description="${1:?Usage: new-migration <description>}"
local timestamp
timestamp=$(date +%Y%m%d%H%M%S)
local filename="db/migrations/V${timestamp}__${description// /_}.sql"
touch "$filename"
echo "Created: $filename"
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
2. Liquibase
Liquibase uses "changelogs" containing "changesets" โ logical units of schema change. The advantage is that Liquibase tracks which changesets have run (by ID, not filename), supports multi-database targeting, and has first-class rollback support.
Changelog Structure
# db/changelog/db.changelog-master.yaml
databaseChangeLog:
- include:
file: db/changelog/2026/11/001-create-users.yaml
- include:
file: db/changelog/2026/11/002-create-accounts.yaml
- include:
file: db/changelog/2026/11/003-add-billing.yaml
# db/changelog/2026/11/001-create-users.yaml
databaseChangeLog:
- changeSet:
id: 001-create-users
author: engineering-team
labels: v1.0
context: "!test" # Don't run in test environments
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: UUID
constraints:
primaryKey: true
nullable: false
defaultValueComputed: gen_random_uuid()
- column:
name: email
type: VARCHAR(255)
constraints:
nullable: false
unique: true
- column:
name: created_at
type: TIMESTAMPTZ
defaultValueComputed: NOW()
constraints:
nullable: false
- createIndex:
tableName: users
indexName: idx_users_email
columns:
- column:
name: email
rollback:
- dropTable:
tableName: users
SQL Changeset with Rollback
- changeSet:
id: 003-add-subscription-status-index
author: devops
comment: "Performance: filter subscriptions by status + account_id"
changes:
- sql:
sql: |
CREATE INDEX CONCURRENTLY idx_subscriptions_status_account
ON subscriptions (account_id, status)
WHERE status IN ('active', 'trialing');
splitStatements: false # Required for CONCURRENTLY
rollback:
- sql:
sql: DROP INDEX IF EXISTS idx_subscriptions_status_account;
Liquibase CI/CD Integration
# .github/workflows/migrate.yml
name: Database Migration
on:
push:
branches: [main]
paths:
- 'db/changelog/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate changelog (no DB needed)
run: |
docker run --rm \
-v ${{ github.workspace }}/db:/liquibase/changelog \
liquibase/liquibase:4.29 \
--changelog-file=changelog/db.changelog-master.yaml \
validate
migrate-staging:
runs-on: ubuntu-latest
needs: validate
environment: staging
steps:
- uses: actions/checkout@v4
- name: Run migrations
env:
LIQUIBASE_URL: ${{ secrets.STAGING_DB_URL }}
LIQUIBASE_USERNAME: ${{ secrets.STAGING_DB_USER }}
LIQUIBASE_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
run: |
docker run --rm \
-v ${{ github.workspace }}/db:/liquibase/changelog \
-e LIQUIBASE_URL \
-e LIQUIBASE_USERNAME \
-e LIQUIBASE_PASSWORD \
liquibase/liquibase:4.29 \
--changelog-file=changelog/db.changelog-master.yaml \
update
- name: Generate diff report
run: |
# Generate a report of what was applied
docker run --rm \
-v ${{ github.workspace }}/db:/liquibase/changelog \
-e LIQUIBASE_URL \
liquibase/liquibase:4.29 \
status --verbose
3. Prisma Migrate
Prisma Migrate generates SQL migrations automatically by diffing your schema.prisma against the current database state. It's the right choice when your team already uses Prisma as an ORM โ the schema is the single source of truth.
Workflow
# 1. Edit schema.prisma (add a column, index, relation, etc.)
# 2. Create a named migration
npx prisma migrate dev --name add_subscription_metadata
# Prisma:
# - Generates db/migrations/20261103120000_add_subscription_metadata/migration.sql
# - Applies it to dev database
# - Regenerates Prisma Client
# 3. Deploy to staging/prod
npx prisma migrate deploy # Only runs pending migrations โ never generates new ones
schema.prisma Best Practices
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["relationJoins"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL") // For pgBouncer: migrate uses directUrl
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique @db.VarChar(255)
name String? @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
account Account?
@@map("users")
@@index([createdAt(sort: Desc)])
}
model Subscription {
id String @id @default(uuid()) @db.Uuid
accountId String @unique @map("account_id") @db.Uuid
status SubStatus
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
currentPeriodEnd DateTime @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("subscriptions")
@@index([status, currentPeriodEnd], name: "idx_subscriptions_status_period")
}
enum SubStatus {
trialing
active
past_due
canceled
paused
@@map("subscription_status")
}
Generated Migration SQL
-- prisma/migrations/20261103120000_add_subscription_metadata/migration.sql
-- CreateEnum
CREATE TYPE "subscription_status" AS ENUM ('trialing', 'active', 'past_due', 'canceled', 'paused');
-- CreateTable
CREATE TABLE "subscriptions" (
"id" UUID NOT NULL,
"account_id" UUID NOT NULL,
"status" "subscription_status" NOT NULL,
"stripe_subscription_id" TEXT,
"current_period_end" TIMESTAMPTZ NOT NULL,
"cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_account_id_key" ON "subscriptions"("account_id");
CREATE UNIQUE INDEX "subscriptions_stripe_subscription_id_key" ON "subscriptions"("stripe_subscription_id");
CREATE INDEX "idx_subscriptions_status_period" ON "subscriptions"("status", "current_period_end");
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_account_id_fkey"
FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Handling Prisma Migrate in CI/CD
# .github/workflows/deploy.yml (relevant section)
- name: Run Prisma migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DIRECT_DATABASE_URL: ${{ secrets.DIRECT_DATABASE_URL }}
run: |
# deploy only โ never runs migrate dev in production
npx prisma migrate deploy
- name: Verify migration status
run: |
npx prisma migrate status
Critical: Never run prisma migrate dev in production or CI against a production database. It can reset the shadow database and cause downtime.
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Zero-Downtime Migration Patterns
These patterns apply regardless of which tool you use.
Pattern 1: Add Column โ Backfill โ Add NOT NULL Constraint
-- Step 1: Add nullable column (instant โ no lock on large tables)
ALTER TABLE users ADD COLUMN subscription_tier VARCHAR(20);
-- Step 2: Backfill in batches (run as separate migration or script)
DO $$
DECLARE
batch_size INT := 1000;
last_id UUID := '00000000-0000-0000-0000-000000000000';
rows_updated INT;
BEGIN
LOOP
WITH batch AS (
SELECT id FROM users
WHERE id > last_id
AND subscription_tier IS NULL
ORDER BY id
LIMIT batch_size
)
UPDATE users u
SET subscription_tier = 'free'
FROM batch
WHERE u.id = batch.id
RETURNING u.id INTO last_id;
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
PERFORM pg_sleep(0.05); -- Brief pause between batches
END LOOP;
END $$;
-- Step 3: Add NOT NULL constraint (deploy after backfill is verified)
ALTER TABLE users ALTER COLUMN subscription_tier SET NOT NULL;
ALTER TABLE users ALTER COLUMN subscription_tier SET DEFAULT 'free';
Pattern 2: Rename Column Without Downtime
Renaming a column while the app is running breaks existing queries. The safe sequence:
-- Migration 1: Add new column (deploy app version reads BOTH)
ALTER TABLE orders ADD COLUMN customer_id UUID;
UPDATE orders SET customer_id = user_id WHERE customer_id IS NULL;
-- Create index on new column
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);
-- Migration 2 (after app deployed reading new column): Drop old column
ALTER TABLE orders DROP COLUMN user_id;
Pattern 3: CREATE INDEX CONCURRENTLY
Never use CREATE INDEX on a table with active writes โ it takes an exclusive lock. Always use CONCURRENTLY:
-- Runs without blocking reads/writes (takes longer, uses more resources)
CREATE INDEX CONCURRENTLY idx_subscriptions_renewal
ON subscriptions (current_period_end)
WHERE status = 'active';
-- In Prisma: wrap in raw SQL in a separate migration
-- In Flyway: set splitStatements=false
-- In Liquibase: set splitStatements: false on the sql changeset
Detecting Lock Contention
-- Monitor for blocked migrations
SELECT
pid,
now() - query_start AS duration,
state,
wait_event_type,
wait_event,
left(query, 100) AS query_snippet
FROM pg_stat_activity
WHERE state != 'idle'
AND wait_event_type = 'Lock'
ORDER BY duration DESC;
-- Find what's holding the lock
SELECT
l.pid,
l.locktype,
l.relation::regclass AS table,
l.mode,
l.granted,
a.query,
now() - a.query_start AS duration
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE NOT l.granted
ORDER BY duration DESC;
Tool Selection Guide
Do you use Prisma as your ORM?
YES โ Prisma Migrate (schema is source of truth, DX is excellent)
NO โ Continue...
Do you need multi-database support (Postgres + MySQL + Oracle)?
YES โ Liquibase (best multi-DB support + enterprise compliance features)
NO โ Continue...
Do you prefer writing plain SQL?
YES โ Flyway (minimal abstraction, SQL-first, easy to debug)
NO โ Liquibase (XML/YAML changesets with built-in rollback)
Cost Reference
| Tool | License Cost | Hosting | Migration Runtime |
|---|---|---|---|
| Flyway Community | Free | Self-hosted (Docker) | Per-deploy (~seconds) |
| Flyway Teams | $500โ2K/year | Self-hosted | Per-deploy |
| Liquibase Community | Free | Self-hosted | Per-deploy |
| Liquibase Pro | $1Kโ5K/year | Self-hosted | Per-deploy |
| Prisma Migrate | Free (MIT) | npm script | Per-deploy |
| Atlas (alternative) | Free (community) | Self-hosted or Cloud | Per-deploy |
Operational cost: The real cost of schema migrations is downtime risk. A migration that locks a 100M-row table for 30 seconds on a busy system costs more than any tooling license.
See Also
- Zero-Downtime Database Migrations
- PostgreSQL Performance: Indexes, Query Plans, and Connection Pooling
- Prisma Production Patterns: N+1, Transactions, and Cursor Pagination
- Database Design Patterns for SaaS Applications
- Database Connection Pooling: PgBouncer and RDS Proxy
Working With Viprasol
Migrating a production database without a solid versioning strategy is one of the highest-risk operations in software engineering. We design migration workflows that fit your team's toolchain โ whether that's Prisma Migrate with zero-downtime patterns, Flyway in CI/CD pipelines, or Liquibase for multi-database enterprise environments.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.