Migrations¶
pycopg includes a simple SQL-based migration system for managing database schema changes.
Quick Start¶
1. Create Migration Directory¶
mkdir migrations
2. Create Migration Files¶
-- migrations/001_create_users.sql
-- UP
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- DOWN
DROP TABLE users;
3. Run Migrations¶
from pycopg import Database, Migrator
db = Database.from_env()
migrator = Migrator(db, "migrations/")
# Check status
status = migrator.status()
print(f"Applied: {status['applied_count']}, Pending: {status['pending_count']}")
# Run pending migrations
applied = migrator.migrate()
for m in applied:
print(f"Applied: {m}")
Migration File Format¶
File Naming¶
Migration files must follow this naming convention:
NNN_description.sql
NNN: Version number (1, 01, 001, etc.)description: Snake_case description.sql: Required extension
Examples:
001_create_users.sql002_add_email_column.sql003_create_orders.sql
File Structure¶
Migrations can have optional UP and DOWN sections:
-- Migration: create_users
-- Created: 2024-01-15
-- UP
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
-- DOWN
DROP TABLE users;
If no UP/DOWN sections are present, the entire file is treated as the UP migration.
Migrator API¶
Creating a Migrator¶
from pycopg import Database, Migrator
db = Database.from_env()
migrator = Migrator(db, "migrations/")
# With custom tracking table
migrator = Migrator(db, "migrations/", table="my_migrations")
Checking Status¶
status = migrator.status()
# {
# 'applied_count': 5,
# 'pending_count': 2,
# 'applied': [
# {'version': 1, 'name': 'create_users', 'applied_at': datetime(...)},
# ...
# ],
# 'pending': [
# {'version': 6, 'name': 'add_audit_log'},
# {'version': 7, 'name': 'create_reports'},
# ]
# }
Running Migrations¶
# Run all pending migrations
applied = migrator.migrate()
for m in applied:
print(f"Applied migration: {m.version:03d}_{m.name}")
# Run up to a specific version
applied = migrator.migrate(target=5)
Rolling Back¶
# Rollback last migration
rolled_back = migrator.rollback()
# Rollback last 3 migrations
rolled_back = migrator.rollback(steps=3)
for info in rolled_back:
print(f"Rolled back: {info['version']:03d}_{info['name']}")
Creating New Migrations¶
# Create a new migration file
path = migrator.create("add_orders_table")
print(f"Created: {path}")
# Created: migrations/006_add_orders_table.sql
The created file includes a template:
-- Migration: add_orders_table
-- Created: 2024-01-15T10:30:00
-- UP
-- Write your migration SQL here
-- DOWN
-- Write your rollback SQL here (optional)
Listing Migrations¶
# Get pending migrations
pending = migrator.pending()
for m in pending:
print(f"Pending: {m.version:03d}_{m.name}")
# Get applied migrations
applied = migrator.applied()
for info in applied:
print(f"Applied: {info['version']:03d}_{info['name']} at {info['applied_at']}")
Migration Tracking¶
Migrations are tracked in a database table (default: schema_migrations):
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Examples¶
Creating Tables¶
-- 001_create_users.sql
-- UP
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
-- DOWN
DROP TABLE users;
Adding Columns¶
-- 002_add_user_profile.sql
-- UP
ALTER TABLE users ADD COLUMN full_name TEXT;
ALTER TABLE users ADD COLUMN avatar_url TEXT;
ALTER TABLE users ADD COLUMN bio TEXT;
-- DOWN
ALTER TABLE users DROP COLUMN bio;
ALTER TABLE users DROP COLUMN avatar_url;
ALTER TABLE users DROP COLUMN full_name;
Creating Relations¶
-- 003_create_orders.sql
-- UP
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total_amount DECIMAL(10, 2) NOT NULL,
status TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
-- DOWN
DROP TABLE orders;
Data Migrations¶
-- 004_backfill_user_slugs.sql
-- UP
ALTER TABLE users ADD COLUMN slug TEXT;
UPDATE users SET slug = LOWER(REPLACE(username, ' ', '-'));
ALTER TABLE users ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX idx_users_slug ON users(slug);
-- DOWN
DROP INDEX idx_users_slug;
ALTER TABLE users DROP COLUMN slug;
Complex Migrations¶
-- 005_restructure_permissions.sql
-- UP
-- Create new tables
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
permissions JSONB DEFAULT '[]'
);
CREATE TABLE user_roles (
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- Migrate existing data
INSERT INTO roles (name, permissions)
SELECT DISTINCT role, '[]'::jsonb
FROM users
WHERE role IS NOT NULL;
INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id
FROM users u
JOIN roles r ON u.role = r.name
WHERE u.role IS NOT NULL;
-- Remove old column
ALTER TABLE users DROP COLUMN role;
-- DOWN
ALTER TABLE users ADD COLUMN role TEXT;
UPDATE users SET role = (
SELECT r.name FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = users.id
LIMIT 1
);
DROP TABLE user_roles;
DROP TABLE roles;
Error Handling¶
from pycopg.exceptions import MigrationError
try:
migrator.migrate()
except MigrationError as e:
print(f"Migration failed: {e}")
# The failed migration is not marked as applied
# Fix the issue and run again
Best Practices¶
1. Make Migrations Idempotent¶
-- Good: Use IF NOT EXISTS
CREATE TABLE IF NOT EXISTS users (...);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Bad: Will fail if run twice
CREATE TABLE users (...);
2. Always Include DOWN Migrations¶
-- UP
ALTER TABLE users ADD COLUMN phone TEXT;
-- DOWN
ALTER TABLE users DROP COLUMN phone;
3. Keep Migrations Small and Focused¶
001_create_users.sql # One table
002_create_orders.sql # One table
003_add_user_email_index.sql # One index
4. Never Modify Applied Migrations¶
Once a migration is applied, create a new migration for changes:
001_create_users.sql # Original
002_fix_users_email.sql # Fix instead of modifying 001
5. Test Migrations Both Directions¶
# Apply
migrator.migrate()
# Rollback
migrator.rollback()
# Re-apply
migrator.migrate()