tursodatabase/turso · error
cursor id {cursor_id} is None
Error message
cursor id {cursor_id} is None What it means
ProgramState::get_cursor() found the slot but it holds None: the cursor was never opened (its OpenRead/VOpen/OpenPseudo has not executed) or was already closed by Insn::Close. Bytecode that consumes a cursor before opening it — often a jump that skips the open — trips this panic mid-execution.
Source
Thrown at core/vdbe/mod.rs:1473
crate::statement::StatementStatusCounter::VmStep => self.metrics.insn_executed = 0,
crate::statement::StatementStatusCounter::Reprepare => self.metrics.reprepares = 0,
crate::statement::StatementStatusCounter::RowsRead => self.metrics.rows_read = 0,
crate::statement::StatementStatusCounter::RowsWritten => self.metrics.rows_written = 0,
}
if let Some(OpProgramState::Step { statement, .. }) = self.active_op_state.program_mut() {
statement.reset_stmt_status(counter);
}
for statement in self.subprogram_stmt_cache.values_mut() {
statement.reset_stmt_status(counter);
}
}
pub fn get_cursor(&mut self, cursor_id: CursorID) -> &mut Cursor {
self.cursors
.get_mut(cursor_id)
.unwrap_or_else(|| panic!("cursor id {cursor_id} out of bounds"))
.as_mut()
.unwrap_or_else(|| panic!("cursor id {cursor_id} is None"))
}
/// Close all virtual table cursors owned by this program.
///
/// A virtual table cursor can own a nested helper statement on the same
/// connection (e.g. `PragmaVirtualTableCursor` runs `PRAGMA ...` via
/// `Connection::prepare_internal`), and that helper holds the
/// connection's nested-statement guard until it is dropped. Both
/// `commit_txn` and `abort` consult `Connection::is_nested_stmt()` to
/// decide whether the current statement owns top-level transaction
/// finalization, so the helpers must be dropped first — otherwise a root
/// statement that scanned a pragma virtual table misclassifies itself as
/// nested, skips ending its implicit read transaction, and subsequent
/// writes on the connection never auto-commit (issue #7466).
pub(crate) fn close_virtual_table_cursors(&mut self) {
for slot in self.cursors.iter_mut() {
if matches!(slot, Some(Cursor::Virtual(_))) {
*slot = None;View on GitHub (pinned to 492c4a71cd)
Solutions
- Report with SQL — every cursor use must be dominated by its open in the bytecode control-flow graph
- Re-prepare after schema changes; do not reuse stale statements across DDL
- Reduce jump-heavy constructs (short-circuit AND/OR chains, CASE) to isolate the bad jump
- Upgrade
Defensive patterns
Strategy: validation
Validate before calling
// straight-line dominance check: a cursor id is only usable after its open insn
fn opened_before(program: &Program, pc: usize, id: CursorID) -> bool {
program.insns[..pc].iter().any(|i| {
matches!(
i,
Insn::OpenRead { cursor_id, .. } | Insn::VOpen { cursor_id }
if *cursor_id == id
)
})
} Prevention
- Verify in debug builds that each cursor-consuming insn is dominated by its open
- Test jump-heavy queries (CASE, short-circuit AND/OR) in CI
- Boundary catch_unwind so a bad jump becomes a query error
When it happens
Trigger: Control flow entering a loop body before the cursor open executed (bad jump target), an open emitted inside a conditional branch whose use site lies outside it, or Close followed by further use — usually after changes to jump/pc patching in translate/.
Common situations: Complex WHERE short-circuits, CASE expressions, and co-routine jumps; refactors of jump patching; upgrades.
Related errors
- cursor id {cursor_id} out of bounds
- cursor id {} out of bounds
- OpenRead on pseudo cursor
- Rewind on non-btree/materialized-view cursor
- unexpected cursor type
AI-assisted analysis of tursodatabase/turso@492c4a71cd (2026-08-20).
Data as JSON: /api/errors/96bc2615b44eb0c0.
Report an issue: GitHub.