| |

SQL 127 🛢️ Cross-Database Migration Strategies and Schema Evolution

⏱️ Estimated read: 15 min | |

Cross-database migration and schema evolution are two different problems that are often confused because both involve moving data and changing structure. A cross-database migration moves data from one database engine to another—PostgreSQL to SQL Server, MySQL to PostgreSQL, on-premises to cloud. Schema evolution changes the structure of a database while it is running, with the application still serving traffic. The first problem is solved by extraction and loading tools; the second is solved by patterns and deployment discipline. The overlap is that a migration often requires schema changes, and a schema change often requires a migration. The distinction matters because the failure modes are different: a migration can lose data, and a schema change can break a running application.

Key point: A schema change that locks a table or removes a column that the running code still uses is not a migration problem; it is a deployment problem. The expand-and-contract pattern exists to separate the structural change from the code change, so that neither requires downtime and neither breaks the other.


Why these two problems require different strategies

The engine difference problem. Moving from PostgreSQL to SQL Server is not a matter of copying files. The data types are different, the SQL dialect is different, the transaction semantics are different, and the catalog is different. Tools like Microsoft’s SQL Server Migration Assistant (SSMA) exist to automate the conversion from Access, Db2, MySQL, Oracle, and SAP ASE to SQL Server, Azure SQL Database, or Azure SQL Managed Instance . For PostgreSQL-to-PostgreSQL upgrades, pg_upgrade and logical replication are the native tools . For PostgreSQL-to-anywhere, pg_dump and pg_restore remain the portable mechanism .

The running-application problem. A schema change that adds a column is safe: the old code does not know about the column, and the database accepts inserts without it. A schema change that renames a column or changes its type is not safe: the old code references the old name or type, and the change breaks it. The expand-and-contract pattern solves this by splitting the change into three phases: expand (add the new structure without removing the old), migrate (update the code to use the new structure while keeping the old one working), and contract (remove the old structure once no code uses it) .

The downtime problem. A migration or a schema change that requires downtime is a business decision, not a technical one. The tools and patterns that avoid downtime exist to give the business the option of continuous operation. pg_dump and pg_restore with parallel jobs can reduce the window for a PostgreSQL-to-PostgreSQL migration . pgroll and similar tools provide zero-downtime schema migrations by serving multiple schema versions simultaneously through views . Online schema change tools like gh-ost use a shadow table and binary log streaming to apply changes without locking the original table .


a. Cross-Database Migration: Tools and Trade-offs

The choice of migration tool depends on the source and target engines, the size of the data, and the acceptable downtime.

For PostgreSQL-to-PostgreSQL migrations, pg_dump produces a logical dump that is internally consistent—a snapshot of the database at the time the dump started . The custom format (-Fc) allows pg_restore to be selective and to reorder items, and it is compressed by default . For large databases, the -j flag parallelizes the dump and restore. The PostgreSQL community recommends a specific sequence for multi-database migrations: dump globals first, then dump each database in parallel, restore globals, then restore each database in parallel, and analyze afterward .

# Dump globals (roles, tablespaces)
pg_dumpall -h oldserver --globals > globals.sql

# Dump each database in parallel custom format
pg_dump -h oldserver -j 8 -Fc -f mydb.dump mydb

# Restore globals and database
psql postgres -f globals.sql
pg_restore -j 8 --exit-on-error -cC -d postgres mydb.dump

# Analyze after restore
vacuumdb --analyze --jobs=8 -d mydb

For minor version upgrades within the same major version, pg_upgrade is faster because it uses hard links to the old data files in link mode, avoiding a full copy . For major version upgrades, pg_upgrade still works but requires more post-upgrade processing. Logical replication is the alternative for zero-downtime upgrades: the old cluster remains the publisher, the new cluster is the subscriber, and the switchover happens after replication catches up .

For cross-engine migrations—PostgreSQL to SQL Server, MySQL to PostgreSQL—the tools are more varied. SSMA automates the schema conversion and data migration for Microsoft’s targets . Open-source tools like ingestra provide a general extraction and loading flow with schema alignment and upsert, supporting PostgreSQL, MySQL, BigQuery, Redshift, and others . The pattern is consistent: extract the data, align the target schema, load into a staging area, and merge into the final table.


b. Schema Evolution: The Expand-and-Contract Pattern

The expand-and-contract pattern is the standard approach for changing a schema without breaking running code . It is not a tool; it is a discipline that any migration tool can implement.

Phase 1: Expand. Add the new structure without removing the old. For a column rename, add the new column as nullable and backfill it from the old column. For a table rename, create the new table and copy the data. For a constraint change, add the new constraint without dropping the old one. The goal is that both the old code and the new code can run against the same schema during this phase.

-- Expand: add new column, keep old column
ALTER TABLE users ADD COLUMN display_name text;

Phase 2: Migrate. Update the application code to read from the new structure and write to both. For a column rename, the code writes to both the old and new columns during the transition. For a table rename, the code reads from the new table but still writes to both. For a constraint change, the code adapts to the new constraint. This phase ends when all application instances are running the new code.

Phase 3: Contract. Remove the old structure. Drop the old column, drop the old table, drop the old constraint. This phase runs only after the migration is complete and no code references the old structure.

-- Contract: drop old column after all code uses display_name
ALTER TABLE users DROP COLUMN name;

The trigger-based backfill is the mechanism that keeps the old and new columns synchronized during Phase 2. A trigger on the old column writes to the new column, and a trigger on the new column writes to the old one, so that any write that arrives from either code path is reflected in both . Without this, a write from the old code would update only the old column, and the new code reading the new column would see stale data.


c. Zero-Downtime Migration Tools

The expand-and-contract pattern requires coordination between the schema change and the code deployment. Tools like pgroll automate this coordination for PostgreSQL.

pgroll works by creating virtual schemas through views on top of the physical tables. When a migration starts, it performs the additive changes in the physical schema and creates a new view that exposes the new schema version. The old view continues to expose the old schema. Client applications connect to the schema version they expect by setting search_path to the view name . This means the old and new code can run simultaneously, each against its own view, while the physical schema is being changed underneath.

# Initialize pgroll
pgroll init --postgres-url postgres://...

# Start a migration (adds new column, creates new view)
pgroll start migration.json

# Applications switch search_path to the new view
# After all applications use the new version:
pgroll complete

For MySQL, the online schema change tools follow a different pattern. pt-online-schema-change creates a shadow table, attaches a trigger to the original table to capture changes, copies existing rows, and then renames the shadow table to replace the original . gh-ost does the same but uses binary log streaming instead of triggers, which avoids the trigger overhead on every DML operation . The triggerless design is faster on write-heavy tables because the shadow table writes are decoupled from the original table’s transaction.

The trade-off between trigger-based and triggerless online schema change is operational. Trigger-based tools are simpler to set up but add latency to every write. Triggerless tools require the database to have binary logging enabled and the tool to have replication privileges, but they do not interfere with the application’s write path .


Complete Example Session

-- ============================================
-- PART 1: CROSS-DATABASE MIGRATION (PG TO PG)
-- ============================================
-- Dump globals and database
pg_dumpall -h oldserver --globals > globals.sql
pg_dump -h oldserver -j 8 -Fc -f mydb.dump mydb

-- Restore on new server
psql postgres -f globals.sql
pg_restore -j 8 --exit-on-error -cC -d postgres mydb.dump


-- ============================================
-- PART 2: SCHEMA EVOLUTION — EXPAND PHASE
-- ============================================
-- Rename "name" to "display_name" without breaking old code
ALTER TABLE users ADD COLUMN display_name text;

-- Backfill from old column
UPDATE users SET display_name = name WHERE display_name IS NULL;


-- ============================================
-- PART 3: SYNC TRIGGERS DURING TRANSITION
-- ============================================
-- Keep both columns in sync while old and new code run
CREATE OR REPLACE FUNCTION sync_user_name() RETURNS trigger AS $$
BEGIN
    IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
        NEW.display_name := COALESCE(NEW.display_name, NEW.name);
        NEW.name := COALESCE(NEW.name, NEW.display_name);
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sync_user_name_trigger
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();


-- ============================================
-- PART 4: MIGRATE APPLICATION CODE
-- ============================================
-- Deploy new code that reads display_name
-- Old code still reads name; trigger keeps both in sync


-- ============================================
-- PART 5: CONTRACT PHASE
-- ============================================
-- After all code uses display_name:
DROP TRIGGER sync_user_name_trigger ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN name;


-- ============================================
-- PART 6: ZERO-DOWNTIME SCHEMA CHANGE WITH pgroll
-- ============================================
-- migration.json: add column
{
  "name": "add-display-name",
  "operations": [
    {"add_column": {"table": "users", "column": {"name": "display_name", "type": "text"}}}
  ]
}

pgroll start migration.json
-- New view created; old view still works

pgroll complete
-- Old view removed


-- ============================================
-- PART 7: MIGRATION VERIFICATION
-- ============================================
-- Check row counts match
SELECT count(*) FROM users;
-- Compare source and target counts

-- Check data integrity for a sample
SELECT id, name FROM oldserver.users LIMIT 10;
SELECT id, display_name FROM newserver.users LIMIT 10;


-- ============================================
-- PART 8: CROSS-ENGINE MIGRATION (PG TO MYSQL)
-- ============================================
-- Extract to NDJSON
COPY (SELECT * FROM users) TO '/tmp/users.ndjson' WITH (FORMAT csv, HEADER);

