Data Backfills skill for Codex
Touch 80 million rows without anyone noticing. Codex treats a backfill as a production operation, not a migration line: it ships the dual write first, maps side effects like triggers and CDC events, then walks the primary key in short per-batch transactions with lock timeouts, a checkpoint table and a kill switch. It throttles on replica lag, estimates run time from a dry run, and finishes with a mismatch query that must return zero.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- data-backfills Installs to ~/.agents/skills/data-backfills/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install data-backfills The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project data-backfills Installs to .agents/skills/data-backfills/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/data-backfills.zipunzip -o data-backfills.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) data-backfills PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $data-backfills, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Plans and runs large data backfills on live databases - keyset-batched UPDATE and INSERT ... SELECT jobs with per-batch transactions, checkpoints for resume, throttling on replica lag and load, kill switches, and before/after verification queries - for PostgreSQL and MySQL, via raw SQL, Python, Rails or Django jobs. Use when populating a new column across millions of rows, copying data into a new table, recomputing a derived field, fixing corrupted rows in bulk, or when a migration backfill is too slow or locks the table.
Use it when
- A new or changed column must be populated across millions of rows
- Data has to be copied or reshaped into a new table on a live system
- A previous bulk UPDATE caused lock waits, replica lag or timeouts
- Bulk repair of bad rows after an incident
Use something else when
- The schema change itself (adding the column, constraints, swaps) is the question: use zero-downtime-migrations
- Recurring pipelines that move data between systems: use etl-pipeline-design
How to check the result
A mismatch query against the source of truth returns zero rows after the run, including rows written during it, and copied totals or checksums match.
The mistake it prevents
Starting the backfill before the application writes the new value, so every row created during the run is silently left behind.
Done well means: the backfill can be stopped and resumed at any point without harm, every batch is short enough that nobody notices it (no lock queues, no replica lag, no latency bump), new writes are already handled before it starts, and a query proves zero rows were missed when it ends.
Before writing the job
- Read
AGENTS.mdand existing jobs; find how the repo runs one-off tasks (rake task, management command, job queue, maintenance-task framework). - Measure the target: total rows, rows needing change, table and index size, write rate.
SELECT reltuples::bigint AS approx_rows,
pg_size_pretty(pg_total_relation_size('orders')) AS total_size
FROM pg_class WHERE relname = 'orders';
SELECT count(*) FROM orders WHERE region_id IS NULL; -- on a replica if huge
- Make new writes correct first. Deploy the code that writes the new value (dual write, trigger, or default) before backfilling, or rows created during the run are missed.
- List side effects of touching each row: triggers (audit,
updated_at), CDC or outbox consumers, search indexing, cache invalidation, ORM callbacks. A million updates can mean a million Kafka events. - Agree on a window, a throttle and an abort condition with the user. Running against production needs explicit confirmation of the exact command and target.
Choose the shape
| Situation | Approach |
|---|---|
| Set a column on existing rows | Batched UPDATE ... WHERE id range AND <needs change> |
| Copy into a new table (re-key, split, merge) | Batched INSERT ... SELECT ... ON CONFLICT DO NOTHING |
| Rewrite most of a very large table | Build a new table, backfill, swap in a short lock (see $zero-downtime-migrations) |
| Needs app logic (API calls, parsing) | Job per batch of ids through the job queue |
| Fewer than about 50k rows, quiet table | Single statement with lock_timeout is fine |
Batching rules
- Keyset, never OFFSET. Walk the primary key (or another indexed, unique, monotonic
key):
WHERE id > :last_id ORDER BY id LIMIT :n. OFFSET rescans skipped rows and gets slower each batch. - Idempotent predicate. Include the “still needs work” condition
(
AND region_id IS NULL,AND version < 2) so reruns skip finished rows. - One transaction per batch. Never wrap the whole backfill in one transaction: it holds locks, blocks vacuum and makes the whole thing all-or-nothing.
- Target batch duration, not batch size. Aim for 100-500 ms per batch. Start at 1,000
rows, measure, adjust. Sparse matches mean ranges by id beat
LIMITon the predicate. - Guard every batch with
SET LOCAL lock_timeout = '2s'andstatement_timeout. - Checkpoint the last processed key to a table or durable store after each commit.
Reference implementation (Postgres, psycopg 3)
import os, sys, time
import psycopg
BATCH, PAUSE, MAX_LAG_S = 2000, 0.05, 5.0
JOB = "orders_region_backfill"
def replica_lag(conn) -> float:
row = conn.execute("""SELECT coalesce(max(extract(epoch FROM replay_lag)), 0)
FROM pg_stat_replication""").fetchone()
return float(row[0])
def stop_requested(conn) -> bool:
row = conn.execute("SELECT stop FROM backfill_control WHERE job = %s", (JOB,)).fetchone()
return bool(row and row[0])
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as conn:
last = conn.execute("SELECT last_id FROM backfill_progress WHERE job = %s",
(JOB,)).fetchone()
last_id = last[0] if last else 0
max_id = conn.execute("SELECT max(id) FROM orders").fetchone()[0] or 0
while last_id < max_id:
if stop_requested(conn):
sys.exit(f"stopped by kill switch at id {last_id}")
while replica_lag(conn) > MAX_LAG_S:
time.sleep(5)
upper = last_id + BATCH
started = time.monotonic()
with conn.transaction():
conn.execute("SET LOCAL lock_timeout = '2s'")
conn.execute("SET LOCAL statement_timeout = '30s'")
cur = conn.execute("""
UPDATE orders o SET region_id = c.region_id
FROM customers c
WHERE o.customer_id = c.id
AND o.id > %s AND o.id <= %s
AND o.region_id IS NULL""", (last_id, upper))
conn.execute("""
INSERT INTO backfill_progress (job, last_id, updated_at)
VALUES (%s, %s, now())
ON CONFLICT (job) DO UPDATE SET last_id = EXCLUDED.last_id,
updated_at = now()""", (JOB, upper))
last_id = upper
took = time.monotonic() - started
print(f"id<={upper} updated={cur.rowcount} {took*1000:.0f}ms", flush=True)
time.sleep(PAUSE + took) # yield at least as long as we worked
pg_stat_replication is only visible on the primary and needs pg_monitor (or
superuser) to show lag columns; on managed services read lag from the provider’s metric
instead. max_id is captured once; rows inserted later are covered by step 3.
Pure SQL alternative
Postgres 11+ procedures can commit between batches; run with CALL outside a transaction:
CREATE PROCEDURE backfill_region(batch int DEFAULT 2000) LANGUAGE plpgsql AS $$
DECLARE lo bigint := 0; hi bigint;
BEGIN
SELECT max(id) INTO hi FROM orders;
WHILE lo < hi LOOP
UPDATE orders o SET region_id = c.region_id FROM customers c
WHERE o.customer_id = c.id AND o.id > lo AND o.id <= lo + batch
AND o.region_id IS NULL;
lo := lo + batch;
COMMIT;
PERFORM pg_sleep(0.05);
END LOOP;
END $$;
It cannot watch replica lag or resume from a checkpoint; prefer the script for anything that runs longer than a few minutes.
MySQL notes
- Same keyset loop; check lag with
SHOW REPLICA STATUS(Seconds_Behind_Source, 8.0.22+) or a heartbeat table (pt-heartbeat) for accuracy. - Large
UPDATEwith a range on InnoDB takes next-key locks on the range; keep batches small and the predicate on the primary key. innodb_lock_wait_timeoutper session (SET SESSION innodb_lock_wait_timeout = 5).
Framework jobs
# Rails: each batch is one UPDATE, no callbacks or validations
Order.where(region_id: nil).in_batches(of: 2000) do |batch|
batch.update_all("region_id = (SELECT region_id FROM customers WHERE customers.id = orders.customer_id)")
sleep 0.05
end
For long Rails backfills prefer a resumable framework (the maintenance_tasks gem) over a
rake task that dies with the SSH session. In Django, iterate primary-key ranges and call
QuerySet.filter(pk__gt=lo, pk__lte=hi, region_id__isnull=True).update(...); never
for obj in Model.objects.all(): obj.save().
Run it
- [ ] Dry run on a production-sized copy; record rows/s and per-batch latency
- [ ] Estimate duration: remaining rows / measured rate; agree on a window
- [ ] Create backfill_progress and backfill_control tables (or equivalent)
- [ ] Get explicit go-ahead for production; start in tmux or as a tracked job
- [ ] Watch: replica lag, p95 API latency, lock waits, dead tuples, CDC consumer lag
- [ ] On anomaly: flip the kill switch, investigate, resume from checkpoint
Dead tuples accumulate with every update. Watch n_dead_tup in pg_stat_user_tables; if
autovacuum falls behind, slow the job or run VACUUM (ANALYZE) orders between phases.
Verification
-- nothing left behind (rows that should have changed)
SELECT count(*) FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.region_id IS DISTINCT FROM c.region_id;
-- spot check against the source of truth
SELECT o.id, o.region_id, c.region_id FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY random() LIMIT 20;
- Remaining count is 0, including rows inserted during the run.
- Totals or checksums match where data was copied:
SELECT count(*), sum(amount_cents) FROM oldvsnew. - Only after verification: add the
NOT NULLconstraint or switch reads ($zero-downtime-migrations).
Gotchas
- Updating an indexed column, or any column on a table without HOT room, writes index entries for every row: expect WAL volume roughly the size of the table plus indexes.
- Triggers that bump
updated_atmake every row look recently changed to sync jobs. ORDER BY random()orLIMITwithoutORDER BYin batch selection skips or repeats rows.- Non-monotonic UUIDv4 keys still work for keyset walking (ordering is stable), but ranges
are uneven; batch by
LIMITonid > lastinstead of fixed numeric ranges. - A backfill run from a laptop dies with Wi-Fi. Run it where it can survive (a job, a pod, tmux on a bastion).
Report template
**Target:** orders.region_id, 84.2M rows, 61.0M needing update
**Method:** keyset batches of 2,000 by id, 50 ms pause plus adaptive sleep, lag cap 5 s
**Pre-reqs done:** dual write deployed in v412; audit trigger kept (about +12% WAL)
**Run:** 6 h 40 min, 2,540 rows/s avg, paused twice for lag (max 4.1 s)
**Verified:** mismatch count 0; 20-row spot check OK; NOT NULL validated after
**Leftovers:** drop backfill_progress after one week What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Data Backfills"
short_description: "Touch 80 million rows without anyone noticing"
default_prompt: "$data-backfills Backfill the new region_id column on our 80M-row orders table without hurting production" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.