SQL cursors appear across multiple programming environments — Python’s psycopg2, Node.js ODBC, Microsoft’s T-SQL, Esri’s arcpy, and SQLAlchemy all have their own cursor implementations and their own characteristic errors. Here’s the fix for each of the most common cursor-related errors, organized by the tool that generated them.
psycopg2: cursor.execute() Not Accepting Returns in String
This error appears in two different scenarios in psycopg2, and they have different fixes:
Scenario 1: RETURNING Clause Not Fetching Results
When you use a PostgreSQL RETURNING clause on an INSERT, UPDATE, or DELETE and call cursor.execute(), the result is available — but you must explicitly fetch it immediately after. Not calling fetchone() or fetchall() before the cursor moves on is the most common source of ‘not accepting returns’ confusion.
# Wrong – executes but doesn’t retrieve the returned value
cursor.execute(
‘INSERT INTO users (name) VALUES (%s) RETURNING id’,
(‘Alice’,)
)
# At this point the returned id is not captured
# Correct – fetch immediately after execute
cursor.execute(
‘INSERT INTO users (name) VALUES (%s) RETURNING id’,
(‘Alice’,)
)
returned_id = cursor.fetchone()[0] # Fetch the RETURNING value
The fetchone() call must happen before any other execute() call on the same cursor, or the result set is lost.
Scenario 2: String Interpolation Instead of Parameterized Queries
A common mistake is building SQL strings with Python f-strings or .format() rather than psycopg2’s parameterized query syntax. This causes syntax errors, unexpected behavior, and SQL injection vulnerabilities:
# Wrong – f-string interpolation
name = user_input
cursor.execute(f’SELECT * FROM users WHERE name = {name}’)
# Correct – parameterized query (psycopg2 handles escaping)
cursor.execute(‘SELECT * FROM users WHERE name = %s’, (name,))
Note the comma inside the tuple for a single parameter: (name,) not (name). A bare (name) without the comma is not a tuple in Python — psycopg2 will raise a TypeError. The %s placeholder is used for all data types in psycopg2, regardless of the actual Python type.
Scenario 3: Using cursor() on a Connection Object
If you’re calling cursor.execute() and getting an AttributeError rather than a syntax error, verify you’re calling it on a cursor object, not the connection itself:
import psycopg2
conn = psycopg2.connect(dsn)
# Wrong – calling execute on connection
conn.execute(‘SELECT …’) # AttributeError
# Correct – create cursor first
cursor = conn.cursor()
cursor.execute(‘SELECT …’)
Node.js ODBC: Invalid Cursor State (Error 24000)
ODBC state code 24000 means ‘Invalid Cursor State’ — the cursor is not in a state where the requested operation is valid. In node-odbc this typically appears in one of three situations:
Cause 1: Fetching After Results Are Exhausted
Calling cursor.fetch() or statement.fetch() after the last row has been returned gives an invalid cursor state because there is nothing left to fetch. The fetch method returns null when the result set is exhausted; continuing to call fetch after null is returned triggers the error.
// Correct pattern – check for null on each fetch
let row;
while ((row = await statement.fetch()) !== null) {
console.log(row);
}
Cause 2: Using cursor.fetch() When connection.query() Was Used
In node-odbc, connection.query() executes a SQL statement and returns results as an array — it doesn’t create a cursor you iterate with fetch(). Calling fetch() after query() will throw an invalid cursor state because query() already consumed and closed the result set.
// Wrong – query() returns array, not a cursor to iterate
const result = await connection.query(‘SELECT * FROM users’);
// result is already an array here – don’t call fetch()
// Correct – use the array directly
const result = await connection.query(‘SELECT * FROM users’);
result.forEach(row => console.log(row));
// If you need cursor-based iteration, use statement API
const statement = await connection.createStatement();
await statement.prepare(‘SELECT * FROM users’);
await statement.execute();
let row;
while ((row = await statement.fetch()) !== null) {
console.log(row);
}
Cause 3: Multiple Result Sets (Stored Procedures)
When a stored procedure returns multiple result sets, ODBC requires advancing through each result set before fetching from the next one. Attempting to fetch from the second result set without first completing (or explicitly closing) the first generates an invalid cursor state error. Use statement.nextResult() to advance between result sets.
T-SQL Cursor: Complete Syntax and Best Practices
A T-SQL cursor is a database object that allows row-by-row processing of a query result set in SQL Server and Azure SQL. The full lifecycle:
— 1. Declare the cursor
DECLARE user_cursor CURSOR
FORWARD_ONLY STATIC READ_ONLY
FOR
SELECT user_id, username FROM users WHERE active = 1;
— 2. Declare variables to hold fetched values
DECLARE @user_id INT, @username NVARCHAR(100);
— 3. Open the cursor
OPEN user_cursor;
— 4. Fetch the first row
FETCH NEXT FROM user_cursor INTO @user_id, @username;
— 5. Loop while fetch succeeded (@@FETCH_STATUS = 0)
WHILE @@FETCH_STATUS = 0
BEGIN
— Process the row
PRINT ‘Processing: ‘ + @username;
— Fetch next row at end of loop
FETCH NEXT FROM user_cursor INTO @user_id, @username;
END
— 6. Close and deallocate
CLOSE user_cursor;
DEALLOCATE user_cursor;
Key points about T-SQL cursor types:
| Cursor Type | Behavior | Best For |
| FORWARD_ONLY | Can only scroll forward; fastest | Sequential processing (default) |
| STATIC | Creates a snapshot at open time; data doesn’t reflect updates | Read-only iteration where base data may change |
| KEYSET | Key values fixed at open; detects updates to non-key columns | Ordered browsing with update visibility |
| DYNAMIC | Reflects all changes to base data; slowest | When you need live data visibility during iteration |
T-SQL cursors should be a last resort. Set-based SQL operations (using WHERE, JOIN, GROUP BY, and window functions) consistently outperform cursors for the same work. Cursors process one row at a time, requiring a round-trip per row. An UPDATE…FROM or a CTE can typically replace a cursor pattern with dramatically better performance.
Nested Cursors in T-SQL
Two cursors — one inside the other — are valid T-SQL but require careful naming. Each cursor must have a unique name, and the inner cursor must complete its full lifecycle (OPEN, FETCH loop, CLOSE, DEALLOCATE) for every iteration of the outer cursor:
DECLARE outer_cursor CURSOR FOR SELECT dept_id FROM departments;
OPEN outer_cursor;
FETCH NEXT FROM outer_cursor INTO @dept_id;
WHILE @@FETCH_STATUS = 0
BEGIN
— Inner cursor for each department
DECLARE inner_cursor CURSOR FOR
SELECT emp_id FROM employees WHERE dept_id = @dept_id;
OPEN inner_cursor;
FETCH NEXT FROM inner_cursor INTO @emp_id;
WHILE @@FETCH_STATUS = 0
BEGIN
— Process employee
FETCH NEXT FROM inner_cursor INTO @emp_id;
END
CLOSE inner_cursor;
DEALLOCATE inner_cursor;
FETCH NEXT FROM outer_cursor INTO @dept_id;
END
CLOSE outer_cursor;
DEALLOCATE outer_cursor;
Nested cursors compound the performance penalty of row-by-row processing exponentially. If you find yourself writing nested cursors, step back and consider whether the logic can be expressed as a single set-based query, possibly with a temp table or CTE to hold intermediate results.
arcpy UpdateCursor: How to Update Features in ArcGIS
The arcpy.da.UpdateCursor is the correct way to iterate over and modify features in an ArcGIS feature class or table. The da (Data Access) version is significantly faster than the older arcpy.UpdateCursor and should always be used in current scripts.
import arcpy
feature_class = r’C:/data/cities.gdb/Cities’
fields = [‘Population’, ‘Status’]
# Use ‘with’ statement for automatic cursor cleanup
with arcpy.da.UpdateCursor(feature_class, fields) as cursor:
for row in cursor:
# row[0] = Population, row[1] = Status
if row[0] > 1000000:
row[1] = ‘Major City’
cursor.updateRow(row) # Must call updateRow to save changes
Critical points for arcpy UpdateCursor:
- Always call cursor.updateRow(row) after modifying values — without this call, changes are not saved to the feature class
- Use the ‘with’ statement — it ensures the cursor and any file locks are released when the block exits, even if an error occurs
- If not using ‘with’, call del cursor explicitly at the end — otherwise the feature class remains locked and other operations will fail
- The WHERE clause (fourth parameter) filters which rows are returned: arcpy.da.UpdateCursor(fc, fields, where_clause=’Population > 500000′)
- Field names are case-insensitive in arcpy but must exactly match field names as they exist in the feature class (no spaces or special characters that aren’t in the actual field name)
- OID@ and SHAPE@ are special token fields: ‘OID@’ returns the Object ID, ‘SHAPE@’ returns the geometry object
Common arcpy UpdateCursor Errors
| Error | Fix |
| RuntimeError: cannot open cursor | Feature class is locked by another process or ArcGIS Pro session. Close ArcGIS Pro or release other cursors on the same data |
| FieldError: field not found | Field name doesn’t exist or is misspelled. Check arcpy.ListFields(fc) to see exact field names |
| Changes not saved after loop | Not calling cursor.updateRow(row). Add cursor.updateRow(row) after modifying row values |
| Feature class still locked after script | Not using ‘with’ statement or not calling del cursor. Use ‘with arcpy.da.UpdateCursor(…) as cursor’ |
SQLAlchemy AttributeError: ‘engine’ Object Has No Attribute ‘cursor’
This error is a SQLAlchemy version compatibility issue. In SQLAlchemy 1.x, developers could call methods directly on the engine object. In SQLAlchemy 2.x (the current version), the API changed significantly and engine-level shortcuts were removed.
What Changed Between SQLAlchemy 1.x and 2.x
# SQLAlchemy 1.x (old, now broken in 2.x)
engine.execute(‘SELECT * FROM users’) # Worked in 1.x
engine.cursor() # AttributeError in 2.x
# SQLAlchemy 2.x (correct)
with engine.connect() as conn:
result = conn.execute(text(‘SELECT * FROM users’))
rows = result.fetchall()
Note the text() wrapper for string SQL in SQLAlchemy 2.x — raw strings are not accepted directly by execute(). Import text from sqlalchemy: from sqlalchemy import text.
Accessing a Raw DBAPI Cursor in SQLAlchemy 2.x
If you specifically need a raw DBAPI cursor (for example, to call stored procedures that require cursor-level operations), use engine.raw_connection():
with engine.raw_connection() as conn:
cursor = conn.cursor()
cursor.execute(‘SELECT * FROM users’)
rows = cursor.fetchall()
conn.commit() # If modifying data
raw_connection() bypasses SQLAlchemy’s ORM and connection pooling abstractions and gives you direct DBAPI access. Use it sparingly — most operations are better handled through the standard SQLAlchemy 2.x Connection API.
Fixing Legacy SQLAlchemy 1.x Code
If you’re maintaining code written for SQLAlchemy 1.x, the upgrade path involves three main changes:
- Replace engine.execute() with engine.connect() + conn.execute()
- Wrap string SQL in text(): conn.execute(text(‘SELECT …’))
- Replace engine.cursor() with engine.raw_connection() then .cursor() on the resulting connection
Summary: SQL Cursor Error Quick Reference
| Tool / Error | Root Cause | Quick Fix |
| psycopg2 RETURNING not captured | Not calling fetchone() after execute() | Add cursor.fetchone() immediately after execute() |
| psycopg2 string interpolation | Using f-string in SQL instead of %s params | Use cursor.execute(sql, (param,)) syntax |
| Node ODBC error 24000 | Fetching past end of results or wrong API pattern | Check for null on each fetch(); don’t call fetch() after query() |
| T-SQL cursor not working | Missing FETCH at loop start or wrong @@FETCH_STATUS check | Fetch before the loop AND at the end of each iteration |
| arcpy changes not saved | Not calling cursor.updateRow(row) | Always call cursor.updateRow(row) after modifying row |
| arcpy feature class locked | Cursor not released; missing del cursor or ‘with’ statement | Use ‘with arcpy.da.UpdateCursor(…) as cursor:’ pattern |
| SQLAlchemy engine.cursor() AttributeError | SQLAlchemy 2.x removed engine-level API | Use engine.connect() for normal queries; engine.raw_connection() for DBAPI cursor |
Frequently Asked Questions
Why does psycopg2 cursor.execute() not return values?
cursor.execute() never directly returns query results in psycopg2. After executing a SELECT or a DML statement with RETURNING, call cursor.fetchone() (for one row), cursor.fetchall() (for all rows), or iterate the cursor directly. The results exist in the cursor object, not as a return value of execute().
What is T-SQL cursor @@FETCH_STATUS?
@@FETCH_STATUS is a system variable in T-SQL that indicates the status of the last FETCH operation: 0 means the fetch succeeded and a row was returned; -1 means the fetch failed or the row was beyond the result set; -2 means the row fetched was missing (for KEYSET cursors). Loop while @@FETCH_STATUS = 0.
How do I update features using arcpy UpdateCursor?
Open a cursor with arcpy.da.UpdateCursor(feature_class, field_list), iterate through rows, modify row values, and call cursor.updateRow(row) after each modification. Use the ‘with’ statement to ensure the cursor is released when done. Without cursor.updateRow(row), changes are not saved.
How do I fix ‘engine’ object has no attribute ‘cursor’ in SQLAlchemy?
This is a SQLAlchemy 2.x compatibility error. Use engine.connect() and conn.execute(text(sql)) for standard queries. For raw DBAPI cursor access, use engine.raw_connection() to get a connection, then call .cursor() on that object.
Can you have a cursor inside a cursor in SQL?
Yes — T-SQL supports nested cursors, provided each inner cursor uses a unique name and follows its complete lifecycle (DECLARE, OPEN, FETCH, CLOSE, DEALLOCATE) inside the outer cursor’s loop. Nested cursors have severe performance implications and should be replaced with set-based operations or temp tables where possible.
Final Thoughts
SQL cursor errors across different libraries share a common theme: cursor state management. Whether it’s psycopg2 requiring an explicit fetchone() call after a RETURNING clause, node-odbc’s 24000 error from fetching past the end of a result set, T-SQL’s strict OPEN/FETCH/CLOSE/DEALLOCATE lifecycle, or arcpy requiring updateRow() to commit changes, each tool has its own state machine for cursor operations. Understanding where in that state machine the error occurs — rather than searching for the error message in isolation — is the fastest path to a fix. The code examples above cover the most common patterns and their correct equivalents.