-- Load into MySQL
LOAD DATA INFILE '/tmp/users.ndjson' INTO TABLE users
FIELDS TERMINATED BY ',' ENCLOSED BY '"';


-- ============================================
-- PART 9: POST-MIGRATION CLEANUP
-- ============================================
-- Drop old replication slots
SELECT pg_drop_replication_slot('old_slot');

-- Analyze tables
VACUUM ANALYZE;


-- ============================================
-- PART 10: ROLLBACK PLAN
-- ============================================
-- If migration fails before contract:
-- 1. Stop new code
-- 2. Revert to old view (pgroll)
-- 3. Drop new column
-- 4. Old column still exists and is intact

The ten parts trace the two problems separately and then show where they intersect. Parts 2 through 5 show the expand-and-contract pattern for a column rename. Part 6 shows the pgroll tool that automates the pattern. Parts 1 and 8 show the cross-database migration flows.


Quick Reference

Migration Tools by Scenario

ScenarioToolNotes
PostgreSQL → PostgreSQL (same version)pg_dump + pg_restoreParallel with -j
PostgreSQL major upgradepg_upgradeLink mode is fastest
Zero-downtime PostgreSQL upgradeLogical replicationPublisher/subscriber switchover
Any → SQL ServerSSMAAutomates schema conversion
PostgreSQL → BigQuery/Redshiftingestra or dbt-bridgeExtract, align, load, merge

Expand-and-Contract Phases

PhaseActionCode Compatibility
ExpandAdd new structure, keep oldBoth old and new code work
MigrateBackfill, sync triggers, deploy new codeBoth work
ContractDrop old structureOnly new code works

Zero-Downtime Tools

ToolEngineMechanism
pgrollPostgreSQLViews expose multiple schema versions
pt-online-schema-changeMySQLShadow table + triggers
gh-ostMySQLShadow table + binary log streaming

pg_dump Format Comparison

FormatFlagCompressedSelective Restore
Plain-FpNoNo
Custom-FcYesYes
Tar-FtNoYes

Best Practices

✅ Do This:

# Use parallel dump and restore for large databases
pg_dump -j 8 -Fc -f db.dump mydb && pg_restore -j 8 -d newdb db.dump  # ✅
-- Add new column as nullable during expand phase
ALTER TABLE users ADD COLUMN display_name text;                         -- ✅
-- Use sync triggers to keep old and new columns consistent
CREATE TRIGGER sync_trigger BEFORE INSERT OR UPDATE ON users ...;      -- ✅
# Verify row counts and sample data before contract phase
SELECT count(*) FROM users;                                            -- ✅

❌ Don’t Do This:

-- Rename a column in a single step while code is running
ALTER TABLE users RENAME COLUMN name TO display_name;                  -- ❌
-- Drop the old column before all code uses the new one
ALTER TABLE users DROP COLUMN name;                                    -- ❌
# Migrate without a rollback plan
pg_restore -d production db.dump;  # no verification, no rollback      -- ❌
-- Assume a schema change is safe because it is "just" a type change
-- Changing text to varchar(50) can truncate data and break inserts     -- ❌

Common Pitfalls

PitfallWhy It HappensFix
Migration loses rowsNo row count verificationCompare counts before and after
Application breaks after schema changeColumn dropped before code updatedExpand-and-contract; contract only after deploy
Long lock during ALTER TABLELarge table, no online toolUse pgroll or off-peak window
Trigger overhead on high-write tableTrigger-based syncUse triggerless tool (gh-ost) or accept latency
Migration takes too longSingle-threaded dumpUse -j flag for parallel

Real-World Examples

1. Parallel Dump and Restore

pg_dump -j 8 -Fc -f mydb.dump mydb && pg_restore -j 8 -d newdb mydb.dump

2. Expand Phase: Add Column

ALTER TABLE users ADD COLUMN display_name text;

3. Backfill from Old Column

UPDATE users SET display_name = name WHERE display_name IS NULL;

4. Sync Trigger

CREATE TRIGGER sync_trigger BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();

5. Contract Phase: Drop Old Column

DROP TRIGGER sync_trigger ON users;
ALTER TABLE users DROP COLUMN name;

6. pgroll Start Migration

pgroll start migration.json

7. pgroll Complete

pgroll complete

8. Verify Row Counts

SELECT count(*) FROM users;

9. Drop Replication Slot After Migration

SELECT pg_drop_replication_slot('migration_slot');

10. Analyze After Restore

vacuumdb --analyze --jobs=8 -d mydb

Visual

The Expand-and-Contract Timeline

