Backfilling Data in Rails Without Locking

A new region column needs values for forty million existing orders. The first attempt was one line in a migration — Order.update_all("region = CASE ship_country WHEN 'DE' THEN 'eu' ... END") — and it ran as a single UPDATE inside the migration’s transaction for twenty-five minutes. Every row it touched stayed locked until the end, so checkout requests updating their own orders queued behind it; replicas received the change as one giant transaction and fell minutes behind; and the deploy pipeline timed out, rolling the whole thing back and leaving nothing done. Backfills are data work, not schema work, and they need a different shape: small batches, each its own short transaction, paced to what the database and replicas can absorb, and resumable when interrupted. This guide builds that shape in Rails. It is part of Rails Active Record Migrations.

Replica Lag: One UPDATE vs Throttled Batches Line chart of replica lag during a 40 million row backfill. A single UPDATE shows zero lag while it runs on the primary, then lag jumps to 25 minutes when it commits and replicas start applying it. Throttled batches of 5,000 rows with a short sleep keep lag below 2 seconds throughout, finishing in about 60 minutes. Replica Lag: One UPDATE vs Throttled Batches 0 500 1000 1500 0 10 20 30 40 50 60 minutes since start replica lag (s) single UPDATE batches of 5,000 + sleep
The single statement looks fine until it commits; batches spread the same work into increments replicas can keep up with.

Symptom / Error Signatures

A backfill written as a single statement or a single transaction shows these signals:

  • pg_stat_activity shows the migration’s session with an xact_start minutes old, and application sessions waiting on transactionid locks for rows it has updated.
  • Replica lag is flat during the backfill and then spikes sharply when it commits.
  • n_dead_tup on the table rises steeply, and autovacuum cannot remove the old row versions until the transaction ends.
  • The deploy fails with a timeout, or ActiveRecord::QueryCanceled: PG::QueryCanceled: ERROR: canceling statement due to statement timeout, and all progress is lost because the transaction rolled back.
  • With find_each and per-record save, the backfill is instead extremely slow and fires callbacks and validations for every row.

Root Cause Analysis

Three properties of a naive backfill cause the damage. It runs inside the migration transaction, so its row locks and dead tuples accumulate until the very end, and replicas receive it only at commit. It is one statement or one loop, so there is nowhere to pause, measure lag or resume after failure. And it runs as part of the deploy, so its duration becomes the deploy’s duration.

Rails provides the building blocks to fix all three. in_batches(of: n) walks the table by primary key and yields relations of n rows; calling update_all on each relation issues one set-based UPDATE ... WHERE id IN (...) or id-range statement per batch, with no model instantiation or callbacks. Running outside a transaction — in a migration with disable_ddl_transaction!, or better, in a background job — makes each batch commit on its own. A short sleep between batches, and a check of replica lag, keeps the pace sustainable, using the reasoning in tuning backfill batch size against replication lag.

Approach Transaction size Callbacks Resumable Where it runs
update_all on whole table in migration one huge no no deploy
find_each { save } one per row (or one huge in migration) yes partly deploy
in_batches.update_all + disable_ddl_transaction! one per batch no yes, with a filter deploy
background job over in_batches one per batch no yes outside deploy
Backfill Outside the Deploy The schema migration adds the nullable column and enqueues a backfill job, then the deploy finishes. A background worker runs the job, processing batches of 5,000 rows with update_all, checking replica lag before each batch and sleeping between batches. Progress is resumable because each batch only touches rows where region is null. A later release tightens the column after the job reports completion. Backfill Outside the Deploy Schema migration add_column region (nullable) Job queue BackfillOrderRegionJob Worker in_batches(of: 5000) Lag check pause if replica lag > 2 s Primary short UPDATE per batch Next release validate + NOT NULL enqueue update_all
The deploy only adds the column; the backfill runs at its own pace in a worker, and the NOT NULL tightening waits for it to finish.

Immediate Mitigation

1. If a monolithic backfill is hurting production, cancel it. It will roll back entirely, which is painful but immediately releases locks and stops the replica backlog from growing.

-- PostgreSQL · requires pg_signal_backend · WARNING: rolls back every row updated so far
SELECT pid, now() - xact_start AS xact_age, left(query, 80) FROM pg_stat_activity
WHERE query ILIKE 'UPDATE "orders"%' AND state = 'active';
SELECT pg_cancel_backend(<pid>);

2. Rewrite it as batched and idempotent. Filter on the rows that still need work so the backfill can be stopped and restarted freely.

# Ruby · app/jobs/backfill_order_region_job.rb · runs in a background worker, not in the deploy
# WARNING: each batch commits independently; the job must remain idempotent (region IS NULL filter).
class BackfillOrderRegionJob < ApplicationJob
  BATCH = 5_000
  MAX_LAG_SECONDS = 2

  def perform
    Order.where(region: nil).in_batches(of: BATCH) do |batch|
      wait_for_replicas
      batch.update_all(<<~SQL.squish)
        region = CASE ship_country WHEN 'DE' THEN 'eu' WHEN 'FR' THEN 'eu'
                                   WHEN 'US' THEN 'na' WHEN 'CA' THEN 'na' ELSE 'other' END
      SQL
      sleep 0.05
    end
  end

  private

  def wait_for_replicas
    loop do
      lag = ActiveRecord::Base.connection.select_value(
        "SELECT COALESCE(EXTRACT(EPOCH FROM max(replay_lag)), 0) FROM pg_stat_replication").to_f
      break if lag < MAX_LAG_SECONDS
      sleep 1
    end
  end
end
# ROLLBACK PATH: the column is nullable; reverting code leaves it unused, and a later migration can drop it.

3. Enqueue the job from a migration or a one-off task. Keep the schema migration fast; it only adds the column.

# Ruby · db/migrate/20260918120000_add_region_to_orders.rb
class AddRegionToOrders < ActiveRecord::Migration[7.1]
  def change
    add_column :orders, :region, :string   # nullable, instant
  end
end
# then, after deploy: bin/rails runner "BackfillOrderRegionJob.perform_later"

Permanent Fix / Long-Term Pattern

Treat backfills as a first-class, separate step in the expand-and-contract lifecycle. The schema migration adds the new structure; a background job fills it, idempotently, in throttled batches; the next release tightens constraints once the job reports completion. That sequence is framework-neutral and described in Backfill Optimization; Rails just supplies in_batches, update_all and Active Job.

Make batches efficient on large tables. in_batches on a filtered relation (where(region: nil)) plucks ids for each batch; with an index on the filter column, or on PostgreSQL a partial index WHERE region IS NULL, those lookups stay fast as the backfill progresses. For the very largest tables, iterate explicit id ranges (where(id: start...start + BATCH)) to avoid the id plucking entirely, per cursor-based vs keyset pagination for large backfills. Record progress (last id, rows done) somewhere visible, and alert if the job stalls.

When the backfill must run inside a migration — for example because a later migration in the same deploy depends on it — use disable_ddl_transaction! and the same batched, idempotent loop, and keep that migration free of schema operations.

Backfill Lifecycle Across Releases Five steps: release 1 adds the nullable column and code writes new rows; a background job backfills old rows in throttled batches; a check confirms zero remaining nulls; release 2 adds a check constraint without validation then validates; finally the column becomes NOT NULL. Backfill Lifecycle Across Releases STEP 1 Add column nullable; new writes set it STEP 2 Background backfill batches + lag check STEP 3 Confirm complete count where null = 0 STEP 4 Validate constraint validate: false, then validate STEP 5 NOT NULL no scan on PG 12+
Each release depends only on work the previous one has provably finished.

Verification Checklist

Frequently Asked Questions

Why not use find_each and save? It instantiates every record, runs validations and callbacks, and issues one UPDATE per row with all changed attributes. For a backfill that is orders of magnitude slower than a set-based update_all per batch, and callbacks may have side effects you do not want to trigger for historical data.

Does in_batches hold a transaction open? No. in_batches only paginates; transactions are determined by where the code runs. Inside a normal PostgreSQL migration everything shares the migration’s transaction, which is why the backfill belongs in a job or a migration with disable_ddl_transaction!.

How big should a batch be? Start with a few thousand rows and adjust based on the time per batch and replica lag. Aim for batches that finish in well under a second; if lag rises, shrink the batch or lengthen the sleep rather than letting the backfill race ahead.

What if the backfill job fails halfway? Rerun it. Because each batch committed and the relation filters on rows that still need work, the rerun continues where the previous attempt stopped without redoing or double-applying anything.