Back to portfolio

Case Study · From our founder's work

Migrating 5,689 Employees from Dayforce to UKG Pro for $3.58

A serverless HRIS migration pipeline — Dayforce HCM API to BigQuery to UKG Pro — built solo in 38 days against a cutoff someone else set. Total cloud spend across the entire project: $3.58.

5,689

Employees migrated

4,158 US · 1,531 Canada

38

Days, code to cutoff

fixed date, set externally

$3.58

Total cloud spend

entire project, not monthly

1

Engineer on the pipeline

sole author of extract → export

At a glance

Scope
Full HRIS data migration: Dayforce HCM → UKG Pro, US + Canada
Population
5,689 employees (4,158 US / 1,531 Canada)
Timeline
First line of code to production cutoff: 38 days
First working export
3 days after project start
Team on the data pipeline
1
Total GCP spend, entire project
$3.58
Data mapped
191 output columns across 9 templates, two national tax regimes
Regeneration time
Minutes — not a re-migration

The problem

A translation problem wearing a data transfer's clothes

An HRIS migration is not a data transfer. Two systems describe the same human being in incompatible vocabularies. Dayforce says a marital status is Common Law; UKG wants C. Dayforce emits JSON booleans; UKG wants Y and N. Dayforce records a Canadian employee's banking as a 5-digit transit and a 3-digit institution code in separate fields; UKG wants them positioned and zero-padded exactly so, or the payment does not clear.

Multiply that by 191 columns, two countries, and 5,689 people, and the translation problem is the entire project.

The deadline was not negotiable and was not his to set: Dayforce employee self-service locked at 4:00 PM Central on April 10, 2026. Whatever data existed at that moment was the data that would define 5,689 people's employment records in a new system — their pay, their tax withholding, their direct deposit, their seniority.

The conventional approach to this is people. A team maps fields into spreadsheets, hand-builds the load files, and then — because the business does not stop hiring while you migrate — maintains those files by hand as reality drifts away from the extract. That dual maintenance window is where migrations go wrong, and it is the part this build was designed to eliminate.

About this case study

This is work StrAinge Business Solutions founder Hunter Strange built in his HRIS role at a 5,689-employee aviation services company — not a StrAinge Business Solutions client engagement. We publish it because it is the clearest demonstration we have of how we approach a problem: Hunter was the sole engineer on the extraction, transformation, and export pipeline. The surrounding migration was a team effort — an HRIS lead, UKG's conversion consultant who performed the actual load, and a project manager. His scope was everything between Dayforce's API and the files that landed on the consultant's desk.

Architecture

Three functions, one queue, one dataset

Source system

Dayforce HCM · /V1/Employees

OAuth2 · hard ceiling of 100 profile expansions per minute

Google Cloud Platform

Cloud Scheduler

daily 6:00 AM CT

dayforce-master-trigger

Cloud Functions gen2 · 512MB · 540s · enqueues and exits

Cloud Tasks

rate limit enforced by schedule_time arithmetic — nothing is alive while it waits

dayforce-worker-node

Cloud Functions gen2 · 1GB · 25 threads · 19-node expand · ~25s of life

BigQuery · dayforce_raw

raw_employee_hr_data

one column: record JSON — the frozen source of truth

raw_employee_delta_log

append-only audit — post-cutoff writes land here

9 transformation views

vw_ukg_mf1–mf4 · 191 columns · materializes nothing

Cloud Storage

UKG-ready CSV, written to the vendor's exact naming convention

dayforce-delta-reporter

Cloud Functions gen2 · 256MB · daily 6:30 AM CT · field-level diff email to stakeholders

Destination

UKG Pro · MF1–MF4 load files

handed to the vendor's conversion consultant for load

Three Cloud Functions, one queue, one dataset. No Kubernetes, no Airflow, no Pub/Sub, no always-on compute. The entire system idles at zero.
Deep dive · technical

API utilization

The constraint that shaped everything

