Database schema migrations and versioning

Maria Garcia Feb 2026
2 tabs
-- Migration naming convention: V{version}__{description}.sql
-- Example: V001__create_users_table.sql

-- Migration 1: Create initial schema
-- V001__create_users_table.sql
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(50) UNIQUE NOT NULL,
  email VARCHAR(100) UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

-- Migration 2: Add column (backwards compatible)
-- V002__add_users_status.sql
ALTER TABLE users
  ADD COLUMN status VARCHAR(20) DEFAULT 'active';

-- Migration 3: Populate new column
-- V003__populate_users_status.sql
UPDATE users
SET status = 'active'
WHERE status IS NULL;

-- Migration 4: Add NOT NULL constraint
-- V004__make_status_not_null.sql
ALTER TABLE users
  ALTER COLUMN status SET NOT NULL;

-- Safe column rename (multi-step for zero downtime)

-- Step 1: Add new column
-- V005__add_users_full_name.sql
ALTER TABLE users
  ADD COLUMN full_name VARCHAR(100);

-- Step 2: Copy data
-- V006__populate_full_name.sql
UPDATE users
SET full_name = username
WHERE full_name IS NULL;

-- Step 3: Update application to use full_name
-- Deploy application code

-- Step 4: Drop old column
-- V007__drop_users_username.sql
ALTER TABLE users
  DROP COLUMN username;

-- Idempotent migration (safe to run multiple times)
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name = 'users' AND column_name = 'phone'
  ) THEN
    ALTER TABLE users ADD COLUMN phone VARCHAR(20);
  END IF;
END $$;

-- Conditional index creation
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);

-- Transaction-wrapped migration
BEGIN;

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INT REFERENCES users(id),
  total DECIMAL(10,2),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);

COMMIT;
-- If any statement fails, all changes are rolled back

-- Data migration with batching (for large tables)
DO $$
DECLARE
  batch_size INT := 1000;
  affected_rows INT;
BEGIN
  LOOP
    UPDATE users
    SET status = 'verified'
    WHERE id IN (
      SELECT id FROM users
      WHERE status = 'active' AND email_verified = true
      LIMIT batch_size
    );

    GET DIAGNOSTICS affected_rows = ROW_COUNT;
    EXIT WHEN affected_rows = 0;

    -- Commit batch and pause
    COMMIT;
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

-- Rollback migration (down migration)
-- V002__add_users_status__rollback.sql
ALTER TABLE users
  DROP COLUMN status;

-- Check migration status
-- Flyway command: flyway info
-- Shows: version, description, type, installed on, state

-- Schema version table (Flyway creates this)
SELECT * FROM flyway_schema_history
ORDER BY installed_rank;
2 files · sql Explain with highlit

Schema migrations evolve database structure safely. I use migration tools like Flyway, Liquibase, or framework migrations. Version-controlled migrations track schema changes. Up migrations apply changes, down migrations revert. Idempotent migrations can run multiple times safely. I avoid destructive changes—rename, don't drop. Backwards-compatible migrations enable zero-downtime deploys. Multi-step migrations: add column, deploy code, backfill data, add constraint. Transaction-wrapped migrations ensure atomicity. Understanding migration order prevents dependency issues. Blue-green deployments need schema compatibility. Testing migrations in staging catches errors. Migration rollback plans minimize downtime. Proper migration strategy enables continuous database evolution without production incidents.