Shadow Testing Database Writes: Validate Migrations Without Data Risk

Shadow Testing Database Writes: Validate Migrations Without Data Risk

Traffic shadowing is straightforward for read-only APIs: duplicate the request, compare responses, discard shadow output. Write operations are harder. Shadow writes that hit a real database can corrupt data, trigger emails, charge payment methods, or create duplicate records. Yet skipping write validation means your database migration or ORM refactor only gets tested in production — the highest-risk moment.

Shadow testing database writes requires careful isolation: real query patterns, synthetic data.

Why Write Operations Need Shadow Testing

Schema migrations, ORM version upgrades, query refactors, and database engine changes all affect write behavior in ways that are hard to test with synthetic data:

  • Constraint violations that only appear with production-shaped data
  • Trigger behavior that depends on specific field combinations
  • Index behavior that only matters at production data volume
  • Serialization behavior for complex JSON or binary types
  • Referential integrity failures that synthetic test data avoids

The challenge: you need realistic data to find these issues, but writes must not touch production.

The Shadow Write Architecture

The core architecture for safe shadow write testing:

Production traffic ──┬──► Production DB (primary)
                     │
                     └──► Shadow service ──► Shadow DB (copy of production schema + recent snapshot)
                                        ──► Response logger (compare with production response)

The shadow service receives a copy of every request, applies identical business logic, writes to an isolated database, and logs its response. Responses are compared against production responses automatically. Shadow writes never touch production data.

Setting Up a Shadow Database

A shadow database is a copy of your production schema, populated with a recent snapshot of production data (sanitized to remove PII):

# 1. Dump production schema
pg_dump --schema-only $PROD_DB_URL > schema.sql

# 2. Dump a sanitized data snapshot
pg_dump --data-only $PROD_DB_URL \
  | ./sanitize.py \  # Replace PII with synthetic equivalents
  > data_sanitized.sql

# 3. Create shadow database
createdb shadow_test_db
psql shadow_test_db < schema.sql
psql shadow_test_db < data_sanitized.sql

The sanitization step is critical. You need data that's structurally realistic (correct types, referential integrity intact, realistic distributions) but contains no real user information.

A minimal sanitizer:

import re
import sys
import hashlib

def sanitize_sql(sql):
    # Replace email addresses
    sql = re.sub(
        r"'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'",
        lambda m: f"'user_{hashlib.md5(m.group().encode()).hexdigest()[:8]}@example.com'",
        sql
    )
    # Replace phone numbers
    sql = re.sub(r"'\+?[\d\s\-\(\)]{10,15}'", "'555-000-0000'", sql)
    # Replace names (simple heuristic)
    sql = re.sub(r"'[A-Z][a-z]+ [A-Z][a-z]+'", "'Test User'", sql)
    return sql

for line in sys.stdin:
    print(sanitize_sql(line), end='')

Intercepting Writes at the Application Layer

One approach is to intercept writes within the application itself, routing them to the shadow database when running in shadow mode. This works well for gradual migrations:

import os
from sqlalchemy import create_engine
from contextlib import contextmanager

PROD_DB = create_engine(os.environ['PROD_DATABASE_URL'])
SHADOW_DB = create_engine(os.environ['SHADOW_DATABASE_URL'])

is_shadow_request = False  # Set by request middleware

@contextmanager
def get_db_session():
    db = SHADOW_DB if is_shadow_request else PROD_DB
    session = db.connect()
    try:
        yield session
        session.commit()
    except:
        session.rollback()
        raise
    finally:
        session.close()
# Middleware that marks shadow requests
def shadow_middleware(app):
    def middleware(request, call_next):
        global is_shadow_request
        is_shadow_request = request.headers.get('X-Shadow-Request') == 'true'
        return call_next(request)
    return middleware

The shadow request header is set by your traffic mirroring layer. The shadow service marks all writes to the shadow database.

Write Comparison and Validation