Dayforce permits 100 employee profile expansions per minute. At 5,689 employees, a full extract is structurally a ~62-minute operation no matter how much compute you throw at it. The limit is the design.

The naive solution is a long-running process that fetches 100 records and sleeps 60 seconds, 57 times over. This fails on serverless immediately: gen2 Cloud Functions cap at 540 seconds. A 62-minute in-process sync cannot exist in a single invocation. An earlier iteration in the repo attempts --timeout=3600s --memory=8192MB — a configuration that exceeds the platform's HTTP limit and cannot deploy. That wall is what forced the redesign.

The move: offload the sleep to the queue.

Rather than any process waiting, the Master function paginates the employee index, chunks the roster into batches of 100, and enqueues each batch onto Cloud Tasks with an explicitly computed future execution time:

python
# Dayforce allows strictly 100 explicit profiles per minute
chunk_size = 100
...
# 3. Mathematically enforce the 65-second sleep block directly on the queue!
# The first message runs now, the second exactly 65s from now, the third 130s, etc.
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(now + datetime.timedelta(seconds=65 * total_tasks))
task["schedule_time"] = timestamp
The rate limit is enforced by arithmetic on the queue, not by wall-clock in a function.

The Master exits in seconds having done nothing but enqueue. Each Worker wakes, does roughly 25 seconds of work, and dies. Nothing is alive during the 62 minutes the extraction spans. There is no process to crash, no timeout to exceed, no instance to pay for.

The 65 seconds — rather than 60 — is a deliberate 5-second safety margin per batch against clock skew and Dayforce's own window accounting.

Burning the quota fast, then getting out

Within its 65-second window a Worker has a 100-call budget and every reason to spend it immediately:

python
# Worker spawns 25 subthreads to instantly process its 100 quota
with ThreadPoolExecutor(max_workers=25) as executor:

Twenty-five threads clear a 100-record batch in roughly 20–30 seconds, leaving 35–45 seconds of headroom inside the window — headroom that is then deliberately spent on a serial retry pass for transient failures, so that a flaky record is recovered within its own batch rather than lost or deferred.

The threads share a single module-global requests.Session:

python
# Global TCP Session Configuration (Eliminates SNAT Port Exhaustion)
session.mount("https://", HTTPAdapter(pool_connections=30, pool_maxsize=30))
This is a scar. Creating a Session per request on Cloud Run exhausts source NAT ports under concurrency, and the failures look like random network flakiness. The pool is sized 30 against 25 threads on purpose.

Retry taxonomy: knowing what's worth retrying

Under a hard rate limit, a wasted retry is stolen throughput. So failures are classified rather than blanket-retried:

python
if reason.startswith("http_5"):            return True
if reason == "http_200_null_data":         return True
if reason.startswith("timeout_"):          return True
if reason.startswith("exception_"):        return True
if reason.startswith("future_exception:"): return True
return False

The docstring states the intent plainly: "Excludes 4xx responses (genuinely bad records) but includes 5xx, timeouts, exceptions, and 200-with-null-Data." A 404 will be a 404 again — retrying it burns quota that a recoverable record needs. Note http_200_null_data: Dayforce will occasionally return HTTP 200 with a null payload. That is a lie, and the pipeline treats it as one.

Rate-limit responses are handled reactively as a second layer, honoring the server's own guidance:

python
if resp.status_code == 429:
    sleep_time = int(resp.headers.get("Retry-After", 10))
    time.sleep(sleep_time)
    continue

The "god payload"

The single highest-leverage API decision was refusing to fetch narrowly. Every employee is retrieved with a 19-node expansion:

expand parameter
Addresses,Contacts,DirectDeposits,EmploymentStatuses,OrgUnitInfos,
CompensationSummary,PayGradeRates,USFederalTaxes,USStateTaxes,USTaxStatuses,
CANFederalTaxes,CANStateTaxes,CANTaxStatuses,WorkAssignments,
LastActiveManagers,EmployeeProperties,EmergencyContacts,Ethnicities,MaritalStatuses

