Skip to main content

Command Palette

Search for a command to run...

Database Migration: Complete Strategy

Published
8 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Traditional Migration Approaches Fail at Scale

The classic "big bang" migration strategy—where teams attempt to migrate entire databases in a single operation—breaks down under modern constraints. This approach assumes you can afford downtime, that your dataset fits comfortably in memory, and that rollback is straightforward. None of these assumptions hold for contemporary systems.

Modern applications run on distributed architectures where a single logical database might span multiple regions, availability zones, or cloud providers. Microservices architectures mean dozens of services depend on the same data store, each with different access patterns and consistency requirements. Real-time analytics pipelines, machine learning models, and event-driven systems all maintain continuous connections that can't tolerate interruption.

The shift to cloud-native infrastructure has introduced new failure modes. Network partitions between regions, eventual consistency in distributed databases, and API rate limits on managed services all complicate migrations. Teams migrating from self-hosted PostgreSQL to Amazon Aurora or Google Cloud Spanner discover that subtle differences in transaction isolation levels or replication lag can break application assumptions.

Compliance requirements have transformed from afterthoughts to primary constraints. GDPR's right to erasure, CCPA's data portability requirements, and industry-specific regulations like HIPAA demand that migrations maintain complete audit trails, encrypt data in transit and at rest, and provide mechanisms for selective data deletion. A migration strategy that doesn't account for these requirements from day one will fail compliance audits.

Modern Database Migration Architecture

A production-grade database migration strategy in 2025 follows a phased approach that maintains system availability throughout the process. This architecture separates the migration into distinct stages: preparation, dual-write, validation, cutover, and cleanup.

The preparation phase establishes the foundation. Teams provision the target database, replicate the schema with necessary modifications, and set up bidirectional replication or change data capture (CDC) pipelines. This phase runs entirely in the background without affecting production traffic.

During the dual-write phase, applications write to both source and target databases simultaneously. This creates a safety net—if issues emerge with the target database, traffic can immediately revert to the source without data loss. The dual-write period typically lasts days or weeks, allowing teams to validate behavior under real production load.

Here's a production-grade implementation of a dual-write coordinator using TypeScript and PostgreSQL:

import { Pool } from 'pg';
import { Logger } from 'winston';

interface MigrationConfig {
  sourcePool: Pool;
  targetPool: Pool;
  logger: Logger;
  validationThreshold: number;
  rollbackOnError: boolean;
}

class DualWriteCoordinator {
  private config: MigrationConfig;
  private metrics: Map<string, number>;

  constructor(config: MigrationConfig) {
    this.config = config;
    this.metrics = new Map();
  }

  async executeWrite(
    operation: 'INSERT' | 'UPDATE' | 'DELETE',
    table: string,
    data: Record<string, any>,
    primaryKey: string
  ): Promise<{ success: boolean; errors: string[] }> {
    const errors: string[] = [];
    const startTime = Date.now();

    try {
      // Execute on source database (primary)
      const sourceResult = await this.writeToSource(operation, table, data, primaryKey);

      // Execute on target database (shadow)
      const targetPromise = this.writeToTarget(operation, table, data, primaryKey);

      // Don't block on target write, but track it
      targetPromise.catch(err => {
        errors.push(`Target write failed: ${err.message}`);
        this.config.logger.error('Target database write failed', {
          operation,
          table,
          primaryKey: data[primaryKey],
          error: err.message
        });

        if (this.config.rollbackOnError) {
          this.scheduleCompensation(operation, table, data, primaryKey);
        }
      });

      // Track metrics
      this.recordMetric('dual_write_latency', Date.now() - startTime);
      this.recordMetric('dual_write_success', 1);

      return { success: true, errors };
    } catch (err) {
      this.recordMetric('dual_write_failure', 1);
      this.config.logger.error('Source database write failed', {
        operation,
        table,
        error: err.message
      });
      throw err; // Source failures must propagate
    }
  }

