You know the pitch: 'We'll just rewrite the ETL. It's straightforward—map fields, copy logic, done.' Four months later, the finance team is screaming because Q3 revenue looks wrong. The bug wasn't in the code. It was in the assumption that the old system handled nulls the same way. I've seen this play out at a 200-person SaaS company that migrated from an old SQL Server DTS package to Airflow. Orders under $0.01 vanished—a legacy rounding quirk nobody documented. The damage? Three weeks of manual reconciliation. Here's how to catch those landmines early.
Who Needs This and What Goes Wrong Without It
The hidden cost of 'just a rewrite'
It starts innocently. A legacy ETL script—maybe ten years old, nobody remembers who wrote it—needs a facelift. New platform, cleaner code, faster runtime. Management nods. The engineer estimates a week. Simple mapping, right? Source table A to target table B, some aggregations, done. That sounds fine until the first Monday after deploy, when a downstream dashboard shows revenue figures that look almost right—off by 0.3%. Nobody panics. By Thursday, the discrepancy has compounded: now it's 2.1%, and the finance team is asking questions. That small rounding error, invisible in unit tests, quietly poisoned six reports across three departments. I have seen this exact pattern kill a quarterly close. The rewrite wasn't the problem—the assumption that old behavior was fully documented was.
Real-world silence: when no one notices until too late
What makes these failures insidious is their silence. No crash. No log alert. The pipeline finishes successfully—green checkmark, all timestamps updated. But the data silently drifted. Maybe a NULL handling rule changed subtly: the old code treated NULLs as zeros in one aggregation, but the new one skipped them. That's a 5% swing in averages. Or a timestamp conversion shifted from local to UTC in a single branch—wrong order, but not a syntax error. The catch? Your staging environment uses static dates that mask timezone bugs. Production runs live, and suddenly Monday's batch includes Sunday's data. Most teams skip this step: comparing row-by-row output between old and new runs before promoting to production. They trust their test suite. That trust gets expensive.
"We spent three days debugging a column order swap. Every test passed because the types matched. But the dollars were in the wrong columns."
— lead data engineer, mid-market retail analytics team
Why your unit tests miss the real bugs
Unit tests check logic; they don't check fidelity to a moving target. The legacy system had quirks—a hardcoded filter for 'region_code = NULL' that actually excluded NULLs rather than including them, a join that duplicated rows on certain dates because the source key wasn't truly unique. Those quirks became assumptions downstream. Your new code is cleaner, logically correct—and produces different results. That's the trade-off: correctness vs. continuity. What usually breaks first is the edge case nobody documented: the February 29th handling, the data from the acquired company's system, the one feed that arrives at 11:59 PM. Your rewrite passes all 200 unit tests. It fails the real world because the real world is messier than your test fixtures. One rhetorical question haunts every rewrite: Who will notice the seam blowing out? Usually it's the person who calls at 10 PM on a Friday.
Prerequisites You Should Settle First
Schema snapshots and row-level hashes
Before you touch a single line of new code, freeze the schema. Not the production schema—the one the legacy ETL actually emits. I have seen teams waste two weeks debugging a mismatch only to discover the old pipeline had an extra trailing space in a column name that was silently dropped by the target database but flagged as a failure by their shiny new validator. Take a snapshot of every table structure: column names, data types, nullability, default values, and key constraints. Store it as a JSON file or a YAML manifest. That file becomes your contract.
Then build row-level hashes. For each row in every critical table, concatenate the columns (in a deterministic order—watch for NULL handling) and run it through SHA-256. This gives you a fingerprint. Compare row counts first—if they’re off by one, you know something’s lost or duplicated—but hashes catch the subtle stuff: a date that shifted one hour due to timezone handling, a decimal that rounded instead of truncated. The catch? Hashing every row in a 50-million-row table is expensive. Dual-run, don’t hash the whole dataset daily; sample a stratified slice of high-risk rows and hash those. Wrong order? It collapses. Better to hash ten thousand rows thoroughly than ten million rows sloppily.
'We skipped row hashing. Our new ETL swapped a hyphen for an em dash in a product code. Downstream sales reports misjoined for three months.'
— Data engineer, postmortem notes
Dual-run infrastructure: staging vs production
You need two clean environments: the untouched legacy pipeline and your new rewrite, both pointing at the same raw source data but writing to separate staging tables. Not a branch of production—isolated schemas or a dedicated database. The legacy path stays live; your new path runs alongside it. This is not optional. The infrastructure cost doubles, the ops overhead climbs, but the alternative is a blind rollout that breaks reports on a Friday afternoon.
Most teams skip this: they test the new ETL against a copy of production data from last week, then deploy and pray. That’s not a test—that’s a bet. Raw source data changes daily. A new vendor feed, a midnight schema patch, a bug in the upstream API—your rewrite handles the snapshotted data fine, but live data exposes edge cases you never imagined. Dual-run environments let you compare legacy output vs new output on the same source data, same timestamp. The pitfall: staging can’t be a thin VM with half the RAM. It must mirror production memory, I/O, and concurrency limits, or your performance benchmarks lie to you.
Baseline report definitions and SLAs
What does “correct” actually mean here? You can't validate ETL output without a documented baseline of every downstream report: the exact SQL query, the business rule behind each metric, the acceptable tolerance for rounding (if any), and the maximum latency before the report is considered stale. One team I worked with discovered their legacy ETL had been double-counting refunds for two years. Their “baseline” was wrong, so their rewrite—which was actually correct—failed validation. They spent three months chasing ghosts.
Flag this for data: shortcuts cost a day.
Flag this for data: shortcuts cost a day.
Document the SLAs too. If the legacy pipeline completes in forty minutes and your rewrite finishes in twelve, that’s not automatically a win—not if the new job starves disk I/O for a concurrent hourly report that runs at the same minute. Define: max runtime, max memory, max parallel load impact. And set a rollback threshold: “If any downstream report deviates by more than 0.01% from the baseline for two consecutive runs, halt the pipeline and alert.” No exceptions. That hurts, but it beats a month of corrupted dashboards.
Core Workflow: Compare, Validate, Reconcile
Step 1: Instrument dual writes without fanout
You don't flip a switch on migration day. Instead, pipe every record through both the old and new ETL paths simultaneously — but crucially, only commit the legacy output downstream. The new pipeline writes to a shadow table, a separate schema, or a tagged partition. No consumers see it. I have seen teams skip this step and immediately fan the new output into production dashboards. That hurts. The catch is isolation: your shadow target must live on the same cluster or database to avoid skew, but with a different suffix (legacy_sales vs new_sales). Worth flagging — if the new ETL errors mid-stream, you don't want it spilling nulls into your comparison set. Hard fail the job instead. Only once both tables are populated do you move to the next step.
Step 2: Row-by-row comparison with hash matching
Aggregate checks hide a thousand sins. You need record-level proof. Grab a key column — order ID, transaction hash, whatever uniquely identifies a row — and concatenate every field into a single string. Hash that string (MD5 is fine, SHA-256 if compliance demands it). Then FULL OUTER JOIN the legacy and new tables on the key, comparing hashes. Mismatches fall into three buckets: missing rows, extra rows, or content drift. Most teams skip this because the join is expensive on large tables — true, but you can batch by date partition. The real pitfall? Null handling. If one pipeline coalesces empty strings to NULL and the other doesn't, every row flags as different. Fix that in the hash constructor before you panic over false positives.
Step 3: Aggregate reconciliation at multiple levels
Row-by-row is surgical but slow. Run aggregates — counts, sums, averages, distinct counts — at three tiers: day, week, and month. Why three? Because a daily sum might match while a weekly roll-up hides a shifted boundary error. Example: your new ETL groups orders by created_at while the old one used updated_at. Days cancel out; the week drifts by Thursday's batch. That breaks your revenue reports silently. Use a drift threshold: flag anything over 0.5% deviation on counts, 1% on monetary sums. Anything under? Probably rounding. Above? Investigate before promotion. I once saw a 0.3% daily match on line-items that masked a dropped WHERE status = 'confirmed' filter — the aggregate looked fine because cancellations were rare. That's why you check multiple granularities.
Step 4: Automate alerting on drift
Manual comparison once is a snapshot. You need continuous validation. Wrap the hash join and aggregate checks into a scheduled job — nightly, after both pipelines finish. Emit a Slack message or PagerDuty alert when drift exceeds your thresholds. But don't alert on every row mismatch — batch them: "34 new rows missing, 12 content mismatches, aggregate deviation 0.7% on revenue." The goal is trend spotting. If drift grows over three nights, something systemic is degrading. If it appears and vanishes, you probably have a race condition in execution order. Blockquote: "A silent ETL bug that fixes itself is still a bug. You just don't know what data it corrupted on the way through."
— lead data engineer at a mid-market fintech, post-mortem after a 9-hour report fire drill
One concrete next action: before you write a single line of new ETL code, define those alert thresholds and the shadow table schema. Code is the easy part — catching drift early is the entire game.
Tools, Setup, and Environment Realities
Great Expectations for data quality suites
You can spend weeks wiring up validation logic yourself — or you can let Great Expectations (GX) do the heavy lifting. It's open-source, it integrates with almost any data source, and its 'expectations' read like plain English: expect_column_values_to_not_be_null, expect_table_row_count_to_be_between. I have used GX on three legacy migrations now, and the killer feature is the data docs — auto-generated HTML that shows exactly which rows failed and why. The catch? GX demands a runtime environment that mirrors production. If your local Postgres has different collation settings or your staging Snowflake warehouse has half the rows, the suite will scream about things that aren't actually broken.
‘Setting up Great Expectations locally but validating against a production replica saved us three false alarms in the first week alone.’
— data engineer, FinTech migration post-mortem
Worth flagging — GX can be slow on large tables. Expect a 10-million-row validation to take several minutes unless you push the checks into the database engine itself. That's a trade-off many teams miss until the pipeline times out.
dbt tests and custom singular tests
If you already run dbt for transformations, you have a quality framework sitting there unused. dbt's built-in tests (unique, not_null, accepted_values) are fast, declarative, and run inside the warehouse — no data egress. For the legacy migration scenario, custom singular tests are where the real power lives. Write a single SQL file that joins old and new tables, flags mismatches, and SELECT * the offenders. That's it. The test fails if any row is returned. Most teams skip this: they treat dbt as a transformation tool only, not a reconciliation harness. But I have seen shops cut their validation time by 80% using singular tests — one per critical report — and wiring them into dbt test as part of the deployment. The downside? dbt tests are pass/fail by default. You don't get graduated thresholds or soft warnings unless you build them yourself. A single null in a billion-row table will kill your pipeline. That hurts.
Custom Python scripts vs managed platforms
Python scripts give you total control. You can write row-by-row comparisons, hash entire tables, fan out parallel checks across partitions — whatever the migration needs. We fixed a broken daily report once by writing a 40-line script that compared checksums on both sides and dumped discrepancies into a timestamped log. Quick, cheap, done. However — and this is the part nobody says out loud — custom scripts rot. Six months later, the person who wrote it has left, the logging format changed, and no one knows why the script fails silently at midnight. Managed platforms like Monte Carlo or Sifflet solve that: they monitor, alert, and track lineage automatically. The trade-off is cost and lock-in. You pay per row or per pipeline, and if you ever want to leave, your validation logic is trapped behind their API. For a one-off legacy migration, I lean toward Python scripts with strict documentation. For ongoing operations after the cutover? Hand it to a managed platform before the script becomes someone else's problem.
Field note: data plans crack at handoff.
Field note: data plans crack at handoff.
CI/CD integration for non-breaking changes
The validation must run before code hits production — not after. Wire your reconciliation tests into CI/CD so that a mismatched row count blocks the deployment. Most teams skip this step, then wonder why the Monday morning report shows a ten-thousand-dollar discrepancy. GitHub Actions, GitLab CI, or even a simple Jenkins job can trigger a Python or dbt validation suite on a staging environment that mirrors production schema. The tricky bit is environment parity: your CI runner might have 10 GB of RAM while production has 64 GB. Sampling strategies matter. Pull 10% of rows for comparison in CI, but flag if sample coverage drops below 99% of unique keys. That's the kind of pragmatism no vendor will sell you. One rhetorical question worth asking: if your validation doesn't run in CI, when does it run — after the CEO sees the wrong number? Don't let that be the answer.
Variations for Different Constraints
Batch versus streaming: what changes
The core compare-validate-reconcile loop looks different when data arrives in a firehose, not a daily dump. In batch, you load everything, pause, then run your checks against a known snapshot. Streaming forces you to validate windows—not whole datasets—and the moment you lose a record in transit, the seam blows out. I've debugged pipelines where a single Kafka offset reset caused three weeks of downstream dashboards to report 0.7% revenue uplift that never happened. The fix was adding a watermark-lag check: if your stream's watermark is more than 5 minutes behind real-time, stop processing and alert. That hurts, but it's better than discovering the gap in a board meeting.
What else shifts? Your reconciliation tables need timestamps, not just counts. Batch can hash a file; streaming needs to verify that each 30-second window produced the exact row count expected. Wrong order in a stream—late-arriving events shuffled by retries—can make a validator scream false positives. You'll want to deduplicate by event ID before comparing. The trade-off: deduping adds latency. Pick your poison.
Handling late-arriving data and reprocessing
Late data breaks naive validators every time. Imagine running Tuesday's reconciliation at 9 AM sharp, only to find three orders from Monday's session landed at 9:02 AM because the source CRM was backlogged. Your batch report shows a discrepancy—those three rows exist in the target but not in your validation snapshot. That's not a bug; it's a timing problem. The pattern we use is a grace-period cutoff: accept late records up to 4 hours past the window, but flag them in a separate late_arrivals table. Then re-run the reconciliation for that window automatically once the grace period expires.
The catch? Reprocessing cascades. If you re-ingest Monday's data on Tuesday, you must invalidate any downstream aggregations built from that window. We fixed this by storing a version hash per partition—when a partition gets overwritten, dependent reports get a warning banner: "Data refreshed; totals may differ." One team I worked with skipped this and watched their monthly P&L report shift by 12%. Not a hypothetical—that's real money.
Multi-source joins: the hardest to validate
Most teams skip this: validating that a join between three source systems produced the same result after migration. Surface-level row counts match, but the joined fact table is subtly wrong. We saw a case where a legacy system joined on a truncated customer ID (first 8 characters), while the new system used the full 12-character ID. Row counts matched. Joins produced different sets. The report showed order totals that were off by 18% for one region. What usually breaks first is the edge keys—nulls, padded strings, or time-zone mismatches that only appear in 0.2% of records.
You can't validate multi-source joins with simple hashes. Instead, use a composite assertion: for each join key, verify that the source A record, source B record, and target record form a consistent triplet. Run that assertion on a sample of 100,000 random rows plus every record where any source has a null or unusual value. It's tedious. It catches the seam.
'A join that looks right for 99.8% of rows will still break your annual report when the missing 0.2% is your largest customer.'
— lead data engineer, after a three-week firefight
Regulated industries: audit trails and sign-offs
Compliance changes the game entirely. You don't just validate correctness—you must prove it later. In batch, this means every reconciliation run writes a signed manifest: source checksums, target checksums, row counts, timestamp of comparison, and the person or system that approved it. For streaming, it's trickier: you need an immutable log of each validation window, signed and stored separately from the data pipeline. One healthcare client failed an audit because their validation logs only lived in the same database as the migrated data—when a rollback erased the tables, it erased the proof that the rollback was necessary.
The practical fix is a write-once validation bucket (S3 or equivalent) with versioning enabled. Every reconcile writes a JSON record, gzipped, with an SHA-256 of the previous record chained in. This gives you an unbroken audit trail. Yes, it adds maybe 200 ms per run. That's cheap insurance when the regulator knocks. What to check next: ensure your sign-off process includes a human step for any reconciliation failure above a configurable threshold—automated approvals for small discrepancies, manual for big ones. The automation should never approve silence; if the validator doesn't run, you get an alert, not a pass.
Pitfalls, Debugging, and What to Check When It Fails
Silent type coercion: the datetime trap
You migrate a `DATETIME` column from MySQL into a Parquet sink. The source says `2023-03-12 02:15:00`. The target says `2023-03-12 03:15:00`. Nobody notices until a finance report sums revenue for the midnight-to-2am window and comes up short by $47k. That's DST spring-forward — your legacy engine stored timestamps in local time, but the new pipeline cast them to UTC implicitly. I have seen teams chase this for three sprints. The fix is brutally simple: never assume a timestamp string has a timezone until you confirm it with the source schema. Add an explicit `tz` parameter at read time. Test with a date that spans your region's DST boundary — March 12 or November 5 in North America. If you can't get that date into your test fixture, your validation is lying to you.
Odd bit about data: the dull step fails first.
Odd bit about data: the dull step fails first.
Null handling differences between engines
Legacy Oracle treats empty strings as NULL. PostgreSQL doesn't. Snowflake? Depends on the parameter EMPTY_FIELD_AS_NULL. A customer-dimension table with 2 million rows: 14% of email addresses are blank strings in the old system, but the new pipeline writes them as ''. Downstream joins behave differently — NULL = NULL is false; '' = '' is true. You get duplicate rows in the CRM export. The seam blows out during a quarterly merge. Worth flagging—this one hides in staging because row counts match. You need a value-level diff: run SELECT COUNT(*) WHERE column IS NULL on both sides, then COUNT(*) WHERE column = '' separately. If those counts drift, you have a null-semantics mismatch.
"We matched row count and total revenue. Week three: the support team started getting complaints about missing account histories."
— Lead data engineer, after a migration that preserved aggregates but broke foreign-key relationships
Rounding and precision: the cumulative effect
A single decimal column in the old system: NUMERIC(18,6). The new system stores it as FLOAT. One row loses 0.000003. Over 500k invoice lines, the total drifts by $1.47. That looks like a rounding difference — until you sum by month and one period shows a $213 variance. Nobody catches it because unit tests check exact-match on three rows. The cumulative effect is a slow bleed, not an explosion. Most teams skip this: profile the distribution of differences, not just the sum. Run MAX(abs(old - new)) and count how many rows exceed 0.01. If it's more than zero, you have a precision debt that compounds. We fixed this by forcing all financial columns to DECIMAL(38,18) on both sides — overkill, but cheap insurance.
Ordering and nondeterministic transformations
Your ETL applies a row-number window for deduplication: ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at). In the legacy system, the sort was stable — same input, same output. The new engine uses parallel workers. updated_at has ties (same millisecond). The order flips. Suddenly the wrong record survives the dedup. Wrong order. Reports show user-activity spikes that never happened. The debugging trap: you can't reproduce it locally because single-threaded runs return the old order. You need to force the parallelism in your test harness, then explicitly break ties with a deterministic column — ORDER BY updated_at, id — even if you think ties don't exist. They always do in production.
FAQ: Quick Answers to Common Questions
How long should I run dual writes?
Long enough to catch a full business cycle—but no longer than you absolutely need. I have seen teams set a two-week window and miss a month-end close that rearranged seven downstream tables. The real answer depends on your report cadence. If your stakeholders run weekly summaries, keep the dual path alive for three Mondays. Monthly? Push it to five or six weeks. The trap is leaving it running indefinitely; you'll accrue technical debt and nobody remembers which pipeline is the source of truth. Shorter is better if your source has reliable timestamps—you'll spot drift inside three cycles. Longer only helps when the data itself is sparse, like quarterly aggregates. One concrete rule: stop dual writes the day after your validation suite passes two consecutive full-run comparisons without a single mismatch. Not before.
What if my source lacks timestamps?
You're in a bind—but not a dead end. Without modification timestamps, you lose the ability to delta-check efficiently. The fix is ugly but workable: use a hash of all columns and compare against a snapshot of the old system. We fixed this by running a nightly full-dump comparison for a week, storing only the row hashes. That felt heavy, but it caught three anomalies the business swore didn't exist. The catch is storage—full table snapshots eat space fast. Consider partitioning by date ranges even if the source doesn't offer a timestamp; insert a processing_date column yourself during extraction. It's not clean, but it's honest. Without any time anchor, you're effectively doing a full rebuild every validation run. That hurts.
Can I skip row-level comparison for large tables?
Yes—but only if you accept the risk of silent skew. For tables above 50 million rows, row-level comparison is expensive and slow. Most teams skip this: they compare row counts, then sample 5% of rows using a staggered hash filter. That catches gross errors but misses isolated corruptions. I once saw a migration where 0.02% of rows had flipped currency codes—row counts matched, aggregates matched within rounding, but a single executive dashboard showed wrong totals for three months. The seam blows out on edge cases. If you must skip row-level checks, run an aggregate fingerprint: sum of all numeric columns, concatenated distinct counts on key string fields. It's not perfect, but it's better than blind trust.
'Sampling is like checking the top layer of a shipping container—everything looks fine until you unload the middle.'
— senior data engineer, after a $40k misreport
Do I need a separate QA environment?
Not necessarily, but you'll regret skipping one. The classic mistake is running validation directly in production, where your new ETL competes for resources with live queries. That slows both pipelines and masks timing issues. A separate QA environment—even a scaled-down clone—lets you simulate load without breaking anything. What usually breaks first is the orchestration layer: the old system finishes at 2:01 AM, the new one at 2:47 AM, and your reconciliation script assumes they complete simultaneously. In a shared environment, that timing gap is invisible. In QA, it's obvious. Worth flagging—you don't need full hardware parity. A 20% data subset with the same schema catches 80% of logic failures. Provision it, test it, then burn it down after migration. That's your action: before your next rewrite, spin up a throwaway QA instance and run three full cycles. You'll find the cracks before they become craters.
What to Do Next: Your Action Plan
Schedule a dual-run window starting next sprint
Don't rip out the old pipeline yet. You need a parallel run—both legacy and new ETL feeding the same warehouse, same tables, same time window. I have seen teams schedule this as a two-week overlap, but the real minimum is three full reporting cycles. That covers month-end closes, weekly snapshots, and any funny-business that only happens on the 31st. The catch is resource cost: double the compute, double the monitoring. Worth it. Without a dual-run, you're flying blind until a stakeholder yells.
Instrument lineage checks on critical reports
Most teams skip this: they validate the raw row counts and call it done. Wrong order. What usually breaks first is a type coercion—a date string that parsed in legacy but silently becomes NULL in the rewrite. Or a decimal rounding that shifts a quarterly total by 0.02%. That hurts when the CFO's variance report lights up. Build a lineage check: trace every column from source to final dashboard cell. A simple diff script on the top 20 reports catches 90% of the silent corruption. We fixed this by adding a nightly hash comparison for report outputs—exact match or flag. No match? You stop the deployment until someone explains the delta.
Most teams skip this: they validate the raw row counts and call it done. Wrong order. Build a lineage check: trace every column from source to final dashboard cell. A simple diff script on the top 20 reports catches 90% of the silent corruption. We fixed this by adding a nightly hash comparison for report outputs—exact match or flag. No match? You stop the deployment. That forces the conversation.
Stress-test with historical edge cases
Pick the twelve ugliest data days from the last year. Leap year handling? Check. Null foreign keys after a partial load? Check. A multi-byte character that choked the previous parser? Yes. Run your new ETL against those dates and compare row-by-row with legacy outputs. The tricky bit is that edge cases cluster: one bad date format in the CRM export can silently corrupt 40 downstream metrics. A colleague once found that a single trailing space in a customer ID field caused a full join to miss 3% of records—no error raised, just missing rows in a revenue report. That's the kind of bug that lives for months before someone notices the numbers feel 'off'.
“We ran the rewrite against last Black Friday's load. It passed every count check. But the order totals were wrong by $0.01 on 12% of rows—a rounding bug that only surfaced because a discount code had 3 decimal places.”
— Senior data engineer, post-mortem on a stalled migration
Document and automate the validation pipeline
Don't hand-crank the comparison script. Automate it into your CI/CD. Every merge triggers a reconciliation run against a snapshot of legacy outputs. If the diff exceeds a 0.0001% threshold on any key metric, the build fails. That sounds aggressive—and it's. But a failure at 2 AM is cheaper than a failed board meeting. Write the runbook as you build: what to check first, which reports are high-risk, who pages. Teams that document after the fact produce vague notes no one reads. Teams that document inline produce something their colleagues actually use when the alert fires. Your action plan starts Monday morning: schedule the dual-run, pick your ugly dates, and automate one comparison script before end of week. That's it. Start there.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!