Loading learning content...
Database migrations are among the most complex and risky operations in software engineering. Unlike application deployments that can be quickly rolled back, database migrations involve persistent state that, once changed, cannot be trivially undone.
Yet migrations are often necessary:
This page provides a comprehensive framework for thinking about and executing database migrations. Whether you're moving from SQL to NoSQL, the reverse, or between databases within the same paradigm, these principles and practices will guide you through the process safely.
By the end of this page, you will understand the common motivations for database migrations, be able to plan migrations with appropriate phases and milestones, navigate the data model transformation challenges, implement migration patterns that minimize risk, and handle the organizational and operational aspects of database transitions.
Understanding why migrations occur helps you anticipate them, design for them, and evaluate whether a proposed migration is justified.
Common Migration Drivers:
| Motivation | Example Scenario | Typical Direction |
|---|---|---|
| Scale limitations | PostgreSQL can't handle 100K writes/sec | SQL → NoSQL (Cassandra) |
| Query complexity needs | Analytics require complex JOINs impossible in DynamoDB | NoSQL → SQL |
| Cost optimization | Oracle licensing too expensive; moving to PostgreSQL | Vendor → Open Source |
| Managed service adoption | Want zero-ops; moving to Aurora or DynamoDB | Self-managed → Cloud |
| Schema flexibility needs | Product catalog needs varying attributes | SQL → Document DB |
| Consistency requirements | Financial data needs ACID after NoSQL experiment | NoSQL → SQL |
| Performance problems | Wrong database choice causing latency issues | Any → More appropriate |
| Acquisition/merger | Two companies consolidating on one platform | Varies |
| Skill availability | Can't hire Cassandra experts; moving to PostgreSQL | Specialized → Common |
When NOT to Migrate:
Not every performance problem or capability gap justifies migration. Consider alternatives:
A major database migration typically takes 6-18 months, involves substantial engineering effort, carries significant risk, and distracts from feature development. Ensure the benefits clearly outweigh these costs before proceeding.
A successful migration requires comprehensive planning before any data moves. The planning phase often takes 20-30% of the total migration timeline.
Phase 1: Assessment
12345678910111213141516171819202122232425262728293031
# Migration Assessment Checklist ## Current State Analysis- [ ] Document all tables/collections, row counts, data sizes- [ ] Map all data types and their target equivalents- [ ] Identify relationships and integrity constraints- [ ] Catalog stored procedures, triggers, views- [ ] List all application queries and access patterns- [ ] Document indexes and their usage frequency- [ ] Identify sensitive data requiring special handling- [ ] Measure current performance baseline (latency, throughput) ## Target State Definition- [ ] Define target database schema/data model- [ ] Plan data type mappings (e.g., SERIAL → UUID)- [ ] Decide on denormalization strategy if applicable- [ ] Design new indexes for target query patterns- [ ] Plan for features not available in target (stored procs → app code) ## Dependency Mapping- [ ] List all applications connecting to current database- [ ] Identify third-party tools (BI, CDC, backups) requiring changes- [ ] Map team responsibilities and skills- [ ] Identify compliance/audit requirements- [ ] Document SLAs that must be maintained during migration ## Risk Assessment- [ ] Estimate data loss window acceptable to business- [ ] Identify rollback scenarios and procedures- [ ] Plan for extended rollback period- [ ] Define go/no-go criteria for final cutoverPhase 2: Strategy Selection
There are multiple migration strategies, each with different trade-offs:
| Strategy | Description | Downtime | Risk | Complexity |
|---|---|---|---|---|
| Big Bang | Migrate everything at once during maintenance window | Hours to days | High—no rollback | Low |
| Parallel Run | Write to both databases; gradually shift reads | Zero | Medium | High |
| Strangler Pattern | New features on new DB; migrate old gradually | Zero | Low per phase | Medium |
| Trickle Migration | Move data in small batches over time | Zero | Medium | Medium |
| Blue-Green | Full copy to new DB; instant cutover | Minimal | Medium | High |
Most common approach: Parallel Run with Gradual Traffic Shift
This pattern provides the safest migration for critical systems:
Migrating between SQL and NoSQL (or vice versa) requires fundamental data model transformation. This is often the most intellectually challenging part of migration.
SQL to Document (e.g., PostgreSQL to MongoDB)
The key transformation is from normalized tables with joins to embedded documents:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// SQL Schema (normalized)/*CREATE TABLE customers (id, name, email);CREATE TABLE addresses (id, customer_id, street, city, country, is_primary);CREATE TABLE orders (id, customer_id, order_date, status);CREATE TABLE order_items (order_id, product_id, quantity, price);CREATE TABLE products (id, name, price, category_id);CREATE TABLE categories (id, name, parent_id);*/ // Document Schema (denormalized for read optimization)// Decision: What to embed vs reference? // Customer document - embed addresses (1:few, queried together){ "_id": ObjectId("..."), "name": "Alice Smith", "email": "alice@example.com", "addresses": [ { "street": "123 Main St", "city": "Seattle", "country": "USA", "isPrimary": true }, { "street": "456 Work Ave", "city": "Seattle", "country": "USA", "isPrimary": false } ]} // Order document - embed items, reference customer and products// Items embedded (always queried with order)// Customer referenced (avoid duplication, infrequent access)// Products referenced with denormalized fields (name, price at time of order){ "_id": ObjectId("..."), "customerId": ObjectId("..."), // Reference "customerEmail": "alice@example.com", // Denormalized for notifications "orderDate": ISODate("2024-01-15"), "status": "shipped", "items": [ // Embedded { "productId": ObjectId("..."), // Reference "productName": "Widget X", // Denormalized (snapshot at order time) "quantity": 2, "unitPrice": 29.99, // Price at time of order "lineTotal": 59.98 } ], "shippingAddress": { // Embedded (snapshot at order time) "street": "123 Main St", "city": "Seattle", "country": "USA" }, "totals": { "subtotal": 59.98, "tax": 6.00, "shipping": 5.99, "total": 71.97 }} // Transformation considerations:// 1. What's queried together? → Embed// 2. What changes independently? → Reference// 3. What needs historical snapshot? → Embed with copy// 4. What would create unbounded arrays? → ReferenceDocument to SQL (e.g., MongoDB to PostgreSQL)
The reverse transformation normalizes embedded data into tables:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
-- MongoDB document (denormalized)/*{ "_id": ObjectId("..."), "title": "Understanding Databases", "author": { "name": "Jane Expert", "bio": "Database specialist...", "social": { "twitter": "@janedb", "github": "janeexpert" } }, "tags": ["databases", "nosql", "sql"], "comments": [ { "user": "reader1", "text": "Great article!", "timestamp": ISODate("2024-01-15T10:30:00Z"), "likes": 15 }, { "user": "reader2", "text": "Very helpful", "timestamp": ISODate("2024-01-15T11:45:00Z"), "likes": 8 } ]}*/ -- Normalized SQL SchemaCREATE TABLE authors ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, bio TEXT, twitter_handle VARCHAR(100), github_handle VARCHAR(100)); CREATE TABLE articles ( id SERIAL PRIMARY KEY, mongo_id VARCHAR(24) UNIQUE, -- Preserve original ID for mapping title VARCHAR(500) NOT NULL, author_id INTEGER REFERENCES authors(id), created_at TIMESTAMP DEFAULT NOW()); CREATE TABLE tags ( id SERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL); CREATE TABLE article_tags ( article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE, tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (article_id, tag_id)); CREATE TABLE comments ( id SERIAL PRIMARY KEY, article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE, username VARCHAR(100) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP NOT NULL, likes INTEGER DEFAULT 0); -- Migration queries-- 1. Extract unique authorsINSERT INTO authors (name, bio, twitter_handle, github_handle)SELECT DISTINCT doc->>'author.name', doc->>'author.bio', doc->'author'->'social'->>'twitter', doc->'author'->'social'->>'github'FROM mongo_import; -- 2. Insert articles with author lookup-- 3. Extract and insert unique tags-- 4. Create article-tag relationships-- 5. Unnest comments array into comments tableChanging the data model usually requires significant application changes. Queries must be rewritten, data access patterns adjusted, and business logic updated. Budget for application refactoring as a major part of database migration.
Here are battle-tested patterns for implementing database migrations safely.
Pattern 1: Dual Write with Shadow Read
During migration, write to both databases but only read from original. Compare results to validate new database:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Dual Write Pattern with Shadow Read Comparison class MigratingOrderRepository { private oldDb: PostgresClient; private newDb: MongoClient; private metrics: MetricsClient; private featureFlags: FeatureFlags; async save(order: Order): Promise<void> { // Always write to old database (source of truth during migration) await this.oldDb.query( 'INSERT INTO orders (...) VALUES (...)', [order.id, order.customerId, ...] ); // Also write to new database (async, non-blocking) this.writeToNewDb(order).catch(err => { this.metrics.increment('migration.dual_write.new_db_error'); console.error('New DB write failed:', err); // Don't fail the operation - old DB is source of truth }); } async findById(orderId: string): Promise<Order | null> { // Read from old database (source of truth) const oldResult = await this.oldDb.query( 'SELECT * FROM orders WHERE id = $1', [orderId] ); // Shadow read from new database (async, for comparison) if (this.featureFlags.isEnabled('migration.shadow_reads')) { this.shadowRead(orderId, oldResult.rows[0]).catch(err => { this.metrics.increment('migration.shadow_read.error'); }); } return oldResult.rows[0] || null; } private async shadowRead(orderId: string, oldResult: any): Promise<void> { const newResult = await this.newDb.collection('orders') .findOne({ _id: orderId }); // Compare results const differences = this.compare(oldResult, newResult); if (differences.length > 0) { this.metrics.increment('migration.shadow_read.mismatch'); console.warn('Data mismatch:', { orderId, differences }); // Log for investigation, don't fail } else { this.metrics.increment('migration.shadow_read.match'); } } private compare(old: any, new_: any): string[] { // Implement comparison logic accounting for schema differences // Return list of field differences }} // Gradually shift trafficclass MigratingOrderRepositoryV2 { async findById(orderId: string): Promise<Order | null> { const readFromNew = this.featureFlags.percentage('migration.read_new_db'); if (Math.random() < readFromNew) { // Read from new database for this percentage of requests const result = await this.newDb.collection('orders') .findOne({ _id: orderId }); if (result) { this.metrics.increment('migration.read.new_db.success'); return this.mapToOrder(result); } else { // Fallback to old if not found (consistency lag) this.metrics.increment('migration.read.new_db.fallback'); return this.readFromOldDb(orderId); } } return this.readFromOldDb(orderId); }}Pattern 2: Strangler Fig Migration
For large systems, migrate functionality piece by piece rather than all at once:
Pattern 3: CDC-Based Migration
Use Change Data Capture to continuously sync data:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
# CDC-based migration pipeline # Phase 1: Initial bulk loadinitial_load: source: PostgreSQL target: MongoDB method: pg_dump | transform | mongoimport estimated_time: "4 hours for 500GB" # Phase 2: Continuous sync via CDCcdc_pipeline: source: type: PostgreSQL connector: Debezium tables: - public.orders - public.order_items - public.customers transform: type: Kafka Streams logic: | - Join order with items - Embed customer address - Convert types (SERIAL → ObjectId) - Transform timestamps target: type: MongoDB connector: MongoDB Kafka Connector collection: orders write_mode: upsert # Phase 3: Cutovercutover: steps: 1: Stop writes to old database 2: Wait for CDC lag to reach zero 3: Verify row counts match 4: Switch application to read/write new database 5: Monitor for 24 hours 6: Decommission old database (after 30-day safety window) rollback: if_within: "30 days" method: | - Stop writes to new database - Bulk export and import to old - Resume CDC in reverse direction - Switch application backDatabase migration isn't just about data—the application must change to work with the new database paradigm. This often represents 50%+ of the total migration effort.
Query Rewriting:
123456789101112131415161718192021222324252627282930313233
-- PostgreSQL: Complex JOINSELECT o.id, o.total, c.name AS customer_name, c.emailFROM orders oJOIN customers c ON o.customer_id = c.idWHERE o.status = 'pending' AND o.created_at > NOW() - INTERVAL '7 days'ORDER BY o.created_at DESCLIMIT 50; -- Aggregation with GROUP BYSELECT DATE(created_at) AS order_date, COUNT(*) AS order_count, SUM(total) AS daily_revenueFROM ordersWHERE created_at >= '2024-01-01'GROUP BY DATE(created_at)ORDER BY order_date; -- SubquerySELECT *FROM productsWHERE id IN ( SELECT product_id FROM order_items GROUP BY product_id HAVING COUNT(*) > 100);12345678910111213141516171819202122232425262728293031323334
// MongoDB: Denormalized (no join needed)db.orders.find({ status: "pending", createdAt: { $gt: new Date(Date.now() - 7*24*60*60*1000) }}).sort({ createdAt: -1 }).limit(50);// Note: customer name/email embedded // Aggregation pipelinedb.orders.aggregate([ { $match: { createdAt: { $gte: new Date("2024-01-01") } }}, { $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" }}, orderCount: { $sum: 1 }, dailyRevenue: { $sum: "$total" } }}, { $sort: { _id: 1 } }]); // Subquery equivalentconst popularProducts = await db.orderItems.aggregate([ { $group: { _id: "$productId", count: { $sum: 1 }}}, { $match: { count: { $gt: 100 }}}]).toArray();const ids = popularProducts.map(p => p._id);db.products.find({ _id: { $in: ids }});Repository Abstraction for Migration:
Design your application to support both databases during migration:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Abstract repository interface - database agnosticinterface OrderRepository { findById(id: string): Promise<Order | null>; findByCustomer(customerId: string, limit: number): Promise<Order[]>; save(order: Order): Promise<void>; findPendingOrders(olderThanDays: number): Promise<Order[]>;} // PostgreSQL implementationclass PostgresOrderRepository implements OrderRepository { async findById(id: string): Promise<Order | null> { const result = await this.pool.query( `SELECT o.*, c.name as customer_name, c.email as customer_email FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.id = $1`, [id] ); return result.rows[0] ? this.mapToOrder(result.rows[0]) : null; } // ... other methods} // MongoDB implementationclass MongoOrderRepository implements OrderRepository { async findById(id: string): Promise<Order | null> { const doc = await this.collection.findOne({ _id: new ObjectId(id) }); return doc ? this.mapToOrder(doc) : null; } // ... other methods} // Migration-aware factoryfunction createOrderRepository(config: Config): OrderRepository { switch (config.database.migration_phase) { case 'postgres_only': return new PostgresOrderRepository(config.postgres); case 'dual_write': return new DualWriteOrderRepository( new PostgresOrderRepository(config.postgres), new MongoOrderRepository(config.mongo) ); case 'mongo_primary': return new MongoOrderRepository(config.mongo); default: throw new Error('Unknown migration phase'); }} // Application code remains unchanged throughout migrationclass OrderService { constructor(private orderRepo: OrderRepository) {} async getOrder(id: string): Promise<Order | null> { return this.orderRepo.findById(id); }}If you're not already using repository patterns or similar abstractions, introduce them before migration. The refactoring cost pays for itself by making the database swap significantly easier and lower risk.
Thorough testing is the difference between successful migrations and data disasters. Testing must cover data integrity, performance, and application behavior.
Data Validation Testing:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
# Data validation framework for migration import hashlibfrom typing import Dict, List class MigrationValidator: def __init__(self, source_db, target_db): self.source = source_db self.target = target_db self.errors: List[str] = [] def validate_row_counts(self) -> bool: """Verify all entities migrated""" tables = [ ('customers', 'customers'), ('orders', 'orders'), ('products', 'products'), ] for source_table, target_collection in tables: source_count = self.source.execute( f"SELECT COUNT(*) FROM {source_table}" ).fetchone()[0] target_count = self.target[target_collection].count_documents({}) if source_count != target_count: self.errors.append( f"{source_table}: source={source_count}, target={target_count}" ) return len(self.errors) == 0 def validate_data_integrity(self, sample_size: int = 10000) -> bool: """Spot check data values match""" # Random sample of IDs from source sample_ids = self.source.execute( f"SELECT id FROM orders ORDER BY RANDOM() LIMIT {sample_size}" ).fetchall() mismatches = [] for (order_id,) in sample_ids: source_order = self.fetch_source_order(order_id) target_order = self.fetch_target_order(order_id) diffs = self.compare_orders(source_order, target_order) if diffs: mismatches.append({'id': order_id, 'diffs': diffs}) if mismatches: self.errors.extend([ f"Data mismatch: {m['id']}: {m['diffs']}" for m in mismatches[:10] # Log first 10 ]) mismatch_rate = len(mismatches) / sample_size print(f"Mismatch rate: {mismatch_rate:.4%}") return mismatch_rate < 0.0001 # < 0.01% acceptable def validate_checksums(self) -> bool: """Verify critical numeric fields sum correctly""" checks = [ # (source_query, target_pipeline, field_name) ( "SELECT SUM(total) FROM orders WHERE status = 'completed'", [{"$match": {"status": "completed"}}, {"$group": {"_id": None, "sum": {"$sum": "$total"}}}], "completed_order_total" ), ] for source_query, target_pipeline, field_name in checks: source_sum = self.source.execute(source_query).fetchone()[0] target_result = list(self.target.orders.aggregate(target_pipeline)) target_sum = target_result[0]['sum'] if target_result else 0 # Allow for floating point precision if abs(source_sum - target_sum) > 0.01: self.errors.append( f"{field_name}: source={source_sum}, target={target_sum}" ) return len(self.errors) == 0 # Run validation suitevalidator = MigrationValidator(postgres_conn, mongo_client)results = { 'row_counts': validator.validate_row_counts(), 'data_integrity': validator.validate_data_integrity(), 'checksums': validator.validate_checksums(),} if all(results.values()): print("✅ Migration validation passed")else: print("❌ Migration validation failed") print("Errors:", validator.errors)Performance Testing:
Performance differences often emerge only at production scale. Testing with a 1% sample won't reveal issues that appear with full data volume. Use production-scale data (anonymized if necessary) for performance testing.
The final cutover is the highest-risk moment in any migration. Meticulous planning and clear rollback procedures are essential.
Cutover Checklist:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
# Migration Cutover Runbook ## Pre-Cutover (T-24 hours)- [ ] All stakeholders notified- [ ] Support team briefed on potential issues- [ ] Rollback procedure tested in staging- [ ] All validation tests passing- [ ] CDC lag < 10 seconds consistently- [ ] Go/no-go meeting completed ## Pre-Cutover (T-1 hour)- [ ] Freeze all deployments- [ ] Confirm team availability for duration- [ ] Open communication channel (Slack, bridge call)- [ ] Prepare status update templates ## Cutover Procedure ### Step 1: Stop Writes (T+0:00)- [ ] Enable maintenance mode / stop application writes- [ ] Verify no new writes in old database (check WAL position)- Record time: _____________ ### Step 2: Final Sync (T+0:05)- [ ] Wait for CDC lag to reach zero- [ ] Run final validation (row counts, checksums)- [ ] If validation fails → ABORT and rollback- Record time: _____________ ### Step 3: Switch Configuration (T+0:15)- [ ] Deploy application config pointing to new database- [ ] Verify application connecting to new database- [ ] Enable writes to new database- Record time: _____________ ### Step 4: Verification (T+0:20)- [ ] Smoke test critical paths manually- [ ] Verify metrics (error rates, latencies)- [ ] Check all services healthy- Record time: _____________ ### Step 5: Re-enable Traffic (T+0:30)- [ ] Disable maintenance mode- [ ] Monitor closely for 30 minutes- [ ] If issues → ROLLBACK- Record time: _____________ ## Success Criteria (before declaring victory)- [ ] Error rate < 0.1% for 30 minutes- [ ] p99 latency within 20% of baseline- [ ] All critical transactions completing- [ ] No data integrity alerts ## Rollback Trigger Conditions- Error rate > 1%- Critical transaction failures- Data corruption detected- Latency > 3x baseline for > 5 minutesRollback Strategy:
Always maintain the ability to roll back, even after cutover:
During parallel run phases, you can rollback instantly by simply switching which database handles reads. This is why the parallel run pattern, despite its complexity, is preferred for critical systems. Invest in robust dual-write and comparison infrastructure.
We've comprehensively explored the challenging world of database migrations. Let's consolidate the essential insights:
Module Completion:
This concludes Module 6: NoSQL vs SQL. You've now mastered:
You're now equipped to make informed database technology decisions and guide teams through complex database evolution.
Congratulations! You've completed the comprehensive exploration of NoSQL vs SQL. You now possess the knowledge to evaluate database technologies, make appropriate selections for different workloads, design multi-database architectures, and navigate the challenges of database migrations. This expertise is essential for modern data architecture and system design.