Agent Wranglers

SCL-27Required

Change large tables in small batches

Update, delete or copy many rows in batches of at most 10,000, each in its own short transaction, with a pause between them. Never touch millions of rows in one statement.

How to do it

One huge UPDATE holds locks for minutes, bloats the table and leaves replicas far behind. Walk the table by primary key, as Page through long lists by key does, and commit each batch. Pause while replica lag is over its limit. Save the last key, so the job resumes there after a crash. Backfills for Expand first and contract later and archive moves use this rule.

Examples

JavaScript example

const BATCH_SIZE = 5000;
const PAUSE_MS = 200;

export async function backfillCurrency(db) {
  let lastId = (await db.oneOrNone('SELECT last_id FROM backfills WHERE name = $1', ['currency']))?.last_id ?? 0;
  for (;;) {
    const rows = await db.any(
      `UPDATE orders SET currency = 'EUR' WHERE id IN (
         SELECT id FROM orders WHERE id > $1 AND currency IS NULL ORDER BY id LIMIT $2)
       RETURNING id`, [lastId, BATCH_SIZE]);
    if (rows.length === 0) return;
    lastId = Math.max(...rows.map(row => row.id));
    await db.none('INSERT INTO backfills (name, last_id) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET last_id = $2', ['currency', lastId]);
    await new Promise(resolve => setTimeout(resolve, PAUSE_MS));
  }
}

Python example

import time

BATCH_SIZE = 5000
PAUSE_SECONDS = 0.2

def backfill_currency(conn) -> None:
    row = conn.execute("SELECT last_id FROM backfills WHERE name = 'currency'").fetchone()
    last_id = row[0] if row else 0
    while True:
        with conn.transaction():
            ids = [r[0] for r in conn.execute(
                """UPDATE orders SET currency = 'EUR' WHERE id IN (
                     SELECT id FROM orders WHERE id > %s AND currency IS NULL ORDER BY id LIMIT %s)
                   RETURNING id""", (last_id, BATCH_SIZE))]
            if not ids:
                return
            last_id = max(ids)
            conn.execute(
                "INSERT INTO backfills (name, last_id) VALUES ('currency', %s) "
                "ON CONFLICT (name) DO UPDATE SET last_id = EXCLUDED.last_id", (last_id,))
        time.sleep(PAUSE_SECONDS)

How to check

Every data migration and job that changes rows uses batches of 10,000 rows or fewer per transaction. Run the backfill on a production-sized copy. No request waits longer than the lock timeout, and replica lag stays under its limit.

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/scaling/growing-the-database/small-batches">Agent Wranglers scaling playbook</a>. In code you write, add a comment next to it with the same link.