Streaming Large Result Sets with asyncpg Cursors¶
await conn.fetch("SELECT * FROM events") is the first thing anyone writes with asyncpg, and it is the right call for a query that returns a page of rows. It is the wrong call for an export, a backfill, a nightly reconciliation or anything else whose row count grows with the business, because it decodes the entire result into a Python list before your first line of processing code runs. On the table used throughout this guide — 200,000 rows holding 42.7 MB of payload text — that costs 70.3 MiB of peak Python memory, measured with tracemalloc. The same query through a server-side cursor costs 0.8 MiB, because only one batch exists at a time. This guide shows how to make that switch, how to size the batch, and the four constraints a cursor imposes in return.
Prerequisites¶
- Python 3.11+ and
asyncpg(pip install asyncpg) with a reachable PostgreSQL server. Every number here came from asyncpg 0.31 against PostgreSQL 18 over a loopback connection. - Driver basics from Async Database Drivers, especially how a connection pool hands out connections.
- Async iteration from writing async iterators for paginated APIs, since a cursor is consumed with
async for.
1. Measure what fetching everything costs¶
Before changing anything, put a number on the problem. tracemalloc reports the peak Python-object memory of a block, which is exactly what the row list occupies:
import asyncio
import tracemalloc
import asyncpg
QUERY = "SELECT id, payload FROM events"
async def fetch_all(dsn: str) -> None:
conn = await asyncpg.connect(dsn)
tracemalloc.start()
rows = await conn.fetch(QUERY) # every row decoded up front
total = sum(len(r["payload"]) for r in rows)
peak = tracemalloc.get_traced_memory()[1] / 1024 / 1024
print(f"rows={len(rows)} bytes={total} peak={peak:.1f} MiB")
await conn.close()
Run against the 200,000-row table this prints rows=200000 bytes=42688890 peak=70.3 MiB. The peak is larger than the payload itself because each row becomes a Record object holding decoded Python strings. The figure scales linearly: ten times the rows is roughly ten times the memory, and a service that does this in a request handler under concurrency multiplies it again by the number of concurrent handlers.
Verify: the reported peak grows in proportion to the row count when you change the LIMIT.
2. Stream the same query through a cursor¶
A cursor asks PostgreSQL to keep the result on the server and hand it over in batches. In asyncpg, Connection.cursor() returns a factory that is iterated with async for, and it must run inside a transaction:
async def stream_all(dsn: str) -> None:
conn = await asyncpg.connect(dsn)
tracemalloc.start()
rows = 0
total = 0
async with conn.transaction(): # a portal lives inside a transaction
async for record in conn.cursor(QUERY, prefetch=1000):
rows += 1
total += len(record["payload"])
peak = tracemalloc.get_traced_memory()[1] / 1024 / 1024
print(f"rows={rows} bytes={total} peak={peak:.1f} MiB")
await conn.close()
This prints rows=200000 bytes=42688890 peak=0.8 MiB — the same rows, the same bytes, 88 times less peak memory. Wall-clock time went from 0.38 s to 0.53 s, the cost of 200 round trips instead of one, which is a good trade whenever the result set is large enough to matter.
Forget the transaction and asyncpg tells you plainly:
asyncpg.exceptions.NoActiveSQLTransactionError: cursor cannot be created outside of a transaction
Verify: the peak stays flat as you raise the row count, while step 1's peak keeps climbing.
3. Size the prefetch for round trips, not memory¶
prefetch controls how many rows asyncpg requests per FETCH. It is the only tuning knob that matters, and its effect is entirely about round trips. Streaming 50,000 rows over loopback:
| prefetch | round trips | elapsed |
|---|---|---|
| 1 | 50,000 | 3.42 s |
| 10 | 5,000 | 0.38 s |
| 100 | 500 | 0.07 s |
| 1,000 | 50 | 0.04 s |
| 10,000 | 5 | 0.04 s |
The default of 50 already avoids the pathological case. Below about 100 the per-round-trip latency dominates; above about 1,000 you are buying nothing but a larger batch in memory. On a real network — a managed database a millisecond away rather than a loopback socket — the left-hand rows get dramatically worse, so the practical advice is: leave it alone, or set it to a round number between 100 and 1,000 that matches the batch size your processing code wants anyway.
Verify: time the same query at two prefetch sizes; the ratio should track the round-trip count until it flattens.
4. Process in batches rather than row by row¶
Most streaming jobs write somewhere else — another table, a file, an HTTP endpoint — and those destinations prefer batches. Cursor.fetch(n) returns a list and an empty list at the end, which makes a clean while loop:
async def export_batches(conn, batch_size: int = 50_000):
async with conn.transaction():
cursor = await conn.cursor("SELECT id FROM events ORDER BY id")
while batch := await cursor.fetch(batch_size): # empty list ends the stream
print("batch of", len(batch), "first id", batch[0]["id"])
Against the same table this prints four lines, first ids 1, 50001, 100001 and 150001. The sibling methods are fetchrow() for a single record and forward(n), which skips n rows server-side and returns how many it actually moved — useful for resuming a job at a known offset without transferring the rows you are skipping.
An async generator is the more composable shape, because the caller never sees the cursor at all:
async def export_rows(conn, batch_size: int = 1000):
"""Stream a large table, yielding lists of records."""
batch = []
async with conn.transaction():
async for record in conn.cursor(QUERY, prefetch=batch_size):
batch.append(record)
if len(batch) == batch_size:
yield batch
batch = []
if batch: # the final short batch
yield batch
Consuming it with async for batch in export_rows(conn) streamed all 200,000 rows at a 1.2 MiB peak. Note that the transaction is held open across yield, so the caller must consume the generator to completion or close it explicitly — see closing async generators with aclosing for why an abandoned generator leaves that transaction open until garbage collection.
Verify: the batch sizes sum to the table's row count, and the last batch is the only short one.
5. Consume inside the pool acquire¶
A cursor is bound to its connection, and a pooled connection goes back to the pool at the end of the async with. Returning a cursor from a helper that acquires and releases produces a failure that is confusing out of context:
asyncpg.exceptions._base.InterfaceError: cannot call CursorFactory.__aiter__():
the underlying connection has been released back to the pool
The fix is structural: the acquire, the transaction and the consumption all live in one block.
async def stream_from_pool(pool) -> int:
total = 0
async with pool.acquire() as conn, conn.transaction():
async for record in conn.cursor(QUERY, prefetch=2000):
total += len(record["payload"]) # consumed before the release
return total
That returned 42688890 bytes, matching the non-pooled runs exactly. Remember that the connection is pinned for the whole stream: a long export with max_size=4 leaves three connections for everything else, so give background exports their own small pool rather than letting them compete with request traffic. The reasoning is the same as bulkheads — isolate the slow work so it cannot exhaust the shared resource.
Verify: the byte total from the pooled version equals the direct-connection version.
6. Keep the transaction short enough to survive¶
The portal lives inside a transaction, and a transaction that sits idle is a liability. Most production servers set idle_in_transaction_session_timeout for exactly this reason. With it set to one second, a consumer that pauses for two seconds between batches loses the connection:
conn = await asyncpg.connect(dsn, server_settings={"idle_in_transaction_session_timeout": "1000"})
first batch: [1, 2]
after idle 2s -> InterfaceError : cannot call Cursor.fetch(): the underlying connection is closed
The server log is explicit: FATAL: terminating connection due to idle-in-transaction timeout. asyncpg surfaces it only as a closed connection, which is why this failure is usually misread as a network problem.
Three ways to stay under the limit, in order of preference:
- Do the slow work outside the loop. Collect a batch, close nothing, but push the HTTP call or the file write into a task whose result you await after the transaction ends, or use a bounded queue with a consumer task as in batching queue items by size and time.
- Chunk by key instead of by cursor. For a table with a monotonic id,
WHERE id > $1 ORDER BY id LIMIT nin a loop gives you restartability and no long transaction, at the cost of re-planning each query. - Raise the timeout for that session only, via
server_settingson a dedicated export connection — a deliberate, local exception rather than a server-wide one.
Verify: with a short idle_in_transaction_session_timeout and a sleep in the loop, the stream fails; moving the sleep outside the transaction makes it pass.
Verification¶
A streaming read is correct when:
- Memory is flat: the
tracemallocpeak does not grow when the row count does. - Row counts match: the streamed count and byte total equal what
conn.fetch()returned on the same query. - Round trips are sane: rows divided by
prefetchis in the tens or hundreds, not the tens of thousands. - Nothing outlives its scope: no cursor is used after its
acquireblock, and no generator holding a transaction is abandoned. - The transaction stays busy: there is no
awaiton an external service between batches inside the transaction.
Pitfalls & edge cases¶
prefetchwith a manualfetch(n). Passingprefetchand then callingcursor.fetch(n)mixes two batch sizes; pick one. asyncpg raisesInterfaceErrorif you passprefetchto a cursor you then drive manually.- Sorting without an index. A cursor does not make
ORDER BYincremental — PostgreSQL still materialises and sorts the whole set server-side before the first row arrives. The client memory is saved; the server work is not. - Cancellation mid-stream. Cancelling the consuming task unwinds through the
async with, which rolls back the transaction and drops the portal. That is correct, but any partial work you did elsewhere is not rolled back — see cancellation patterns. - Reading and writing on one connection. The cursor pins the connection, so writes based on the streamed rows need a second connection, which puts them in a different transaction. Plan for the reader seeing a snapshot older than the writer's.
COPYis faster for a plain dump. If the destination is a file or another table,copy_from_queryandcopy_records_to_tableskip Python object creation entirely.
Frequently Asked Questions¶
How do I iterate over a large query result in asyncpg without loading it all?
Open a transaction and iterate the cursor factory: async with conn.transaction(): async for record in conn.cursor(query, prefetch=1000). asyncpg fetches prefetch rows per round trip and decodes only that batch, so peak memory stays constant regardless of the result size.
Why does asyncpg raise NoActiveSQLTransactionError when I create a cursor?
A server-side cursor is a portal, and PostgreSQL portals only exist inside a transaction. Wrap the iteration in async with conn.transaction(). asyncpg deliberately does not open one implicitly, because the transaction's lifetime is a decision with consequences for locking and for idle-in-transaction timeouts.
What prefetch value should I use with an asyncpg cursor?
Something between 100 and 1,000 for most workloads. The value only controls how many rows come back per round trip: measured on 50,000 rows, prefetch=1 took 3.42 s, prefetch=100 took 0.07 s, and prefetch=1000 took 0.04 s, after which more prefetch buys nothing but memory per batch.
Can I use an asyncpg cursor with a connection pool?
Yes, as long as the cursor is consumed inside the same async with pool.acquire() block. Once the connection returns to the pool, using the cursor raises InterfaceError. Also remember the connection is pinned for the whole stream, so long exports deserve their own small pool.
Why does my long asyncpg stream fail with 'the underlying connection is closed'?
Usually the server terminated the session for exceeding idle_in_transaction_session_timeout, which the PostgreSQL log records as "terminating connection due to idle-in-transaction timeout". It happens when the consumer does slow work between batches. Move that work outside the transaction, or page by key instead of holding a cursor open.
Related¶
- Async Database Drivers — up to the topic overview for pooling, transactions and driver choice.
- Using LISTEN/NOTIFY with asyncpg — the other long-lived use of a dedicated asyncpg connection.
- Batching queue items by size and time — how to hand streamed batches to a consumer without stalling the reader.
- Network I/O & Protocol Handling — the section overview for I/O-bound work.