Introduction
In a schema-per-tenant architecture, applying database migrations is more complex than in a single-schema setup. When you add a column or create an index, that change must be applied to every tenant schema. This lesson covers strategies for reliable, scalable migrations across hundreds or thousands of tenant schemas.
Key Concepts
- Iterative Migration: A function that loops through all tenant schemas and applies the same DDL to each one.
- Migration Tracking: Recording which migrations have been applied to which schemas, so failed partial migrations can be resumed.
- Concurrent Index Creation: Using
CREATE INDEX CONCURRENTLYto avoid locking tables during migrations.
Real World Context
Your SaaS platform has 500 tenant schemas and you need to add a phone column to the users table in every schema. Running 500 ALTER TABLE statements manually is not an option. You need an automated, resumable migration process that handles failures gracefully — because schema 347 might fail due to a constraint conflict while the other 499 succeed.
Deep Dive
Iterative Migration Function
The core pattern is a function that iterates over tenant schemas and applies DDL:
sqlCREATE OR REPLACE FUNCTION migrate_all_tenants(migration_sql TEXT) RETURNS TABLE(schema_name TEXT, success BOOLEAN, error_message TEXT) AS $$ DECLARE s TEXT; BEGIN FOR s IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant_%' LOOP BEGIN EXECUTE format('SET LOCAL search_path TO %I, public', s); EXECUTE migration_sql; schema_name := s; success := true; error_message := NULL; RETURN NEXT; EXCEPTION WHEN OTHERS THEN schema_name := s; success := false; error_message := SQLERRM; RETURN NEXT; END; END LOOP; END; $$ LANGUAGE plpgsql;
Usage:
sqlSELECT * FROM migrate_all_tenants( 'ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT' );
Migration Tracking Table
sqlCREATE TABLE public.schema_migrations ( id SERIAL PRIMARY KEY, migration_name TEXT NOT NULL, schema_name TEXT NOT NULL, applied_at TIMESTAMPTZ DEFAULT NOW(), success BOOLEAN NOT NULL, error_message TEXT, UNIQUE(migration_name, schema_name) );
Before applying a migration, check if it has already been applied to each schema. This makes the process idempotent and resumable.
Handling Long-Running Migrations
For large tables, use non-blocking techniques:
sql-- Add column with default (non-blocking in PG 11+) ALTER TABLE users ADD COLUMN phone TEXT DEFAULT ''; -- Create index concurrently (cannot be inside a transaction) CREATE INDEX CONCURRENTLY idx_users_phone ON users(phone);
Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction block, so iterative migration functions need special handling for index creation.
Common Pitfalls
- Running migrations in a single transaction — If one schema fails, the entire transaction rolls back, undoing work on all other schemas. Use per-schema transactions instead.
- Forgetting IF NOT EXISTS / IF EXISTS — When resuming a failed migration, DDL statements must be idempotent to avoid errors on already-migrated schemas.
Best Practices
- Make all migration DDL idempotent — Use
IF NOT EXISTS,IF EXISTS, andADD COLUMN IF NOT EXISTSso migrations can be safely re-run. - Track migrations per schema — Maintain a migration tracking table so you can resume partial failures and audit migration status.
Summary
- Multi-tenant schema migrations require iterating over all tenant schemas.
- Track migration status per schema to enable resumable, auditable migrations.
- Use idempotent DDL and concurrent operations for reliability at scale.
Code Examples
-- Apply migration to all tenant schemas with error handling
SELECT * FROM migrate_all_tenants(
'ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT'
);
-- Check migration status
SELECT schema_name, success, error_message
FROM public.schema_migrations
WHERE migration_name = 'add_phone_column'
ORDER BY schema_name;