Processing large tables in batches with partitions

Use result.partitions(n) when the work is read-only and can finish in one transaction, and a restartable keyset loop — WHERE id > :last ORDER BY id LIMIT n, committing each batch — whenever the job writes, because committing part-way through a server-side cursor invalidates it. This guide belongs to streaming large result sets with yield_per.

Quick Answer

Streaming and committing do not combine: the cursor belongs to the transaction the commit ends.

You cannot commit while streaming Left: a server-side cursor lives inside a transaction, so committing part-way through invalidates it and the next batch fails; the alternative is one enormous transaction that holds a snapshot for the whole run. Right: the keys are read first, or a keyset walk fetches each batch in its own transaction, so every batch can be committed independently. stream + commit per batch the cursor belongs to the transaction commit closes it the next batch raises or one transaction for the whole run keys first, or a keyset walk each batch is its own transaction commit freely restartable from the last key vacuum is not held back Streaming is for reading. Read-modify-write over a large table needs batches that stand alone.

Before — committing inside a streamed iteration:

from sqlalchemy import select

from shop.models import Order


async def normalise_orders(session) -> None:
    result = await session.stream_scalars(
        select(Order).order_by(Order.id).execution_options(yield_per=1_000)
    )
    async for order in result:
        order.reference = order.reference.strip().upper()
        await session.commit()        # ends the transaction the cursor lives in
# sqlalchemy.exc.InvalidRequestError / the cursor is gone on the next batch,
# and without the commit this becomes one transaction over the whole table.

After — a restartable keyset loop, one transaction per batch:

import logging

from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker

from shop.models import Order

log = logging.getLogger("jobs.normalise")


async def normalise_orders(Session: async_sessionmaker, batch_size: int = 1_000) -> int:
    last_id, processed = 0, 0
    while True:
        async with Session.begin() as session:
            ids = list((await session.scalars(
                select(Order.id)
                .where(Order.id > last_id, Order.reference.is_not(None))
                .order_by(Order.id)
                .limit(batch_size)
            )))
            if not ids:
                return processed
            await session.execute(
                update(Order)
                .where(Order.id.in_(ids))
                .values(reference=func.upper(func.trim(Order.reference)))
            )
            last_id = ids[-1]
            processed += len(ids)
        log.info("normalised %d orders (through id %d)", processed, last_id)

Each iteration is its own transaction: it commits, releases its snapshot, and records a position the next iteration continues from. A failure loses at most one batch, and a restart with the last logged id resumes rather than beginning again.

Execution Context & Async Workflow Integration

yield_per puts the result into streaming mode — a server-side cursor on PostgreSQL — and result.partitions(n) yields lists of rows from it. Both belong to the transaction that opened the cursor. commit() or rollback() ends that transaction, and the cursor with it, which is why a batch loop that commits cannot be built on top of streaming.

A restartable batch loop Five steps. Select a bounded batch of rows ordered by primary key, starting after the last key processed. Do the work for that batch. Commit, which ends the transaction and releases its snapshot. Record the last key, so a restart resumes rather than beginning again. Repeat until a batch comes back shorter than the limit, which means the end of the table. SELECT ... WHERE id > :last ORDER BY id LIMIT 1000 a bounded batch its own transaction process the batch update, enqueue, transform in the same transaction COMMIT snapshot released locks released record the last key progress is durable a restart resumes here repeat until short fewer rows than the limit Ordering by primary key keeps every batch an index scan, whatever the table size.

That leaves two distinct shapes, and choosing between them is the main decision in any large-table job.

Streaming, one transaction. Right when the work is read-only and can complete in one pass: an export, a checksum, feeding rows to an external API. partitions() is the convenient form because most such work batches naturally:

from sqlalchemy import select

from shop.models import Order


async def export_batches(session) -> None:
    result = await session.stream(
        select(Order.id, Order.total_cents)
        .order_by(Order.id)
        .execution_options(yield_per=5_000)
    )
    async for batch in result.partitions(5_000):
        await ship_to_warehouse(batch)      # one call per 5,000 rows

Keyset batches, a transaction each. Required whenever the job writes, and preferable for anything long-running even when it does not, because it is restartable and does not hold a snapshot.

The costs of the single long transaction are worth being concrete about. It holds a snapshot, so VACUUM cannot reclaim rows deleted by other transactions while it runs — a job over a large table for an hour can leave a table measurably bloated. It occupies a pooled connection throughout. It cannot be resumed. And on a hot standby, a long query can be cancelled outright when it conflicts with replay, which is a failure mode that only appears in production.

Two details make keyset batching correct rather than approximately correct. Order by a unique key — the primary key — so id > :last is a total ordering with no ties to skip or repeat; ordering by a timestamp with duplicates loses rows. And re-check the work predicate in each batch (reference IS NOT NULL above), because rows change while the job runs, and a batch selected on stale criteria can act on rows that no longer qualify.