  private async writeToSource(
    operation: string,
    table: string,
    data: Record<string, any>,
    primaryKey: string
  ): Promise<void> {
    const client = await this.config.sourcePool.connect();
    try {
      await client.query('BEGIN');

      switch (operation) {
        case 'INSERT':
          await this.executeInsert(client, table, data);
          break;
        case 'UPDATE':
          await this.executeUpdate(client, table, data, primaryKey);
          break;
        case 'DELETE':
          await this.executeDelete(client, table, data[primaryKey], primaryKey);
          break;
      }

      await client.query('COMMIT');
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  }

  private async writeToTarget(
    operation: string,
    table: string,
    data: Record<string, any>,
    primaryKey: string
  ): Promise<void> {
    const client = await this.config.targetPool.connect();
    try {
      await client.query('BEGIN');

      switch (operation) {
        case 'INSERT':
          await this.executeInsert(client, table, data);
          break;
        case 'UPDATE':
          await this.executeUpdate(client, table, data, primaryKey);
          break;
        case 'DELETE':
          await this.executeDelete(client, table, data[primaryKey], primaryKey);
          break;
      }

      await client.query('COMMIT');
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  }

  private async executeInsert(client: any, table: string, data: Record<string, any>): Promise<void> {
    const columns = Object.keys(data);
    const values = Object.values(data);
    const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');

    const query = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`;
    await client.query(query, values);
  }

  private async executeUpdate(
    client: any,
    table: string,
    data: Record<string, any>,
    primaryKey: string
  ): Promise<void> {
    const updates = Object.keys(data)
      .filter(k => k !== primaryKey)
      .map((k, i) => `${k} = $${i + 1}`)
      .join(', ');

    const values = Object.keys(data)
      .filter(k => k !== primaryKey)
      .map(k => data[k]);
    values.push(data[primaryKey]);

    const query = `UPDATE ${table} SET ${updates} WHERE ${primaryKey} = $${values.length}`;
    await client.query(query, values);
  }

  private async executeDelete(
    client: any,
    table: string,
    id: any,
    primaryKey: string
  ): Promise<void> {
    const query = `DELETE FROM ${table} WHERE ${primaryKey} = $1`;
    await client.query(query, [id]);
  }

  private scheduleCompensation(
    operation: string,
    table: string,
    data: Record<string, any>,
    primaryKey: string
  ): void {
    // Queue compensation operation for async processing
    this.config.logger.info('Scheduling compensation operation', {
      operation,
      table,
      primaryKey: data[primaryKey]
    });
  }

  private recordMetric(name: string, value: number): void {
    const current = this.metrics.get(name) || 0;
    this.metrics.set(name, current + value);
  }

  getMetrics(): Record<string, number> {
    return Object.fromEntries(this.metrics);
  }
}

This coordinator handles the complexity of dual writes while maintaining source database as the source of truth. Target database failures don't impact application availability, but they're logged and tracked for later reconciliation.

Validation Framework for Data Consistency

The validation phase runs continuously during dual-write, comparing data between source and target databases to detect inconsistencies. This catches replication lag, transformation errors, and application bugs before cutover.

interface ValidationResult {
  table: string;
  totalRows: number;
  mismatches: number;
  sampleMismatches: Array<{
    primaryKey: any;
    sourceData: Record<string, any>;
    targetData: Record<string, any>;
  }>;
  checksumMatch: boolean;
}

class DataValidator {
  private sourcePool: Pool;
  private targetPool: Pool;
  private logger: Logger;

  constructor(sourcePool: Pool, targetPool: Pool, logger: Logger) {
    this.sourcePool = sourcePool;
    this.targetPool = targetPool;
    this.logger = logger;
  }

  async validateTable(
    table: string,
    primaryKey: string,
    batchSize: number = 1000
  ): Promise<ValidationResult> {
    const result: ValidationResult = {
      table,
      totalRows: 0,
      mismatches: 0,
      sampleMismatches: [],
      checksumMatch: false
    };

    // Get row counts
    const sourceCount = await this.getRowCount(this.sourcePool, table);
    const targetCount = await this.getRowCount(this.targetPool, table);

    if (sourceCount !== targetCount) {
      this.logger.warn('Row count mismatch', {
        table,
        sourceCount,
        targetCount
      });
    }

    result.totalRows = sourceCount;

    // Batch validation
    let offset = 0;
    while (offset < sourceCount) {
      const sourceBatch = await this.fetchBatch(
        this.sourcePool,
        table,
        primaryKey,
        offset,
        batchSize
      );

      const targetBatch = await this.fetchBatch(
        this.targetPool,
        table,
        primaryKey,
        offset,
        batchSize
      );

      const batchMismatches = this.compareBatches(
        sourceBatch,
        targetBatch,
        primaryKey
      );

      result.mismatches += batchMismatches.length;

      if (result.sampleMismatches.length < 10) {
        result.sampleMismatches.push(...batchMismatches.slice(0, 10 - result.sampleMismatches.length));
      }

      offset += batchSize;
    }

    // Checksum validation for final verification
    result.checksumMatch = await this.compareChecksums(table);

    return result;
  }

  private async getRowCount(pool: Pool, table: string): Promise<number> {
    const result = await pool.query(`SELECT COUNT(*) as count FROM ${table}`);
    return parseInt(result.rows[0].count);
  }

  private async fetchBatch(
    pool: Pool,
    table: string,
    primaryKey: string,
    offset: number,
    limit: number
  ): Promise<Record<string, any>[]> {
    const result = await pool.query(
      `SELECT * FROM ${table} ORDER BY ${primaryKey} LIMIT $1 OFFSET $2`,
      [limit, offset]
    );
    return result.rows;
  }

  private compareBatches(
    sourceBatch: Record<string, any>[],
    targetBatch: Record<string, any>[],
    primaryKey: string
  ): Array<{ primaryKey: any; sourceData: Record<string, any>; targetData: Record<string, any> }> {
    const mismatches = [];
    const targetMap = new Map(targetBatch.map(row => [row[primaryKey], row]));

    for (const sourceRow of sourceBatch) {
      const targetRow = targetMap.get(sourceRow[primaryKey]);

      if (!targetRow) {
        mismatches.push({
          primaryKey: sourceRow[primaryKey],
          sourceData: sourceRow,
          targetData: null
        });
        continue;
      }

      if (!this.rowsEqual(sourceRow, targetRow)) {
        mismatches.push({
          primaryKey: sourceRow[primaryKey],
          sourceData: sourceRow,
          targetData: targetRow
        });
      }
    }

    return mismatches;
  }

  private rowsEqual(row1: Record<string, any>, row2: Record<string, any>): boolean {
    const keys1 = Object.keys(row1).sort();
    const keys2 = Object.keys(row2).sort();

    if (keys1.length !== keys2.length) return false;

    for (let i = 0; i < keys1.length; i++) {
      if (keys1[i] !== keys2[i]) return false;

      const val1 = row1[keys1[i]];
      const val2 = row2[keys1[i]];

      // Handle dates, nulls, and other special cases
      if (val1 instanceof Date && val2 instanceof Date) {
        if (val1.getTime() !== val2.getTime()) return false;
      } else if (val1 !== val2) {
        return false;
      }
    }

    return true;
  }

  private async compareChecksums(table: string): Promise<boolean> {
    const sourceChecksum = await this.calculateChecksum(this.sourcePool, table);
    const targetChecksum = await this.calculateChecksum(this.targetPool, table);
    return sourceChecksum === targetChecksum;
  }

  private async calculateChecksum(pool: Pool, table: string): Promise<string> {
    // Use database-specific checksum function
    const result = await pool.query(`
      SELECT MD5(STRING_AGG(t::text, '' ORDER BY t::text)) as checksum
      FROM ${table} t
    `);
    return result.rows[0].checksum;
  }
}

This validation framework runs continuously, detecting drift between databases before it compounds. Teams can set thresholds—if mismatches exceed 0.1%, automated alerts fire and cutover pauses until teams investigate.

Cutover Strategy and Rollback Planning

The cutover phase transitions read traffic from source to target database. This happens gradually, starting with a small percentage of read queries while writes continue to both databases. Teams monitor error rates, latency, and data consistency before increasing the percentage.

A feature flag system controls the cutover:

interface CutoverConfig {
  readPercentage: number;
  writePercentage: number;
  enableRollback: boolean;
  maxErrorRate: number;
}

class CutoverController {
  private config: CutoverConfig;
  private metrics: {
    sourceLatency: number[];
    targetLatency: number[];
    errorRate: number;
  };

  constructor(config: CutoverConfig) {
    this.config = config;
    this.metrics = {
      sourceLatency: [],
      targetLatency: [],
      errorRate: 0
    };
  }

  shouldUseTarget(operation: 'read' | 'write'): boolean {
    const percentage = operation === 'read' 
      ? this.config.readPercentage 
      : this.config.writePercentage;

    // Check error rate threshold
    if (this.metrics.errorRate > this.config.maxErrorRate) {
      if (this.config.enableRollback) {
        return false; // Automatic rollback
      }
    }

    return Math.random() * 100 < percentage;
  }

  recordLatency(database: 'source' | 'target', latency: number): void {
    if (database === 'source') {
      this.metrics.sourceLatency.push(latency);
    } else {
      this.metrics.targetLatency.push(latency);
    }

    // Keep only recent samples
    if (this.metrics.sourceLatency.length > 1000) {
      this.metrics.sourceLatency.shift();
    }
    if (this.metrics.targetLatency.length > 1000) {
      this.metrics.targetLatency.shift();
    }
  }

  recordError(): void {
    this.metrics.errorRate = (this.metrics.errorRate * 0.95) + 0.05;
  }

  recordSuccess(): void {
    this.metrics.errorRate = this.metrics.errorRate * 0.99;
  }

  getHealthMetrics(): {
    sourceP95: number;
    targetP95: number;
    errorRate: number;
    recommendation: string;
  } {
    const sourceP95 = this.calculatePercentile(this.metrics.sourceLatency, 95);
    const targetP95 = this.calculatePercentile(this.metrics.targetLatency, 95);

    let recommendation = 'continue';
    if (this.metrics.errorRate > this.config.maxErrorRate) {
      recommendation = 'rollback';
    } else if (targetP95 > sourceP95 * 1.5) {
      recommendation = 'investigate_performance';
    } else if (this.metrics.errorRate < 0.001 && targetP95 < sourceP95 * 1.1) {
      recommendation = 'increase_percentage';
    }

    return {
      sourceP95,
      targetP95,
      errorRate: this.metrics.errorRate,
      recommendation
    };
  }

  private calculatePercentile(values: number[], percentile: number): number {
    if (values.length === 0) return 0;
    const sorted = [...values].sort((a, b) => a - b);
    const index = Math.ceil((percentile / 100) * sorted.length) - 1;
    return sorted[index];
  }
}

This controller enables gradual cutover with automatic rollback if error rates spike. Teams can increase read percentage from 1% to 100% over days, validating each