This grew empirically. A dedicated API investigation harness probed every candidate endpoint and diffed the results against the then-current 14-node expansion, recommending five additions that landed.

The reasoning is economic. Under a 100/min ceiling, a re-extract costs an hour. Field mappings churn constantly during a migration — a stakeholder asks for W-4 Step 2(c) on April 9, or an address field turns out to live somewhere unexpected. If the extract were scoped to known requirements, every discovery would cost an hour and a redeploy. By over-fetching every available node once and deciding what it means later in SQL, new requirements cost a view change and nothing else.

Deep dive · technical

Cloud platform

Schema-on-read: the load-bearing decision

The primary table has exactly one column.

python
job_config = bigquery.LoadJobConfig(
    schema=[bigquery.SchemaField("record", "JSON")],
)

The entire Dayforce employee object lands verbatim in record as a native BigQuery JSON type. No flattening, no staging schema, no ETL mapping at ingest.

This is the architectural counterpart to the god payload, and together they define the system. Extraction is expensive and rate-limited; transformation is free and instant. So extraction is made dumb and total — grab everything, interpret nothing — and every ounce of interpretation is pushed into SQL views that can be rewritten and redeployed in seconds against data already sitting in BigQuery.

The shape of the answer can change without re-asking the question. Over eight production iterations, the mapping logic was rewritten continuously. The extraction was not touched.

Service roles

ServiceRole
Cloud SchedulerTwo cron triggers: daily delta sync (6:00 AM CT), delta report (6:30 AM CT)
Cloud Functions gen2Master (512MB/540s), Worker (1GB/540s), Reporter (256MB/120s)
Cloud TasksRate-limit enforcement via schedule_time; durable retry substrate
BigQueryLanding zone (raw JSON), transformation engine (9 views), audit log
Cloud StorageUKG-ready CSV delivery under the vendor's exact naming convention
Secret ManagerCredentials for the delta report mailer

Idempotency

Workers are dispatched by a queue, and queues redeliver. So writes converge rather than accumulate:

sql
MERGE `{table_id}` T
USING `{stage_table_id}` S
ON JSON_EXTRACT_SCALAR(T.record, '$.XRefCode') = JSON_EXTRACT_SCALAR(S.record, '$.XRefCode')
WHEN MATCHED THEN UPDATE SET record = S.record
WHEN NOT MATCHED THEN INSERT (record) VALUES(S.record)
A duplicate delivery of batch 34 produces byte-identical state.

Each batch stages into a UUID-named temp table, MERGEs on the employee's cross-reference code extracted from the JSON itself, and drops the stage.

Observability decisions that mattered

Batch status is written unconditionally — including when every single fetch in the batch failed. The comment explains why: "This is the only signal the reporter has to tell ‘all-batches-failed’ from ‘no-batch-ran’." Silence is ambiguous; a failure record is not. Under a 62-minute distributed extract with no live operator, that distinction is the difference between a caught problem and a shipped one.

Conversely, the audit-log append is wrapped so it cannot break ingestion: "Audit failure must NOT block primary ingestion." Observability that takes down the thing it observes is worse than none.

Deep dive · technical

Data transformations

Where the migration actually lives

OutputColumnsNotes
vw_ukg_mf1_employee_us106Demographics, org, compensation, federal + state tax
vw_ukg_mf1_employee_ca80SIN expiry, union locals, CPP/QPP/EI, LEEP reporting
vw_ukg_mf2_deduction_us / _ca17 eachDeductions
vw_ukg_mf3_directdeposit_us / _ca13 eachABA vs. Canadian EFT
vw_ukg_mf4_earnings_us / _ca12 eachEarnings
vw_w4_step2_us4Standalone — no MF1 column exists for it

2,703

Lines of SQL

17,841

Lines of Python

492

Lines in the US MF1 view