For the write itself, prefer a set-based statement over loading objects. update(Order).where(Order.id.in_(ids)) is one statement per batch; loading a thousand objects and flushing is a thousand UPDATEs unless the ORM can batch them, plus the memory and the identity map. The exception is work that must run per-object Python — validators, events, version counters — which is the trade-off described in using ORM-enabled UPDATE and DELETE statements.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
The second batch fails after a commitThe commit ended the transaction that owned the server-side cursor.A keyset loop, one transaction per batch.
OFFSET-based batching gets slower every batchOFFSET n makes the database count and discard n rows.Keyset: WHERE id > :last ORDER BY id LIMIT n.
Rows processed twice, or skippedOrdering by a non-unique column, or by a column the job updates.Order by the primary key.
The table bloats during the jobOne long transaction holding a snapshot.Commit per batch.
QueryCanceledError on a replicaA long query conflicting with replay.Shorter transactions; or run against the primary.
Pool timeouts while the job runsThe job holds a connection for its whole duration.Batches release the connection between iterations; use a separate small engine.
Memory grows batch after batchObjects accumulating in the session's identity map.A new session per batch, or session.expunge_all().
Three iteration shapes Three tiles. yield_per with a row loop streams rows one at a time in a single read-only transaction, which suits exports. partitions gives lists of rows from the same stream, which suits work that batches naturally such as writing a file or posting to an API. And a keyset loop re-queries per batch in its own transaction, which is the only shape that can commit as it goes. yield_per + async for row one row at a time one transaction, read-only result.partitions(n) lists of rows same stream, batched keyset loop a query per batch commits as it goes Only the third survives a restart, because only it has a position it can resume from.

The identity-map growth is worth expanding, because it surprises people who did switch to batches. A session keeps every object it loaded, so a loop that reuses one session for a thousand batches holds a million objects by the end. Opening a session per batch — async with Session.begin() inside the loop, as in the quick answer — solves it completely, and costs one checkout per batch rather than one per row.

Progress reporting deserves to be durable rather than logged. A job that records its position in a table can resume exactly, and its progress is visible to an operator:

from sqlalchemy import select, update

from shop.models import JobProgress


async def resume_point(session, job: str) -> int:
    return await session.scalar(
        select(JobProgress.last_id).where(JobProgress.job == job)
    ) or 0


async def record_progress(session, job: str, last_id: int) -> None:
    await session.execute(
        update(JobProgress).where(JobProgress.job == job).values(last_id=last_id)
    )

Updating that row inside the batch transaction is what makes the resume exact: the work and the position commit together, so the recorded position can never be ahead of the work. Logging it separately, after the commit, leaves a window where a crash re-processes one batch — acceptable when the work is idempotent, and not when it is not.

Throttling is the last practical addition. A batch loop at full speed can saturate the database's write capacity and starve request traffic. A short sleep between batches, or a check of replication lag before continuing, turns the job into a background process rather than an incident:

import asyncio

from sqlalchemy import text

MAX_LAG_SECONDS = 5.0

lag = await session.scalar(text(
    "SELECT COALESCE(EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())), 0)"
))
if lag and lag > MAX_LAG_SECONDS:
    await asyncio.sleep(lag)

Advanced: Parallel Batches and Work Claiming

A sequential batch loop is bounded by one connection's throughput. When a job has to finish inside a window, the batches can run concurrently — with two constraints: the concurrency must be bounded by the pool, and no two workers may process the same rows.

What a long transaction costs Four costs. It holds a snapshot, so dead rows from other transactions cannot be vacuumed for its duration and the table bloats. It holds a pooled connection, which other work cannot use. It cannot be restarted, so a failure at ninety percent discards the work. And on a replica, a long-running query can conflict with replay and be cancelled. vacuum is held back for the whole run dead rows accumulate; the table and its indexes bloat a pooled connection is held throughout size the pool for the job as well as for requests no restart point a failure at 90% discards everything, and the retry starts over replica replay conflicts a long query on a standby can be cancelled to let replay proceed

For a job that can partition its work statically, the simplest safe split is by key range:

import asyncio

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker

from shop.models import Order


async def parallel_normalise(Session: async_sessionmaker, workers: int = 4) -> int:
    async with Session() as session:
        lo, hi = (await session.execute(
            select(func.min(Order.id), func.max(Order.id))
        )).one()
    if lo is None:
        return 0

    step = (hi - lo) // workers + 1
    ranges = [(lo + i * step, min(lo + (i + 1) * step, hi + 1)) for i in range(workers)]

    async def run(start: int, end: int) -> int:
        return await normalise_range(Session, start, end)

    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(run(start, end)) for start, end in ranges]
    return sum(task.result() for task in tasks)

