PostgreSQL Error 25008: held cursor requires same isolation level
PostgreSQL error 25008 occurs when a holdable cursor (declared with WITH HOLD) is accessed within a transaction that has a different isolation level than the one used when the cursor was originally created. Holdable cursors survive transaction commits, but PostgreSQL enforces that any subsequent transaction fetching from that cursor must match its original isolation level to preserve data consistency guarantees.
Top 3 Causes
1. Fetching a WITH HOLD cursor at a different isolation level
The most common cause: you open a cursor under one isolation level and fetch from it under another.
-- Open cursor under SERIALIZABLE
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
DECLARE held_cur CURSOR WITH HOLD FOR
SELECT id, amount FROM invoices ORDER BY id;
COMMIT;
-- Attempt FETCH under READ COMMITTED → triggers error 25008
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
FETCH NEXT FROM held_cur; -- ERROR: 25008 held cursor requires same isolation level
ROLLBACK;
-- Fix: match the isolation level
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
FETCH NEXT FROM held_cur; -- OK
COMMIT;
CLOSE held_cur;
2. Connection poolers silently changing the session isolation level
Tools like PgBouncer or pgpool-II can reset or inherit session-level settings between client connections. If a WITH HOLD cursor is left open and the session isolation level changes before the next fetch, error 25008 is triggered.
-- Check current isolation level before fetching
SHOW transaction_isolation;
-- Explicitly set the session default to match cursor creation context
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
DECLARE pool_cursor CURSOR WITH HOLD FOR
SELECT order_id FROM orders WHERE status = 'NEW';
COMMIT;
-- Always fetch with matching isolation level
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
FETCH 50 FROM pool_cursor;
COMMIT;
CLOSE pool_cursor;
3. Application code overriding isolation levels per transaction
ORM frameworks or legacy code may automatically set a transaction isolation level on every BEGIN, conflicting with a still-open WITH HOLD cursor from a prior transaction.
-- Identify open held cursors in the current session
SELECT name, statement, holdable, held
FROM pg_cursors
WHERE held = true;
-- Emergency cleanup: close all held cursors
DO $$
DECLARE
cur RECORD;
BEGIN
FOR cur IN SELECT name FROM pg_cursors WHERE held = true LOOP
EXECUTE 'CLOSE ' || quote_ident(cur.name);
END LOOP;
END $$;
Quick Fix Solutions
-
Always match isolation levels: Ensure any transaction that fetches from a
WITH HOLDcursor uses the exact same isolation level as when the cursor was declared. -
Close cursors before committing: If holdability is not strictly required, skip
WITH HOLDentirely and keep the cursor within a single transaction. -
Use keyset pagination instead: Replace
WITH HOLDcursors in batch jobs with stateless pagination patterns.
-- Safer alternative to WITH HOLD cursors
SELECT id, data
FROM large_table
WHERE id > :last_processed_id
ORDER BY id
LIMIT 500;
Prevention Tips
-
Avoid
WITH HOLDwhen possible. Most use cases (batch processing, large result sets) can be replaced with application-side pagination or server-sideLIMIT/OFFSETqueries, eliminating the cross-transaction cursor lifecycle entirely. -
Reset session state in connection pools. Configure your pooler's reset query to include
CLOSE ALL; RESET ALL;so that no held cursors or isolation level overrides bleed between client sessions. In PgBouncer, setserver_reset_query = DISCARD ALLin transaction pooling mode to enforce a clean slate.
📖 Want a more detailed guide?
Check out the full in-depth version (Korean) on oraerror.com — includes detailed analysis, additional SQL examples, and prevention tips.
Top comments (0)