Postgres Cursors: DECLARE, FETCH, and When to Use Them
Chat2DB TeamRun SELECT * FROM events on a 200-million-row table and your client tries to buffer 200 million rows in memory — most drivers fetch entire result sets by default. A cursor is the SQL-level answer: it holds a query open on the server and hands you rows in batches, keeping client memory flat no matter how large the result. Cursors also power row-by-row processing in plpgsql and cross-call result streaming via WITH HOLD. They're also frequently misused where a plain LIMIT or keyset pagination would be better. This guide covers all the forms with runnable SQL, plus the tradeoffs that decide when a cursor is the right tool.
SQL-level cursors: DECLARE / FETCH / CLOSE
A cursor lives inside a transaction (by default) and is consumed with FETCH:
BEGIN;
DECLARE big_scan CURSOR FOR
SELECT id, payload
FROM events
ORDER BY id;
FETCH 1000 FROM big_scan; -- first thousand rows
FETCH 1000 FROM big_scan; -- next thousand
FETCH ALL FROM big_scan; -- everything remaining (careful!)
CLOSE big_scan;
COMMIT;FETCH directions worth knowing:
FETCH NEXT FROM big_scan; -- one row forward (default)
FETCH FORWARD 500 FROM big_scan;
FETCH PRIOR FROM big_scan; -- backwards — needs SCROLL
FETCH ABSOLUTE 42 FROM big_scan; -- jump to row 42 — needs SCROLL
MOVE FORWARD 10000 IN big_scan; -- reposition without returning rowsBackward movement requires declaring SCROLL CURSOR, which may force PostgreSQL to materialize results to support rewinding; plain (NO SCROLL) cursors are cheaper.
Two properties make cursors attractive for batch jobs:
- Flat memory: the server executes the plan incrementally; the client holds one batch at a time.
- A consistent snapshot: the whole scan sees the data as of the cursor's snapshot, even while other transactions write.
And one property bites: a cursor pins its transaction (and thus its snapshot) open. A cursor consumed slowly over hours holds back vacuum's cleanup horizon for the entire database — the same damage as any long-running transaction. Consume promptly, or use WITH HOLD.
WITH HOLD: outliving the transaction
BEGIN;
DECLARE report CURSOR WITH HOLD FOR
SELECT * FROM monthly_rollup ORDER BY region;
COMMIT; -- cursor SURVIVES the commit
FETCH 100 FROM report; -- works outside the transaction
CLOSE report; -- until you close it (or the session ends)At COMMIT, PostgreSQL runs the cursor's query to completion and materializes the result (spilling to temp files if large — watch temp_bytes in pg_stat_database). Cost up front, but no snapshot is pinned afterward. This is the tool for "open a result, then feed it to something slow" without blocking vacuum. Rolled-back transactions discard their holdable cursors.
Cursors in plpgsql
Most explicit cursor code in the wild lives in functions. The concise form is FOR ... IN over a query — which uses an internal cursor automatically and batches sensibly:
CREATE OR REPLACE FUNCTION archive_old_events() RETURNS bigint
LANGUAGE plpgsql AS $$
DECLARE
rec record;
moved bigint := 0;
BEGIN
FOR rec IN
SELECT id FROM events
WHERE created_at < now() - interval '2 years'
LOOP
INSERT INTO events_archive SELECT * FROM events WHERE id = rec.id;
DELETE FROM events WHERE id = rec.id;
moved := moved + 1;
END LOOP;
RETURN moved;
END $$;Explicit cursors add control — parameters, EXIT conditions, and the ability to return the cursor itself:
CREATE OR REPLACE FUNCTION user_orders(p_user bigint)
RETURNS refcursor
LANGUAGE plpgsql AS $$
DECLARE
c refcursor := 'user_orders_cur';
BEGIN
OPEN c FOR SELECT * FROM orders WHERE user_id = p_user ORDER BY id;
RETURN c; -- caller FETCHes from 'user_orders_cur' in the same txn
END $$;
BEGIN;
SELECT user_orders(42);
FETCH ALL FROM user_orders_cur;
COMMIT;Returning refcursor is how functions hand back multiple result sets, and how legacy Oracle code (SYS_REFCURSOR) usually lands in Postgres.
An anti-pattern to recognize: row-by-row UPDATE loops over a cursor where one set-based statement would do. UPDATE events SET archived = true WHERE created_at < ... beats a million iterations by orders of magnitude. Reach for cursor loops only when per-row logic genuinely can't be expressed in SQL — or to batch commits (process N rows, COMMIT, continue — possible in procedures since PG11 with CALL).
Driver-level cursors: the setting everyone misses
You rarely need DECLARE by hand for "stream a big query" — drivers wrap it (or the equivalent portal) behind one setting:
- JDBC:
stmt.setFetchSize(1000)andautoCommit = false— otherwise the driver silently fetches everything. - psycopg2/3: named cursors —
conn.cursor(name='big', withhold=False); setitersize. - Node (pg): the
pg-cursor/pg-query-streampackages. - Go (pgx): rows are streamed by default.
If your exporter OOMs on a big SELECT, this — not more RAM — is the fix.
Cursor vs LIMIT/OFFSET vs keyset pagination
Cursors get proposed for pagination because "FETCH 20 at a time" sounds like pages. Compare honestly:
| Server cursor | LIMIT/OFFSET | Keyset (WHERE id > last) | |
|---|---|---|---|
| Works across HTTP requests | ✗ (needs sticky session+txn) | ✓ | ✓ |
| Cost of page N | Flat | Grows with N (scans+discards) | Flat |
| Consistent snapshot across pages | ✓ | ✗ | ✗ (but stable ordering) |
| Holds server resources | ✓ | ✗ | ✗ |
For stateless APIs, use keyset pagination. Cursors win inside one job: ETL, exports, report generation — anywhere a single session drains a huge, consistent result.
Operational notes
-- What cursors are open right now (pins snapshots!)
SELECT name, statement, is_holdable, creation_time
FROM pg_cursors;- Planner nuance: for cursors, PostgreSQL optimizes for fast startup (governed by
cursor_tuple_fraction, default 0.1) — it may pick an index scan over a faster-in-total hash join, expecting you might not fetch everything. If a cursor's plan looks odd versus the plain query, that's why. - Long-lived cursors show up as long-running transactions in monitoring: check
pg_stat_activity.backend_xid/xminage, and see our kill/cancel guide when one must die. idle_in_transaction_session_timeoutwill kill sessions dawdling between FETCHes — a feature, not a bug; size it to your batch cadence.
Stepping through cursor-based procedures is far easier in a client that keeps the transaction open while you experiment. Chat2DB (opens in a new tab) gives you session-scoped SQL consoles (so DECLARE/FETCH sequences just work), an AI assistant that can convert a row-by-row loop into set-based SQL, and a browser version at app.chat2db.ai (opens in a new tab).
FAQ
Why does FETCH say "cursor does not exist"?
The transaction that declared it ended — non-holdable cursors die at COMMIT/ROLLBACK. Either FETCH inside the same transaction, or declare WITH HOLD. In psql, autocommit means a bare DECLARE without BEGIN commits (and closes the cursor) immediately.
Are cursors faster than running the full query?
No — the same plan executes either way; cursors change delivery, not computation (and cursor_tuple_fraction may even pick a slower-in-total plan). Their value is bounded memory and a stable snapshot, not speed.
Can I keep a cursor open across connections?
No. Cursors (even WITH HOLD) are session objects. Across connections you need keyset pagination, a temp-to-real staging table, or re-running the query.