Each worker owns a disjoint key range, so no coordination is needed, and each runs its own keyset loop inside that range with its own sessions. Keep workers at or below pool_size, for the reasons in running concurrent queries with AsyncSession; key ranges are also uneven when ids are sparse, so expect one worker to finish early.

When the work cannot be partitioned in advance — a queue of rows arriving continuously — claiming is the alternative. SELECT ... FOR UPDATE SKIP LOCKED hands each worker a batch nobody else will touch:

from sqlalchemy import select, update

from shop.models import Job


async def claim_batch(session, size: int = 100) -> list[int]:
    ids = list((await session.scalars(
        select(Job.id)
        .where(Job.status == "pending")
        .order_by(Job.id)
        .limit(size)
        .with_for_update(skip_locked=True)
    )))
    if ids:
        await session.execute(
            update(Job).where(Job.id.in_(ids)).values(status="running")
        )
    return ids

That is the job-queue pattern in full, with its own guide in building a job queue with SELECT FOR UPDATE SKIP LOCKED. For a one-off backfill it is more machinery than key ranges need; for continuous work it is the right shape.

One measurement decides whether any of this is worth it: whether the job is bound by the database or by the application. If each batch's UPDATE is the slow part, more workers make it slower by competing for the same write capacity. If the slow part is per-row Python or an external API call, parallel batches help a great deal.

Choosing a Batch Size

Batch size is the one parameter these jobs always need tuned, and it is tuned against three limits rather than one.

Batch in Python, or batch in SQL Left: the loop loads a thousand objects, changes an attribute on each and flushes, which issues a statement per row unless the ORM batches them. Right: the loop selects a bounded set of keys and issues one set-based UPDATE for the batch, so each iteration is two statements regardless of batch size. load, mutate, flush 1,000 objects in the identity map per-object events and validation run a statement per row at flush right when hooks must run select keys, UPDATE ... WHERE id IN no objects loaded two statements per batch set-based, index-driven right for mechanical changes Choose by whether per-object Python has to run — not by which reads more naturally.

Lock duration. Every batch holds row locks until it commits, and a batch that takes two seconds blocks concurrent writers to those rows for two seconds. For a table serving live traffic, that is the binding constraint: keep batches short enough that a user never waits noticeably. A few hundred to a few thousand rows is the usual range.

Statement efficiency. Very small batches waste round trips: a batch of ten rows spends most of its time on the two statements rather than the work. There is a clear knee in the curve, typically somewhere between a hundred and a few thousand rows, past which larger batches stop helping.

WAL and replication. Each commit produces WAL that replicas must apply. Large batches produce it in bursts, and a job that outruns replication lag either delays replicas or — if a standby is serving reads — starts returning stale data. Watching pg_last_xact_replay_timestamp() between batches, as above, turns that from a surprise into a throttle.

A reasonable procedure is to start at 1,000, measure three things per batch — duration, rows affected, and replication lag after the commit — and adjust:

import time


async def timed_batch(Session, last_id: int, size: int) -> tuple[int, float]:
    started = time.perf_counter()
    async with Session.begin() as session:
        count = await process_one_batch(session, last_id, size)
    return count, time.perf_counter() - started

If batches take under about fifty milliseconds, raise the size; if they exceed a second on a table with live traffic, lower it. Log the numbers rather than inferring them — a job whose batch duration climbs as it runs is usually hitting a missing index on the predicate, not a batch-size problem.

Finally, decide up front what the job does when it fails. A restartable loop with a durable position needs nothing but a retry. A loop that logs its position needs an operator to pass it back. A loop with neither has to start over, which for a table of tens of millions of rows may mean it never completes at all. Writing the position into a table, in the same transaction as the work, is a few lines that turn a fragile script into a job that can be run again without thought — the same reasoning behind resumable migration loops in running Alembic across multiple databases and tenant schemas.

Frequently Asked Questions

Can I commit while iterating a streamed result?

No. The server-side cursor belongs to the transaction, so a commit invalidates it. Use a keyset loop that re-queries per batch if the job needs to commit as it goes.

What is result.partitions() for?

It yields lists of rows from a streamed result, which suits work that batches naturally — writing a file, posting to an API. It is still one transaction, so it is for read-only work.

Why is my OFFSET-based batch job getting slower?

Because OFFSET n makes the database count and discard n rows before returning any. Use keyset pagination — WHERE id > :last ORDER BY id LIMIT n — which is constant cost per batch.

How large should a batch be?

Start at about a thousand rows and tune against lock duration on a live table, statement overhead, and replication lag. Batches over a second are usually too large; batches under fifty milliseconds are too small.