25 CASE expressions · 206 WHEN branches

0

Staging tables

everything shreds inline from record

The US MF1 view alone contains 127 JSON_EXTRACT_SCALAR calls — reading raw JSON and emitting a 106-column, load-ready row.

State tax filing status: 39 regimes, no shared vocabulary

The hardest single problem. US state withholding codes are not standardized — each state invented its own, and Dayforce stores a human-readable description while UKG demands the state's specific code:

sql
(SELECT CASE
  -- === AL: H, M, N, S, W ===
  WHEN sc = 'AL' AND fs = 'Single'                THEN 'S'
  WHEN sc = 'AL' AND fs = 'Married, Self Separate' THEN 'W'
  -- === AZ: Percentage-based codes A-H, Z; Dayforce doesn't capture % election ===
  WHEN sc = 'AZ'                                   THEN 'Z'
  -- === CT: Withholding codes A-F, N, X ===
  WHEN sc = 'CT' AND fs = 'Single'                THEN 'F'
  -- === MS: A, B, C, D, N, X, Y ===
  WHEN sc = 'MS' AND fs = 'Single'                THEN 'A'
  -- === NJ: Rate codes A-E ===
  WHEN sc = 'NJ' AND fs = 'Single'                THEN 'C'
  ...
  WHEN fs LIKE 'Married%'                          THEN 'M'
  ELSE 'S'
END

"Single" maps to S in Alabama, A in Mississippi, C in New Jersey, and F in Connecticut. Arizona collapses to Z because its scheme is percentage-based and the source system does not capture the election at all. 39 distinct state tax codes × 9 Dayforce filing statuses = 103 mappings, each one a small decision with a real consequence for someone's paycheck.

Every mapping is documented twice — once as executable SQL and once as a reviewable CSV carrying a justification column, so a payroll stakeholder could audit the lossy ones without reading SQL:

csv
CASIT,Married,S,"SINGLE/MARRIED 2 or MORE INCOMES",Conservative default; Dayforce does not distinguish one vs two incomes
AZSIT,Single,Z,NO FORM A-4,AZ uses percentage-based codes (A-H); Dayforce does not capture % election
FLSIT,Head of Household,S,SINGLE,FL has no HOH code; default to S

Where information is genuinely lost, the mapping is conservative and the loss is written down. That is the difference between a migration and a data-loss event.

Canada is not "US minus fields"

A separate tax regime with its own vocabulary. Temporary SIN detection: Canadian temporary/non-resident Social Insurance Numbers begin with 9 and carry an expiry date; permanent ones do not. Emitting an expiry for a permanent SIN is a data error, so the view branches on the leading digit:

sql
CASE WHEN LEFT(JSON_EXTRACT_SCALAR(record, '$.SocialSecurityNumber'), 1) = '9'
     THEN FORMAT_DATE('%m/%d/%Y', CAST(SAFE_CAST(JSON_EXTRACT_SCALAR(record, '$.SSNExpiryDate') AS TIMESTAMP) AS DATE))
     ELSE NULL END AS SINExpirationDate,

Canadian EFT decomposition, with a defensive filter that caught real corruption:

sql
-- Canadian EFT: RoutingTransitNumber stores the 5-digit transit alone; BankNumber
-- holds the 3-digit institution code (RBC=003, TD=004, Scotia=002, CIBC=010, BMO=001, ...).
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(dep, '$.RoutingTransitNumber'), 5, '0'), '"') AS TransitorBranch,
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(dep, '$.BankNumber'), 3, '0'), '"') AS InstNum,
...
-- Drop rows lacking a Canadian institution code or with non-5-digit transits
-- (US ABA 9-digit routings mixed into CA direct deposits cannot clear through
-- Canadian EFT). Surfaced separately as exceptions for triage.
AND JSON_EXTRACT_SCALAR(dep, '$.BankNumber') IS NOT NULL
AND LENGTH(JSON_EXTRACT_SCALAR(dep, '$.RoutingTransitNumber')) = 5

