Skip to content
Talk to our solutions team

Run a data migration safely

A shape change applied to real data: rehearsed on a copy, applied in batches, resumable if it stops, and verified when it finishes.

When you finish the migration is a file you can re-run, not an afternoon you have to survive.

A migration is the one piece of automation where “run it again” is not free.

Without thisWith this
One statement over the whole table, holding a lockBatches, each committing
It fails at 80% and you do not know which 80%It resumes from the last completed batch
You find out it was wrong from a userYou rehearsed it on a copy and counted
No record of what ranThe run names every batch that completed
You needWhy
A copy of the data to rehearse againstNon-negotiable. See step 1
A key you can order byBatching needs a stable cursor
A way to tell migrated rows from unmigratedSo a resume knows where to start

The worked example: plan was a free-text column, and it needs to become one of free, pro, enterprise, with anything unrecognised becoming free.

Do this even when the change looks trivial. Especially then — a trivial change is the one nobody counts afterwards.

Terminal window
# restore last night's backup into a scratch target, then point the migration at it
kis flow -f restore.yaml -v target=scratch

Run the whole migration against scratch, count the result, and read a sample by hand. What you are looking for is not “did it error” but “is the answer right” — a migration that maps every value to free completes perfectly and is wrong.

See Restore from a backup for the restore itself.

Write the transform so that running it on already-migrated data changes nothing. That is what makes a resume safe, and it removes the need to know exactly where you stopped.

- name: transform
script:
language: javascript
code: |
const VALID = ['free', 'pro', 'enterprise'];
function main(rows) {
return rows.map((r) => ({
id: r.id,
plan: VALID.includes(r.plan) ? r.plan : 'free',
}));
}
args: ["{{batch}}"]
setvar: mapped

Note what makes it idempotent: a row already holding pro maps to pro. Re-running is a no-op rather than a second transformation, so an interrupted run can simply be started again.

name: migrate-plans
vars:
api: https://data.example.internal
batch_size: 500
after_id: 0
tasks:
- name: fetch
http:
url: "{{api}}/data/customer?filter=id:gt:{{after_id}}&limit={{batch_size}}&sort=id"
headers:
Authorization: "Bearer {{token}}"
setbody: page
next:
go: check
- name: check
choice:
rules:
- condition: "page.items.length > 0"
next: apply
default: done
- name: apply
http:
url: "{{api}}/data/customer/bulk"
method: PATCH
headers:
Content-Type: application/json
Authorization: "Bearer {{token}}"
payload: "{{mapped}}"
next:
go: advance
set:
after_id: "{{page.items | last | map: 'id'}}"
- name: advance
print:
message: "migrated through id {{after_id}}"
next:
go: fetch
- name: done
succeed: true

The cycle fetch → check → apply → advance → fetch is the batching. check is what ends it: an empty page routes to done instead of looping.

Order by a stable key and page with id > last, not with an offset. An offset re-reads rows whose position shifted while you were writing; a keyset cursor cannot.

Step 4 — this is where the flow earns its place

Section titled “Step 4 — this is where the flow earns its place”

Both surfaces above do the same work. They behave differently the moment something goes wrong at row 40,000 of 50,000.

FlowScript
On failureThe run records which nodes completed and what after_id heldThe process ends
To continueResume, or re-run with -v after_id=<last>Re-run, and re-derive where you were
If the machine diesAnother worker picks up from the last checkpointThe run is lost
Visible to an operatorYes, as a run recordOnly in whatever you logged

For a migration on data that matters, write the flow. The script is the right tool for developing the transform — fast loop, real debugger — and step 2 is deliberately the piece that moves between them unchanged.

Step 5 — verify by counting, not by trusting

Section titled “Step 5 — verify by counting, not by trusting”
Terminal window
# every row should now hold one of the three valid values
curl -fsS "$API/data/customer?filter=plan:nin:free,pro,enterprise&limit=0" \
-H "Authorization: Bearer $TOKEN" | jq .total # must be 0
# and the distribution should look like the rehearsal predicted
curl -fsS "$API/data/customer?groupBy=plan&limit=0" \
-H "Authorization: Bearer $TOKEN" | jq

The first query is the invariant. The second is the sanity check — if everything became free, the count of unmigrated rows is still zero and the migration is still wrong.

CheckExpect
Rows outside the valid set0
Total row countUnchanged from before the migration
DistributionWithin a row or two of the rehearsal
Re-run the migrationReports zero rows changed

That last one is step 2 paying off, and it is the check most people skip.

ChangeHow
Bigger than a few million rowsA data pipeline — it streams instead of paging
The transform needs another serviceAdd a node; keep the batch loop unchanged
Must not run during business hoursAttach it to a schedule with the cron: operation
Needs a rollbackSnapshot first — a backup you took is a rollback you have