tursodatabase/turso · error · ProgrammingError

Cannot operate on a closed cursor

Error message

Cannot operate on a closed cursor

What it means

ProgrammingError raised by Cursor._ensure_open() (connection.py:202-204) when execute(), executemany(), executescript(), fetchone(), fetchmany(), fetchall(), or iteration runs on a cursor after Cursor.close(). close() also clears the buffered row list, so there is no way to read leftover rows afterwards. Unlike a closed Connection, this is cheap to recover from: the parent connection may still be open and can mint a new cursor.

Source

Thrown at serverless/python/turso_serverless/connection.py:204

    @property
    def description(self) -> tuple[tuple[str, None, None, None, None, None, None], ...] | None:
        return self._description

    @property
    def lastrowid(self) -> int | None:
        return self._lastrowid

    @property
    def rowcount(self) -> int:
        return self._rowcount

    def close(self) -> None:
        self._closed = True
        self._rows = []

    def _ensure_open(self) -> None:
        if self._closed:
            raise ProgrammingError("Cannot operate on a closed cursor")

    @staticmethod
    def _convert_params(
        parameters: Sequence[Any] | Mapping[str, Any],
    ) -> tuple[Optional[list], Optional[list[tuple[str, Any]]]]:
        """Convert DB-API parameters to protocol args/named_args."""
        if isinstance(parameters, Mapping):
            named = []
            for key, val in parameters.items():
                # Try :name, $name, @name prefixes
                if isinstance(key, str) and not key.startswith((":", "$", "@")):
                    named.append((f":{key}", val))
                else:
                    named.append((key, val))
            return None, named
        params = list(parameters) if parameters else []
        return params if params else None, None

View on GitHub (pinned to bad083fafb)

Solutions

  1. Create a fresh cursor with conn.cursor() (or use conn.execute(sql) for one-shot statements)
  2. Never close cursors you did not create — let the owner close them
  3. Fetch all rows first, then close: cur.close() must be the last statement touching the cursor

Example fix

// before
cur = conn.cursor()
cur.execute("SELECT 1")
cur.close()
rows = cur.fetchall()  # ProgrammingError: closed cursor

// after
cur = conn.cursor()
cur.execute("SELECT 1")
rows = cur.fetchall()
cur.close()
Defensive patterns

Strategy: validation

Validate before calling

def fetch_all_then_close(cur):
    """Drain rows before closing; close is always last."""
    try:
        return cur.fetchall()
    finally:
        if not getattr(cur, "_closed", False):
            cur.close()

Try / catch

from turso_serverless.dbapi import ProgrammingError

try:
    cur.execute(sql, params)
except ProgrammingError as e:
    if "closed cursor" not in str(e):
        raise
    cur = conn.cursor()  # parent connection may still be open
    cur.execute(sql, params)

Prevention

When it happens

Trigger: cur.close() followed by cur.execute(), cur.fetchone(), or 'for row in cur'. Common shapes: a helper closes a cursor it received as a parameter and the caller keeps using it; a retry loop that closes the cursor at the end of each attempt but re-enters with the same cursor; storing a cursor in a long-lived object and closing it during cleanup.

Common situations: Mixing conn.execute() (which creates and returns a fresh cursor each call) with manually managed cursors and closing the wrong one; porting sqlite3 code where cursors are cheap and short-lived; cleanup code in generators that closes before the consumer finishes iterating.

Related errors


AI-assisted analysis of tursodatabase/turso@bad083fafb (2026-08-16). Data as JSON: /api/errors/46a9470e26576c89. Report an issue: GitHub.