Canadian direct deposit records in the source system contained US 9-digit ABA routing numbers — payments that would never have cleared. The pipeline refuses to emit them and routes them to a human-readable exception file instead of silently shipping broken banking data.

The details that decide whether a load succeeds

Excel text-armoring

Every identifier is wrapped so that no tool between BigQuery and UKG eats a leading zero:

sql
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(record, '$.XRefCode'), 6, '0'), '"') AS SrcEmpNo,
CONCAT('="', LPAD(JSON_EXTRACT_SCALAR(record, '$.SocialSecurityNumber'), 9, '0'), '"') AS SSN,
An SSN of 012345678 becoming 12345678 is a silent, catastrophic corruption. Files are written UTF-8 with BOM for the same reason: they will be opened in Excel by someone, and they must survive it.

Termination gating

TermDate and TermReason null themselves out when the employee is active, rather than trusting the source field — because Dayforce retains stale termination dates on rehired employees:

sql
CASE
  WHEN UPPER((SELECT ...EmploymentStatus.XRefCode... LIMIT 1)) IN ('ACTIVE','INACTIVE','LOA','PRESTART')
    THEN CAST(NULL AS DATE)
  ELSE CAST(SAFE_CAST(JSON_EXTRACT_SCALAR(record, '$.TerminationDate') AS TIMESTAMP) AS DATE)
END AS TermDate,

Both W-4 generations, side by side

Pre-2020 FedExemptions alongside 2020+ FedDependentAmt, FedOtherIncomeAmt, FedDeductionAmt, plus W4EffectiveDate so the destination can tell which form vintage governs. Step 2(c) had no MF1 column at all, so it ships as a standalone view whose WHERE clause is a byte-for-byte copy of MF1's — guaranteeing the two files reconcile row for row.

Automated address repair

Dayforce address lines were inconsistently populated across 5,689 records — duplicated, split mid-address, or overlapping. A four-rule engine repairs what it can prove and flags what it cannot:

  • Exact duplicate — normalize both lines, if equal, clear line 2
  • Substring overlap — one contains the other and the shorter is ≥6 normalized chars; keep the longer. The 6-char floor stops a legitimate APT 2 from nuking a real address line
  • Split address — line 1 is a bare street number (295, 311 147, 1806-15, handling en- and em-dashes) and line 2 starts with a letter; concatenate
  • Standalone address in line 2 — ≥15 chars, contains a digit, ≥3 words, has a comma or matches one of 26 street suffixes → flag for human review, change nothing

Rule four is the one that matters. The engine repairs 167 records automatically and refuses to guess on 20, routing them to a review file. Automation that knows the boundary of its own confidence is automation you can ship.

Deep dive · technical

The delta isolation pipeline

Eliminating the dual-maintenance window

This is the part that is usually impossible, and it is the reason the exports were current at the moment of ingestion.

The dual-maintenance problem. At 4:00 PM on April 10 the source system freezes and the extract is taken. But the business does not freeze. People are hired, terminated, promoted, and change their banking during the days or weeks between extract and load. Conventionally the migration team now maintains two realities by hand — the frozen load files and a running list of everything that changed since — and reconciles them manually at ingestion. It is laborious, it is where errors are introduced, and it scales linearly with the delay.

The design: keep syncing. Route the writes somewhere else.

The Master accepts a WriteMode parameter. Before cutoff, workers MERGE into raw_employee_hr_data — the frozen source of truth for the load files. After cutoff, the exact same code path INSERTs into raw_employee_delta_log, an append-only audit table.

Switchover

?SyncMode=Delta&WriteMode=DeltaLog

One URL parameter. No redeploy, no branch, no second pipeline to maintain.

Watermarking makes it safe to run continuously — the Master reads MAX(watermark_ts) from a dedicated table and advances it only on delta-mode runs, so "a primary-mode rerun should not move the post-freeze watermark." A 25-hour look-back covers the empty-table cold start.