┌──────────────────────────────────────────────────────────────┐
│  PHASE 1: EXPAND                                             │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Add new column (nullable)                               ││
│  │ Old code: works (ignores new column)                    ││
│  │ New code: not deployed yet                              ││
│  └─────────────────────────────────────────────────────────┘│
│           │                                                  │
│           ▼                                                  │
│  PHASE 2: MIGRATE                                            │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Backfill new column from old                            ││
│  │ Add sync triggers (both columns stay consistent)        ││
│  │ Deploy new code (reads new column)                      ││
│  │ Old code: still works (trigger syncs writes)            ││
│  └─────────────────────────────────────────────────────────┘│
│           │                                                  │
│           ▼                                                  │
│  PHASE 3: CONTRACT                                           │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ All code uses new column                                ││
│  │ Drop sync triggers                                      ││
│  │ Drop old column                                         ││
│  │ New code: works                                         ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  At no point does the running code see a schema it cannot    │
│  handle. The change is split across deployments.            │
│                                                              │
└──────────────────────────────────────────────────────────────┘

pgroll Schema Versions

┌──────────────────────────────────────────────────────────────┐
│  PHYSICAL TABLE: users (name, display_name)                  │
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ VIEW: public_v1 (old schema)                            ││
│  │ SELECT id, name AS name, ... FROM users                 ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ VIEW: public_v2 (new schema)                            ││
│  │ SELECT id, display_name AS name, ... FROM users         ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  Old code: SET search_path = public_v1;                     │
│  New code: SET search_path = public_v2;                     │
│                                                              │
│  Both run simultaneously against the same physical table.   │
│  The contract phase drops the old view and the old column.  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Cross-Engine Migration Flow

┌──────────────────────────────────────────────────────────────┐
│  SOURCE DATABASE                                             │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Extract (pg_dump, COPY, or tool-specific)               ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Staging area (NDJSON, CSV, or cloud storage)            ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Align target schema (create tables, types)              ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Load into target (COPY, LOAD DATA, upsert)              ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Verify (row counts, sample data, constraints)           ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  The pattern is consistent across tools.                     │
│  The differences are in the extraction and loading syntax.  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Downtime Comparison

┌──────────────────────────────────────────────────────────────┐
│  APPROACH            DOWNTIME        RISK                   │
│                                                              │
│  Stop, dump, restore  Hours          Data loss if restore    │
│                       (depends on    fails, no rollback      │
│                       size)                                 │
│                                                              │
│  Logical replication  Minutes        Replication lag,        │
│                       (switchover)   slot management         │
│                                                              │
│  pgroll               Zero           Tool complexity,        │
│                       (running app)  view overhead           │
│                                                              │
│  Expand-and-contract  Zero           Deployment discipline,  │
│  (manual)             (running app)  trigger overhead        │
│                                                              │
│  The zero-downtime approaches trade operational complexity   │
│  for availability. The choice depends on the business        │
│  requirement for uptime.                                     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
PostgreSQL dumppg_dump -Fc (custom format, compressed, selective)
Parallel dump/restore-j flag
PostgreSQL upgradepg_upgrade (link mode fastest)
Zero-downtime upgradeLogical replication
SQL Server migrationSSMA
Expand phaseAdd new structure, keep old
Migrate phaseBackfill, sync triggers, deploy code
Contract phaseDrop old structure
pgrollZero-downtime schema migrations via views
gh-ostTriggerless online schema change for MySQL

Key takeaways:

  • Cross-database migration and schema evolution are different problems with different tools. A migration moves data between engines; a schema change alters structure while the application runs. The first uses extraction and loading tools; the second uses patterns like expand-and-contract and deployment discipline .
  • The expand-and-contract pattern separates structural change from code change. Add the new structure without removing the old, deploy code that works with both, then remove the old structure. The running application never sees a schema it cannot handle.
  • Sync triggers are the mechanism that keeps old and new columns consistent during the transition. Without them, a write from the old code updates only the old column, and the new code reads stale data from the new column .
  • Zero-downtime tools automate the pattern. pgroll serves multiple schema versions through views, allowing old and new code to run simultaneously . gh-ost and pt-online-schema-change use shadow tables to apply schema changes without locking the original table .
  • The migration tool depends on the source and target. pg_dump and pg_restore for PostgreSQL-to-PostgreSQL. pg_upgrade for major version upgrades. SSMA for SQL Server targets. ingestra and dbt-bridge for general cross-engine flows .

Remember: Migration and schema evolution are both about change, but the change is different. A migration changes where the data lives. A schema evolution changes what the data looks like. The tools for the first are extraction and loading; the tools for the second are patterns and deployment discipline. The overlap is that a migration often requires a schema change, and the safest way to make that change is to separate it into expand, migrate, and contract phases. The expand phase is additive and safe. The migrate phase requires synchronization. The contract phase is destructive and requires confidence that no code depends on the old structure. The pattern is not a tool; it is a way of thinking about change that any tool can implement.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!