Skip to content

sqlite3: concurrent execute() on a shared Connection crashes the process with a native access violation (threadsafety == 3) #160699

Description

@BlueDestination

Summary

Sharing a single sqlite3.Connection between multiple threads (check_same_thread=False) and calling execute() concurrently on it kills the process with a native access violation (0xC0000005) on 64-bit Windows. sqlite3.threadsafety on this build is 3 (SQLITE_THREADSAFE=1, serialized mode), and the docs for check_same_thread state that the connection may be shared between threads as long as writes are serialized by the user — here nothing is being written at all, only SELECTs of BLOBs.

No Python-level exception is raised; the interpreter dies silently. faulthandler reports the fault on the CONN.execute(...) line (or on the very next byte-consuming line), i.e. the heap has already been corrupted.

The same pattern with no third-party packages at all reproduces, so this is not a C-extension interaction problem.

Reproduction

import faulthandler
import hashlib
import os
import sqlite3
import threading
import time
from concurrent.futures import ThreadPoolExecutor

faulthandler.enable(all_threads=True)

CONN = sqlite3.connect(':memory:', check_same_thread=False)
CONN.execute('create table entries (key blob primary key, value blob)')
for i in range(8):
    CONN.execute(
        'insert into entries values (?, ?)',
        (f'k{i}'.encode(), os.urandom(1_400_000)),
    )
CONN.commit()
KEYS = [f'k{i}'.encode() for i in range(8)]


def load(key):
    row = CONN.execute('select value from entries where key = ?', (key,)).fetchone()
    blob = row[0][:]
    return hashlib.blake2b(blob).digest()[0]


def burst():
    with ThreadPoolExecutor(max_workers=4) as executor:
        sum(executor.map(load, KEYS))


threads = []
start = time.time()
while time.time() - start < 70:
    if sum(t.is_alive() for t in threads) < 4:
        thread = threading.Thread(target=burst, daemon=True)
        thread.start()
        threads.append(thread)
    time.sleep(0.05)
print('no crash', len(threads))

Run it a few times; it usually dies within 10-70 seconds.

Observed output:

Windows fatal exception: access violation

Current thread 0x000079d0 [ThreadPoolExecutor-11_3] (most recent call first):
  File "repro.py", line 29 in load
    row = CONN.execute('select value from entries where key = ?', (key,)).fetchone()
  File ".../concurrent/futures/thread.py", line 73 in run
  File ".../concurrent/futures/thread.py", line 86 in run
  File ".../concurrent/futures/thread.py", line 119 in _worker
  File ".../threading.py", line 1160 in run

In other runs the fault is reported one line later (hashlib.blake2b(blob)), the process exits with code 3221225477 (0xC0000005).

Expected behavior

Either it works (as threadsafety == 3 implies), or a Python-level exception is raised. It should never be possible for concurrent execute() on a shared connection to corrupt the process heap.

Your environment

  • CPython 3.15.1 (tags/v3.15.1:c37e74c, Oct 21 2026), 64-bit, MSC v.1951
  • Windows 11 (10.0.26200)
  • sqlite3.sqlite_version 3.53.4, sqlite3.threadsafety 3
  • No third-party packages involved in the reproduction

Additional context

  • Adding heavy native work in other threads (e.g. polars.read_ipc() on the returned blob) makes the crash appear inside that unrelated library instead, because the corruption has already happened. This is how the problem was originally found: a Streamlit app was dying with 0xC0000005 inside polars, and the crash site moved around randomly.
  • Serializing all execute()/fetchone() calls with a single threading.Lock did not help in the original application; my guess is that cursor teardown (pysqlite_cursor_dealloc -> sqlite3_finalize) happens after the lock is released, and therefore races with another thread reusing the same cached prepared statement.
  • Suspected cause: the per-connection prepared-statement cache and cursor state are shared between threads, while sqlite3_step releases the GIL. Another thread can reset/reuse the same cached sqlite3_stmt while it is still being stepped, or finalize it concurrently.
  • Workaround that works here: one connection per thread (thread-local), which stops the crash.

Happy to add tracing, test a patch, or try a build with Py_DEBUG if that helps.

Linked PRs

Activity

  1. added 2 commits that reference this issue on Oct 26, 2026
  2. BlueDestination commented on Oct 27, 2026

    @BlueDestination
    Author

    Confirmed on Python 3.14.9 as well (so this is not a 3.15 regression):

    • Python 3.14.9 (uv cpython-3.14-windows-x86_64-none), Windows 11 10.0.26200
    • sqlite3.sqlite_version == 3.53.1, sqlite3.threadsafety == 3
    • Same pure-stdlib script as in the report (:memory: connection, 8 blobs of 1.4 MB, two concurrent bursts of ThreadPoolExecutor(max_workers=4))

    New detail: pysqlite raises a Python-level error right before the access violation, which may help locate the unsafe spot:

    Exception in thread Thread-1 (burst):
      File "control_pure_stdlib.py", line 19, in load
        row = CONN.execute('select value from entries where key = ?', (key,)).fetchone()
    sqlite3.InterfaceError: bad parameter or other API misuse
    
    Windows fatal exception: access violation
    
    Current thread 0x00005c54 [ThreadPoolExecutor-1_3]:
      File "control_pure_stdlib.py", line 19 in load       <- same CONN.execute() call
    

    So a thread can observe the shared connection in a state where sqlite3_prepare_v2/sqlite3_bind_* reports misuse, and the same code path then dereferences invalid memory instead of failing cleanly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions