This error occurs when trying to use a WITH HOLD cursor in a transaction with a different isolation level than the one that created it. The cursor must maintain the same isolation level throughout its lifetime.
PostgreSQL error code 25008 "held_cursor_requires_same_isolation_level" is a transaction state error that enforces isolation level consistency for held cursors. When you declare a cursor WITH HOLD, it can persist across transaction commits within the same database session. However, PostgreSQL requires that any transaction using this held cursor must operate at the same isolation level as the transaction that originally created the cursor. This restriction exists to maintain data consistency and prevent anomalies. A held cursor takes a snapshot of the data based on the isolation level of its creating transaction. If subsequent transactions could access this cursor with different isolation levels, it could lead to inconsistent reads and violate the guarantees provided by transaction isolation. The error typically occurs in applications that change isolation levels dynamically, use connection pooling with varying isolation settings, or work with ODBC/JDBC drivers that manipulate isolation levels behind the scenes.
The simplest solution is to close any active cursors before changing the transaction isolation level:
-- Close the cursor first
CLOSE my_cursor;
-- Now safe to change isolation level
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Reopen cursor with new isolation level
BEGIN;
DECLARE my_cursor CURSOR WITH HOLD FOR
SELECT * FROM orders WHERE status = 'pending';This ensures the cursor is recreated with the correct isolation level.
Set the isolation level once at the beginning of your session and maintain it:
-- Set at session start
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
DECLARE persistent_cursor CURSOR WITH HOLD FOR
SELECT * FROM products ORDER BY id;
COMMIT;
-- All subsequent transactions inherit the session default
BEGIN;
FETCH 10 FROM persistent_cursor; -- Works correctly
COMMIT;This approach prevents isolation level mismatches.
If your application needs to change isolation levels frequently, consider not using WITH HOLD cursors:
-- Instead of WITH HOLD cursor
BEGIN;
DECLARE standard_cursor CURSOR FOR
SELECT * FROM transactions WHERE date > CURRENT_DATE - 30;
FETCH 100 FROM standard_cursor;
COMMIT; -- Cursor automatically closed
-- Subsequent transaction can use different isolation level
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- Define new operations here
COMMIT;Standard cursors (without WITH HOLD) are automatically closed at transaction end, avoiding the issue.
Programmatically verify isolation level before using held cursors:
-- Check current isolation level
SHOW transaction_isolation;
-- Only proceed if it matches the cursor's original level
-- In application code (pseudocode):
-- if (current_isolation_level != cursor_isolation_level) {
-- close_cursor();
-- set_isolation_level(cursor_isolation_level);
-- reopen_cursor();
-- }
BEGIN;
-- Ensure isolation level matches
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
FETCH FROM my_held_cursor;
COMMIT;This defensive approach prevents the error at runtime.
If using a connection pool, configure it to reset isolation levels consistently:
// Example with node-postgres
const pool = new Pool({
// Reset isolation level when connection is returned to pool
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
max: 10,
// Add connection reset hook
onRelease: async (connection) => {
await connection.query('CLOSE ALL;'); // Close any cursors
await connection.query('SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;');
}
});This ensures clean state between operations.
Performance Implications of WITH HOLD Cursors
WITH HOLD cursors materialize their entire result set at COMMIT time and store it in temporary files or memory. This can cause COMMIT operations to take significantly longer than expected, especially for large result sets. Consider whether you truly need cursor persistence across transactions.
Isolation Level Specifics
PostgreSQL supports four isolation levels: READ UNCOMMITTED (treated as READ COMMITTED), READ COMMITTED, REPEATABLE READ, and SERIALIZABLE. The stricter the isolation level, the more important it becomes to maintain consistency for held cursors. SERIALIZABLE cursors, in particular, maintain a complete snapshot that cannot be mixed with other isolation levels.
Alternative Patterns
Instead of WITH HOLD cursors, consider:
- Temporary tables to store intermediate results across transactions
- Server-side cursors without WITH HOLD, recreating them as needed
- Batched array fetching without cursors (LIMIT/OFFSET or keyset pagination)
- Streaming results through application-level buffering
ODBC/JDBC Driver Considerations
Some database drivers automatically manage isolation levels and cursor behavior. The PostgreSQL ODBC driver, in particular, has been known to change isolation levels during operations, which can trigger this error. Check driver documentation and connection string parameters like UseServerSidePrepare and Protocol settings that may affect cursor and transaction handling.
PostgreSQL Version Differences
This error and the underlying cursor isolation enforcement has been present since early PostgreSQL versions. The behavior is consistent across versions 9.x through 17.x, though the exact error message formatting may vary slightly.
ERROR: syntax error at end of input
Syntax error at end of input in PostgreSQL
Bind message supplies N parameters but prepared statement requires M
Bind message supplies N parameters but prepared statement requires M in PostgreSQL
Multidimensional arrays must have sub-arrays with matching dimensions
Multidimensional arrays must have sub-arrays with matching dimensions
ERROR: value too long for type character varying
Value too long for type character varying
insufficient columns in unique constraint for partition key
How to fix "insufficient columns in unique constraint for partition key" in PostgreSQL