A fourth function runs 30 minutes after each sync, diffs the delta log against the frozen snapshot at field level, suppresses byte-identical rows as suppressed_duplicates (Dayforce's filter boundaries are inclusive, and humans rerun things), and emails a change report to stakeholders daily.

The catchup snapshot

When it came time to load post-cutoff arrivals, a single command reconstructed a point-in-time view of exactly the right cohort:

bash
python3 build_catchup_frozen.py 2026-04-27

It builds raw_employee_hr_data_<YYYYMMDD> from the delta log with a timezone-correct end-of-day Central bound and two exclusions — employees already present in any prior frozen snapshot, and anyone whose latest status is TERMINATED (never onboard someone into a new HRIS on their way out the door). Prior snapshots are auto-discovered via INFORMATION_SCHEMA rather than hardcoded, and the script refuses to run without them:

python
if not prior:
    sys.exit("No prior frozen tables found — refusing to build (would overlap raw_employee_hr_data).")

And here is the part that eliminates dual maintenance entirely. The matching MF1 views are generated from the live view SQL by string substitution:

python
swapped = sql.replace(source_table, target_table)
swapped = swapped.replace(f"vw_ukg_mf1_employee_{country}",
                          f"vw_ukg_mf1_employee_{country}_{date_tag}")

Diffing the generated catchup view against the live view yields exactly 2 changed lines out of 492: the view name and the source table. There is precisely one copy of the mapping logic in existence. It cannot drift, because there is nothing for it to drift from.

All 106 columns of tax translation, address logic, date gating, and boolean coercion are inherited rather than re-typed. Downstream audit tooling then discovers the new views automatically:

sql
SELECT table_name FROM `{PROJECT}.{DATASET}.INFORMATION_SCHEMA.VIEWS`
WHERE REGEXP_CONTAINS(table_name, r'^vw_ukg_mf1_employee_(us|ca)(_\d{8})?$')

Add a catchup cohort; every audit script picks it up with no edits. The first catchup snapshot captured 56 post-cutoff new hires — 44 US, 12 Canada — as a load-ready file, built from data the pipeline had been quietly collecting since the moment the source system froze.

Speed

38 days, evidenced by self-dating artifacts

The export script stamps its own execution timestamp into every filename it writes; filesystem mtimes corroborate each one. These are machine-generated receipts, not recollection.

  1. Mar 3

    First line of code. API probes, auth, schema discovery.

  2. Mar 6, 06:08

    First working export produced — 3 days in.

  3. Mar 10

    Formal scope: US + Canada action plans, GCP implementation plan.

  4. Mar 12

    Full template review with stakeholders.

  5. Mar 20

    API investigation complete — every required field located.

  6. Mar 23 → Apr 10

    Six further production iterations.

  7. Apr 6

    Delta isolation pipeline designed and shipped.

  8. Apr 10, 16:00

    Source system cutoff.

  9. Apr 10, 16:44:55

    Final production extract — 5,689 employees, all templates.

38 days from zero to a production cutoff serving 5,689 employees, against a fixed date set by someone else. The final extract was in hand 45 minutes after the source system locked.

Eight full pipeline iterations in 35 days, each one a complete regeneration rather than a hand-patched delta — and each visibly adding capability: census audit sidecars, then the W-4 Step 2 view, then address review, then address change logs.

The field specifications alone — every one of 191 columns documented with source path, transformation rule, decision provenance, and open items — run to 3,471 lines across the US and Canada plans, versioned V1 through V9 in 27 days as requirements were discovered in meetings and resolved in code.

The compounding effect showed up in what new requirements cost:

Yeah, if I've got a translation table, then it's super fast. I mean, it's negligible — no work for me for all practical purposes.

— Hunter Strange, April 10 review — on adding a newly-surfaced mapping

On the final extract itself, in front of the project team:

Hunter:"That whole process of the final API call and preparing the CSVs is a three minute operation."

Project manager:"Okay. Wow. Yeah, that's fast."

The number that matters most, though, is the one about what was otherwise achievable at all:

It is a good thing I built this tool because pulling this information on four eleven as of four eleven and providing it same day, would be literally impossible without the tool. I mean, just one hundred percent out of the question.

— Hunter Strange

That is the real claim, and it is a modest one. Not that the pipeline made a manual process faster — that it made a different process possible. Same-day delivery of a fully-transformed, 191-column, two-country, load-ready dataset at the exact moment of a hard cutoff is not a thing you do by hand at any speed.

Cost

$3.58. Total. For the entire project.

Not per month. Not per environment. The complete Google Cloud spend to extract, land, transform, audit, and deliver a 5,689-employee HRIS migration across two countries — plus the ongoing delta pipeline that ran daily afterward.

It is not a trick, and it is not a free-tier accident. It is what the architecture costs when the architecture is right:

Nothing runs when nothing is happening.

Three Cloud Functions and a queue. The system's resting state is zero compute. Between the 6:00 AM sync and the next one, the bill is storage.

The expensive resource was never Google's.

The binding constraint was Dayforce's 100 calls/minute. The compute needed to saturate a 100-call-per-minute API is trivial — a 1GB function awake for 25 seconds, 57 times a day. Sizing infrastructure to a rate limit rather than to a data volume is why the number is this small.

The queue does the waiting, and waiting is free.

The 62 minutes a full extract spans are 62 minutes in which nothing is billable. Cloud Tasks holds the schedule; no instance holds a thread. This is the single largest cost decision in the build — an equivalent design with a long-running worker would have paid for an hour of idle compute per sync, forever.

BigQuery is charged for what you ask, not what you keep.

~6,000 JSON documents is a rounding error in storage. Transformation is nine views — computed on demand, materializing nothing, storing nothing.

Schema-on-read made iteration free.

Eight full mapping rewrites cost eight redeployed views. The alternative — re-extracting to reshape a staging schema — would have cost an hour of API time and a pipeline run each. The god payload was purchased once.

The comparison worth making is not against another cloud bill. A single hour of consulting time on this project costs more than the entire cloud infrastructure did.

What made it work

Four decisions carried the project

All four are transferable.

1

Separate the expensive resource from the cheap one, then over-buy the cheap one.

The API is rate-limited and precious; BigQuery storage is effectively free. So fetch everything available in one pass and interpret it later. The god payload plus schema-on-read meant that in eight iterations of continuously churning requirements, the extraction layer was never touched. Discovery cost a view change, not an hour.

2

Push the constraint into infrastructure, not code.

The rate limit lives in Cloud Tasks' schedule_time, not in a sleep(). That single inversion is what let a 62-minute operation run on a platform with a 9-minute ceiling, at zero idle cost, with no process alive to fail.

3

Generate, never duplicate.

The catchup views differ from production by two lines because they are produced from it. Downstream tooling finds them through INFORMATION_SCHEMA rather than a config file. There is one copy of the business logic, so there is nothing to keep in sync — which is precisely why the exports could be regenerated current at ingestion instead of hand-maintained against drift.

4

Automate to the edge of confidence, then stop.

The address engine repairs what it can prove and flags what it cannot. Canadian direct deposits with US routing numbers are refused, not shipped. Lossy tax mappings are conservative and documented in a file a payroll analyst can read. In a domain where a silent error becomes a wrong paycheck, knowing where the automation ends is a feature of the automation.

The through-line

Treat the migration as a compiler, not a transfer.

Source data in, load files out, business rules as code, deterministic and re-runnable at any moment. Once it is a compiler, "give me an up-to-date export" stops being a project and becomes a command.

Have a system migration that can't slip?

Fixed cutoffs, two systems that don't speak the same language, and no room for a data-loss event. That's the shape of problem we build for. Let's talk about yours.

Book a Strategy Consult