Describe the bug
mssql-python holds the Python GIL for the entire duration of a query
executed via cursor.execute(...). Other Python threads — including the
asyncio event loop on the main thread when the query is dispatched via
asyncio.to_thread — are completely starved while the driver waits on the
network round-trip to SQL Server.
This is a regression versus pyodbc, which releases the GIL around the
blocking ODBC calls (SQLExecute, SQLExecDirect, SQLFetch, ...). Any
user combining mssql-python with asyncio.to_thread,
concurrent.futures.ThreadPoolExecutor, FastAPI/Starlette background
threads, a Flask threaded server, or even a simple background heartbeat
thread will see the entire process freeze for the duration of every query —
even though the query is dispatched to a worker thread specifically to
avoid that.
No exception is raised; the symptom is a stalled event loop / unresponsive
threads.
To reproduce
Self-contained repro using asyncio.to_thread and WAITFOR DELAY so no
schema or table is needed. The main thread runs an asyncio "heartbeat"
coroutine that prints a tick every 2 seconds; the query is dispatched to a
worker thread. If the GIL were released around the ODBC call, the heartbeat
would keep ticking. It does not — it freezes for the full 10 seconds.
"""Minimal repro: mssql-python holds the GIL for the entire duration of a query.Setup: pip install mssql-python set DB_CONNECTION_STRING=Server=...;Database=...;UID=...;PWD=...;Encrypt=yes;Run: python repro_gil_held.py"""importasyncioimportosimporttimeimportmssql_pythonQUERY_DELAY_SECONDS=10HEARTBEAT_INTERVAL=2.0defrun_blocking_query() ->None:
"""Runs in a worker thread via asyncio.to_thread."""conn_str=os.environ["DB_CONNECTION_STRING"]
conn=mssql_python.connect(conn_str)
try:
cursor=conn.cursor()
t0=time.monotonic()
print(f"[query ] {t0:8.3f} starting WAITFOR DELAY '00:00:{QUERY_DELAY_SECONDS:02d}'")
cursor.execute(f"WAITFOR DELAY '00:00:{QUERY_DELAY_SECONDS:02d}'")
cursor.fetchall() ifcursor.descriptionelseNonet1=time.monotonic()
print(f"[query ] {t1:8.3f} query returned (elapsed {t1-t0:.3f}s)")
cursor.close()
finally:
conn.close()
asyncdefheartbeat(stop: asyncio.Event) ->None:
"""Prints a tick every HEARTBEAT_INTERVAL seconds on the event loop thread."""last=time.monotonic()
whilenotstop.is_set():
now=time.monotonic()
gap=now-lastmarker=" <-- STALLED (GIL held by query thread)"ifgap>HEARTBEAT_INTERVAL*1.5else""print(f"[hbeat ] {now:8.3f} tick (gap {gap:5.3f}s){marker}")
last=nowtry:
awaitasyncio.wait_for(stop.wait(), timeout=HEARTBEAT_INTERVAL)
exceptasyncio.TimeoutError:
passasyncdefmain() ->None:
if"DB_CONNECTION_STRING"notinos.environ:
raiseSystemExit("Set DB_CONNECTION_STRING env var first.")
stop=asyncio.Event()
hb_task=asyncio.create_task(heartbeat(stop))
awaitasyncio.sleep(2*HEARTBEAT_INTERVAL)
awaitasyncio.to_thread(run_blocking_query)
awaitasyncio.sleep(2*HEARTBEAT_INTERVAL)
stop.set()
awaithb_taskif__name__=="__main__":
print(f"mssql_python version: {getattr(mssql_python, '__version__', 'unknown')}")
asyncio.run(main())Observed output (1.6.0):
mssql_python version: 1.6.0
[hbeat ] 0.000 tick (gap 0.000s)
[hbeat ] 2.001 tick (gap 2.001s)
[query ] 4.003 starting WAITFOR DELAY '00:00:10'
[hbeat ] 14.012 tick (gap 12.011s) <-- STALLED (GIL held by query thread)
[query ] 14.014 query returned (elapsed 10.011s)
[hbeat ] 16.014 tick (gap 2.002s)
The 12-second gap between heartbeat ticks is the smoking gun: the asyncio
event loop on the main thread cannot run at all while a worker thread is
inside cursor.execute(...).
Expected behavior
The blocking ODBC calls should release the GIL around the
network/server-bound portion of the call, the way pyodbc does. The
heartbeat in the repro should continue to fire every ~2 s while the query
runs:
[hbeat ] 0.000 tick (gap 0.000s)
[hbeat ] 2.001 tick (gap 2.001s)
[query ] 4.003 starting WAITFOR DELAY '00:00:10'
[hbeat ] 4.001 tick (gap 2.000s)
[hbeat ] 6.001 tick (gap 2.000s)
[hbeat ] 8.001 tick (gap 2.000s)
[hbeat ] 10.001 tick (gap 2.000s)
[hbeat ] 12.002 tick (gap 2.001s)
[hbeat ] 14.002 tick (gap 2.000s)
[query ] 14.014 query returned (elapsed 10.011s)
Further technical details
Python version: 3.13.1 (CPython, 64-bit)
SQL Server version: SQL Server 2022
Operating system: Windows
mssql-python version: 1.6.0 (latest at time of filing)
Additional context
Root cause (from reading mssql_python/pybind/)
A grep across the entire pybind/ tree shows the GIL is released in only
two places, both in connection/connection.cpp:
$ rg "gil_scoped_release|call_guard|Py_BEGIN_ALLOW_THREADS" mssql_python/pybind
mssql_python/pybind/connection/connection.cpp:94 SQLDriverConnect
mssql_python/pybind/connection/connection.cpp:156 SQLDisconnect
ddbc_bindings.cpp (~288 KB, contains every statement-level wrapper)
contains zerogil_scoped_release and zero
py::call_guard<py::gil_scoped_release>(). As a result, every blocking
statement-level ODBC call holds the GIL across the network round-trip:
| Wrapper | C++ function | ODBC call | Approx. line in ddbc_bindings.cpp |
|---|
DDBCSQLExecDirect | SQLExecDirect_wrap | SQLExecDirectW | 1705 |
DDBCSQLExecute | SQLExecute_wrap | SQLExecute (+ DAE: SQLParamData, SQLPutData) | 1881 |
SQLExecuteMany | SQLExecuteMany_wrap | repeated SQLExecute (+ DAE) | 2750, 2770 |
DDBCSQLFetch | SQLFetch_wrap | SQLFetch | 2944 |
DDBCSQLFetchOne/Many/All | FetchOne_wrap / FetchMany_wrap / FetchAll_wrap | SQLFetch / SQLFetchScroll (+ SQLGetData for LOBs) | 5483+, 5587, 5621+ |
DDBCSQLFetchScroll | SQLFetchScroll_wrap | SQLFetchScroll | (registered at 5810) |
DDBCSQLMoreResults | SQLMoreResults_wrap | SQLMoreResults | 5649 |
DDBCSQLGetData | FetchLobColumnData | SQLGetData (LOB streaming loop) | 2958 |
DDBCSQLTables/Columns/PrimaryKeys/ForeignKeys/Procedures/SpecialColumns/Statistics/GetTypeInfo | catalog wrappers | corresponding catalog ODBC calls — all blocking | 5806–5862 |
Connection.commit / rollback | (in connection.cpp) | SQLEndTran | declared at 354 |
Suggested fix
The maintainers clearly know the pattern — it's already used correctly for
SQLDriverConnect and SQLDisconnect in
mssql_python/pybind/connection/connection.cpp line 94:
SQLRETURN ret;
{
py::gil_scoped_release release;
ret = SQLDriverConnect_ptr(_dbcHandle->get(), nullptr, connStrPtr,
SQL_NTS, nullptr, 0, nullptr,
SQL_DRIVER_NOPROMPT);
}The same pattern needs to be applied to every blocking ODBC call in
ddbc_bindings.cpp. Two equivalent options:
Option A — explicit scope (required for wrappers that build Python
objects, e.g. the Fetch*_wrap family and SQLExecute_wrap's DAE loop,
which interleave Python-object construction with ODBC calls):
SQLRETURN ret;
{
py::gil_scoped_release release;
ret = SQLExecDirect_ptr(StatementHandle->get(), queryPtr, SQL_NTS);
}Option B — pybind11 call_guard on the binding (only safe for wrappers
that do not touch any Python object between entry and the ODBC call, e.g.
SQLFetch_wrap, SQLMoreResults_wrap):
m.def("DDBCSQLFetch", &SQLFetch_wrap, "...",
py::call_guard<py::gil_scoped_release>());
m.def("DDBCSQLMoreResults", &SQLMoreResults_wrap, "...",
py::call_guard<py::gil_scoped_release>());Releasing the GIL around just SQLExecute_wrap, SQLExecDirect_wrap, and
the Fetch*_wrap family would resolve the vast majority of user-visible
contention, including this repro.
Describe the bug
mssql-pythonholds the Python GIL for the entire duration of a queryexecuted via
cursor.execute(...). Other Python threads — including theasyncio event loop on the main thread when the query is dispatched via
asyncio.to_thread— are completely starved while the driver waits on thenetwork round-trip to SQL Server.
This is a regression versus
pyodbc, which releases the GIL around theblocking ODBC calls (
SQLExecute,SQLExecDirect,SQLFetch, ...). Anyuser combining
mssql-pythonwithasyncio.to_thread,concurrent.futures.ThreadPoolExecutor, FastAPI/Starlette backgroundthreads, a Flask threaded server, or even a simple background heartbeat
thread will see the entire process freeze for the duration of every query —
even though the query is dispatched to a worker thread specifically to
avoid that.
No exception is raised; the symptom is a stalled event loop / unresponsive
threads.
To reproduce
Self-contained repro using
asyncio.to_threadandWAITFOR DELAYso noschema or table is needed. The main thread runs an asyncio "heartbeat"
coroutine that prints a tick every 2 seconds; the query is dispatched to a
worker thread. If the GIL were released around the ODBC call, the heartbeat
would keep ticking. It does not — it freezes for the full 10 seconds.
Observed output (1.6.0):
The 12-second gap between heartbeat ticks is the smoking gun: the asyncio
event loop on the main thread cannot run at all while a worker thread is
inside
cursor.execute(...).Expected behavior
The blocking ODBC calls should release the GIL around the
network/server-bound portion of the call, the way
pyodbcdoes. Theheartbeat in the repro should continue to fire every ~2 s while the query
runs:
Further technical details
Python version: 3.13.1 (CPython, 64-bit)
SQL Server version: SQL Server 2022
Operating system: Windows
mssql-python version: 1.6.0 (latest at time of filing)
Additional context
Root cause (from reading
mssql_python/pybind/)A grep across the entire
pybind/tree shows the GIL is released in onlytwo places, both in
connection/connection.cpp:ddbc_bindings.cpp(~288 KB, contains every statement-level wrapper)contains zero
gil_scoped_releaseand zeropy::call_guard<py::gil_scoped_release>(). As a result, every blockingstatement-level ODBC call holds the GIL across the network round-trip:
ddbc_bindings.cppDDBCSQLExecDirectSQLExecDirect_wrapSQLExecDirectWDDBCSQLExecuteSQLExecute_wrapSQLExecute(+ DAE:SQLParamData,SQLPutData)SQLExecuteManySQLExecuteMany_wrapSQLExecute(+ DAE)DDBCSQLFetchSQLFetch_wrapSQLFetchDDBCSQLFetchOne/Many/AllFetchOne_wrap/FetchMany_wrap/FetchAll_wrapSQLFetch/SQLFetchScroll(+SQLGetDatafor LOBs)DDBCSQLFetchScrollSQLFetchScroll_wrapSQLFetchScrollDDBCSQLMoreResultsSQLMoreResults_wrapSQLMoreResultsDDBCSQLGetDataFetchLobColumnDataSQLGetData(LOB streaming loop)DDBCSQLTables/Columns/PrimaryKeys/ForeignKeys/Procedures/SpecialColumns/Statistics/GetTypeInfoConnection.commit/rollbackconnection.cpp)SQLEndTranSuggested fix
The maintainers clearly know the pattern — it's already used correctly for
SQLDriverConnectandSQLDisconnectinmssql_python/pybind/connection/connection.cppline 94:The same pattern needs to be applied to every blocking ODBC call in
ddbc_bindings.cpp. Two equivalent options:Option A — explicit scope (required for wrappers that build Python
objects, e.g. the
Fetch*_wrapfamily andSQLExecute_wrap's DAE loop,which interleave Python-object construction with ODBC calls):
Option B — pybind11
call_guardon the binding (only safe for wrappersthat do not touch any Python object between entry and the ODBC call, e.g.
SQLFetch_wrap,SQLMoreResults_wrap):Releasing the GIL around just
SQLExecute_wrap,SQLExecDirect_wrap, andthe
Fetch*_wrapfamily would resolve the vast majority of user-visiblecontention, including this repro.