The comparison logic for writes differs from HTTP response comparison. You're checking what the database state looks like after both the production and shadow writes:

import psycopg2
import json

def compare_write_results(prod_conn, shadow_conn, table, record_id):
    """Compare production and shadow state for a specific record."""
    
    query = f"SELECT * FROM {table} WHERE id = %s"
    
    prod_cursor = prod_conn.cursor()
    prod_cursor.execute(query, (record_id,))
    prod_row = prod_cursor.fetchone()
    
    shadow_cursor = shadow_conn.cursor()
    shadow_cursor.execute(query, (record_id,))
    shadow_row = shadow_cursor.fetchone()
    
    if prod_row is None and shadow_row is None:
        return True  # Both deleted or never created
    
    if prod_row is None or shadow_row is None:
        return False  # One exists, one doesn't
    
    # Compare column by column, ignoring non-deterministic fields
    IGNORED_COLUMNS = {'created_at', 'updated_at', 'request_id'}
    
    for col_name, prod_val, shadow_val in zip(
        [desc[0] for desc in prod_cursor.description],
        prod_row, shadow_row
    ):
        if col_name in IGNORED_COLUMNS:
            continue
        if prod_val != shadow_val:
            print(f"Divergence in {table}.{col_name}: prod={prod_val}, shadow={shadow_val}")
            return False
    
    return True

Validating ORM Query Changes

ORM upgrades and refactors are a common use case for shadow write testing. The new ORM version might generate different SQL for the same model operations — sometimes equivalent, sometimes not.

Enable SQL logging in both versions and compare queries:

# Log all SQL queries in your shadow environment
import logging
logging.basicConfig()
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)

Then compare query logs:

# Production queries (captured from pg_stat_activity or pg_log)
grep "INSERT\|UPDATE\|DELETE" prod_queries.log > prod_writes.txt

# Shadow queries
grep "INSERT\|UPDATE\|DELETE" shadow_queries.log > shadow_writes.txt

# Compare
diff prod_writes.txt shadow_writes.txt

If the new ORM generates different queries, you can see exactly what changed before it touches production.

Database Migration Validation

Schema migrations are high-risk writes. Shadow migration testing:

  1. Apply migration to shadow database only
  2. Route 5-10% of write traffic to shadow service
  3. Monitor for constraint violations, errors, performance regressions
  4. Compare shadow vs. production error rates
  5. Roll back shadow migration and fix any issues found
  6. Apply to production when shadow shows acceptable divergence rates
# Apply migration to shadow only
DATABASE_URL=$SHADOW_DB_URL alembic upgrade head

# Ramp up shadow traffic
nginx_config_update --shadow-percent 5

# Monitor for 1 hour
python monitor_shadow.py --duration 3600 --alert-on-error

# If clean, apply to production
DATABASE_URL=$PROD_DB_URL alembic upgrade head

This pattern catches data type incompatibilities, constraint violations, and query performance regressions before they affect any production data.

Handling State Drift

The shadow database will drift from production over time — shadow writes create records that don't exist in production, and production writes don't appear in the shadow database. This limits how long a shadow test session is useful.

Practical approach: refresh the shadow database daily from a production snapshot, then run shadow testing sessions within that day. Sessions lasting more than a few hours accumulate enough drift that comparisons become noisy.

For high-throughput services, use a percentage of traffic (5-10%) rather than all traffic — this reduces shadow database write volume and extends the useful session window.

After Migration: Continuous Monitoring

Shadow testing validates a migration before it goes live. After the migration is deployed, HelpMeTest provides continuous API monitoring that catches any issues that emerge under real production load — failures that only appear at scale, with specific user data combinations, or under concurrent write patterns that shadow testing didn't fully replicate.

The combination of shadow testing (pre-deployment validation with realistic data) and continuous monitoring (post-deployment verification under real conditions) is the safest path for high-stakes database changes.

Read more

Start now free