|
| 1 | +--- |
| 2 | +id: databases-query-optimization-streaming-large-result-sets |
| 3 | +domain: databases |
| 4 | +category: query-optimization |
| 5 | +applies_to: [postgresql, python, general] |
| 6 | +confidence: verified |
| 7 | +sources: |
| 8 | + - https://github.com/psycopg/psycopg2/blob/master/doc/src/usage.rst |
| 9 | + - https://github.com/psycopg/psycopg2/blob/master/doc/src/cursor.rst |
| 10 | + - https://openpyxl.readthedocs.io/en/stable/optimized.html |
| 11 | +last_verified: 2026-07-13 |
| 12 | +related: [databases-query-optimization-keyset-pagination] |
| 13 | +--- |
| 14 | + |
| 15 | +# Streaming Large Query Result Sets into the App |
| 16 | + |
| 17 | +## When this applies |
| 18 | + |
| 19 | +A single query returns a very large result (hundreds of thousands of rows or |
| 20 | +more) and your process reads it all into the app — typically to export to a file |
| 21 | +(Excel/CSV) or feed another sink. The pain is process **memory peak**, not query |
| 22 | +speed. (Splitting the read into many bounded queries → keyset-pagination.) |
| 23 | + |
| 24 | +## Do this |
| 25 | + |
| 26 | +| Situation | Do | |
| 27 | +|-----------|----| |
| 28 | +| Stream a large single-query result to the app | Server-side cursor (`cursor(name=...)` in psycopg2 → PostgreSQL `DECLARE`) fetching in chunks via `itersize`/`fetchmany` — the DB sends batches, client memory stays minimal | |
| 29 | +| Server-side cursor is unavailable (env blocks transactions/`BEGIN`) | Client-side cursor + `fetchmany(N)` batches so you never build the full Python list (`fetchall`), and write output through a **streaming writer** instead of buffering it | |
| 30 | +| Output is Excel | openpyxl `Workbook(write_only=True)` + `ws.append(row)` — never holds the whole workbook; near-constant memory (<10 MB). Install `lxml` for large dumps (serialization speed, **not** the memory saving) | |
| 31 | +| The result must be traversed twice (e.g. a post-pass needs the whole set first) | Don't hold it all in Python — spool each row to a local file (pickle/CSV) once, then stream-read the spool for the second pass | |
| 32 | + |
| 33 | +Why, in order of memory impact: |
| 34 | + |
| 35 | +1. **A client-side (default) cursor pulls the entire result set to the client on |
| 36 | + `execute`.** `fetchmany` only hands you slices of what libpq already buffered, |
| 37 | + so it caps the *Python object* explosion but not the driver buffer. Avoiding |
| 38 | + `fetchall` (the full Python list) is necessary but not sufficient. |
| 39 | +2. **Only a server-side (named) cursor truly streams** — the DB ships chunks. But |
| 40 | + it lives *inside a transaction* (created `WITHOUT HOLD`), so it fails under |
| 41 | + autocommit or wherever transaction control is blocked. |
| 42 | +3. **The output writer is often the bigger share.** A normal openpyxl workbook |
| 43 | + holds every cell object until `save()`; write-only mode removes that. |
| 44 | + |
| 45 | +## Edge cases |
| 46 | + |
| 47 | +| Case | Then | |
| 48 | +|------|------| |
| 49 | +| Named cursor under `autocommit=True` | Fetching raises "named cursor isn't valid anymore" / can't use outside a transaction — set `autocommit=False` | |
| 50 | +| A read-only access proxy blocks `BEGIN` (e.g. an access-control proxy like QueryPie) | Server-side cursor is impossible (`No permission to execute BEGIN statement`) → fall back to client-side `fetchmany` + spool + streaming writer. Measured: 300k rows `fetchall`+normal workbook 838 MB → `fetchmany`+write-only **38 MB** | |
| 51 | +| write-only workbook re-save/append | Only one `save()` is allowed (`WorkbookAlreadySaved`). Set column widths / `freeze_panes` **before** the first `append`; compute `auto_filter` from a row count, not `ws.max_row` (unreliable in write-only) | |
| 52 | +| Two-pass over the result via re-running the query | Re-executing a heavy query doubles DB load — spool the single fetch to disk and re-read it instead | |
| 53 | + |
| 54 | +## Sources |
| 55 | + |
| 56 | +- https://github.com/psycopg/psycopg2/blob/master/doc/src/usage.rst — server-side (named) cursors are `WITHOUT HOLD`, invalid after commit / under autocommit; stream large datasets in chunks |
| 57 | +- https://github.com/psycopg/psycopg2/blob/master/doc/src/cursor.rst — `fetchmany`/`itersize` semantics |
| 58 | +- https://openpyxl.readthedocs.io/en/stable/optimized.html — write-only mode, near-constant memory, one save only, lxml recommended for large dumps |
0 commit comments