github microsoft/mssql-python v1.13.0

3 hours ago

Enhancements

• ODBC Driver Now Ships Exclusively via mssql-python-odbc — Phase 2 Complete (#693)

What changed: The bundled libs/ fallback introduced in v1.12.0 has been removed. mssql-python now hard-depends on mssql-python-odbc==18.6.2.1 (declared in install_requires); the native loader imports mssql_python_odbc at startup and resolves the driver / libs base directory from there. There is no in-wheel fallback anymore. pip install mssql-python continues to Just Work — the companion package is pulled transparently.

Who benefits: Users who wanted to pin or update driver binaries independently of the Python driver, redistributors who wanted a slimmer mssql-python wheel, and CI/reproducible builds that could not tolerate two distributions co-owning the libs/ directory.

Impact: Wheels are smaller and driver binaries are managed as an ordinary versioned dependency. If you install mssql-python from a private index or with --no-deps, install mssql-python-odbc==18.6.2.1 alongside it explicitly.

PR #693

• Apache Arrow Bulk Copy (#665)

What changed: New Cursor.bulkcopy_arrow(table_name, source) method for bulk loading directly from Apache Arrow sources — pyarrow.Table, pyarrow.RecordBatch, RecordBatchReader, or any object exposing the Arrow C Data Interface. The Arrow path skips the Python row materialization that classic bulkcopy() does, so ingestion of Arrow-native data is significantly faster. The classic bulkcopy() method now raises TypeError when given Arrow-shaped input and steers users to the new method. Connection context and Azure AD authentication were refactored into a shared _build_pycore_context() helper so bulkcopy and bulkcopy_arrow share identical auth handling.

Who benefits: Users with Arrow-native pipelines (Polars, DuckDB, Parquet, cross-language ETL) ingesting into SQL Server, and anyone who previously converted Arrow tables to lists of tuples just to call bulkcopy().

Impact: A fast, first-class Arrow ingestion API without leaving Arrow memory.

PR #665

token_provider= Parameter for Azure Identity Credentials (#603)

What changed: connect() and the Connection class now accept a token_provider parameter — any object with a .get_token(scope) method. This includes every credential in azure-identity (DefaultAzureCredential, AzureCliCredential, ManagedIdentityCredential, ClientSecretCredential, …) as well as custom credential objects. Bulk copy operations re-acquire a fresh token from the provider per operation. token_provider is mutually exclusive with Authentication= in the connection string and with pre-acquired tokens in attrs_before. Only the Azure commercial cloud scope is supported via this path. A TokenProvider protocol type is exported for typing.

Who benefits: Applications that already build their credential chain with azure-identity (very common for Azure-hosted services) and want a single credential object to authenticate to SQL Server without hand-rolling ODBC SQL_COPT_SS_ACCESS_TOKEN handling.

Impact: Idiomatic Azure SDK auth for mssql-python.

PR #603 | GitHub Issue #577

• Identity-Aware Connection Pooling with Token-Expiry Refresh (#660)

What changed: The connection pool used to key only on the connection string, so two callers hitting the same server with different Entra identities collided onto one pool — a connection authenticated as user A could be handed to user B. The pool now keys on security context: for non-token auth (SQL / trusted / Service Principal / Windows-interactive) the key is unchanged; for token-based auth the key becomes connStr + "\x00" + <identity discriminator> where the discriminator is msi:<client_id> / msi:system for managed identity, acct:<home_account_id> for interactive/device-code, and tok:<sha256(token)> for DefaultAzureCredential / raw tokens. Two secondary fixes: token acquisition is now deferred to pool-misses (previously a token was acquired on every connect, even on pool hits), and pooled connections whose token is within 5 minutes of expiry are refreshed by minting a new token, byte-comparing against the pooled one, and reopening the slot when they differ. Idle identity-pools are reclaimed lazily to avoid leaks.

Who benefits: Multi-tenant services and any application that authenticates with more than one Entra identity in the same process.

Impact: Cross-identity connection leaks are prevented and token overhead on pool hits is eliminated.

PR #660 | GitHub Issue #651 | GitHub Issue #659

Bug Fixes

• Silent Zero-Row executemany Batches on Late NULLs (#702)

What changed: Fixed the numeric array parameter binding paths (TINYINT, SMALLINT, INT, FLOAT) which allocated the ODBC indicator array only after encountering the first NULL. Earlier rows then contained uninitialized indicator slots that ODBC could interpret as data-at-execution markers, silently inserting zero rows for the entire batch without raising an exception. Indicators are now initialized for every fixed-width numeric parameter before array execution. Reproduction rate dropped from 209/300 anomalous batches to 0/300.

Who benefits: Any code path that uses executemany with mixed non-NULL / NULL numeric values.

Impact: Batches now insert every row deterministically.

PR #702 | GitHub Issue #670

SQL_WVARCHAR Output Converter Applied as Catch-All to Non-String Columns (#692)

What changed: Cursor._build_converter_map used to fall back to the converter registered for SQL_WVARCHAR for any column that had no direct type-keyed converter, so registering a single SQL_WVARCHAR converter mangled INT / DECIMAL / DATE values as well. The optimized apply path also swallowed the resulting AttributeError, hiding the misbehavior in tests. The fallback is now gated on the column's mapped Python type being str or bytes, mirroring the guard already present in Row._apply_output_converters.

Who benefits: Users who register add_output_converter(SQL_WVARCHAR, ...) for legitimate string decoding without wanting it applied to numeric or date columns.

Impact: Output converters registered for SQL_WVARCHAR behave consistently with the documented and pyodbc-compatible semantics.

PR #692 | GitHub Issue #691

• Integer-Keyed Output Converters Silently Never Fired (#690)

What changed: Connection.add_output_converter(sqltype, func) is documented (and pyodbc accepts) to take an integer ODBC SQL type code as the key — for example SQL_DECIMAL, SQL_INTEGER. However, Cursor._build_converter_map() dispatched on the mapped Python type in cursor.description[i][1], so integer-keyed converters were stored but never invoked. The raw ODBC SQL type code is now captured in self._column_sql_types, and _build_converter_map dispatches in pyodbc-compatible order: (1) integer SQL type code, (2) Python type in description[i][1], (3) the legacy WVARCHAR catch-all. Integer keys use exact ODBC type matching, so SQL_DECIMAL and SQL_NUMERIC are distinct. Catalog metadata result sets (columns(), tables(), …) also build the converter map now.

Who benefits: Users migrating from pyodbc who register converters by SQL type code and expect them to fire, and anyone who needs distinct handling for SQL_DECIMAL vs SQL_NUMERIC or exact ODBC type dispatch.

Impact: add_output_converter now matches its documentation and pyodbc's behavior.

PR #690 | GitHub Issue #684

RecordBatchReader.Close() for Arrow Result Sets (#644)

What changed: Cursor.arrow_reader() previously returned a raw pyarrow.RecordBatchReader, so closing the reader did not release the server-side cursor and the parent Cursor was left in an inconsistent state. It now returns an _ArrowReader wrapper whose .close() runs an 8-step cleanup: stops fetching, releases the server-side cursor, resets cursor state, and is idempotent. The parent Cursor remains usable after the reader is closed, and the wrapper supports use as a context manager.

Who benefits: Applications that fetch large result sets via Arrow and close early (streaming pipelines, request-scoped queries, long-running services that reuse cursors).

Impact: Predictable, prompt cleanup of server-side resources on Arrow reader close.

PR #644 | GitHub Issue #643

AttributeError in Cursor.__del__ on Partially-Initialized Cursor (#646)

What changed: If Cursor.__init__ raised before self.closed and self.hstmt were set, garbage collection later called __del__ and hit an AttributeError that surfaced as an unraisable exception. __init__ now sets self.closed = False and self.hstmt = None as its first statements before any code that can raise, close() reads self.closed via getattr(self, "closed", True), and __del__ uses the correct sys.is_finalizing() (not sys._is_finalizing()) and guards its logging call so it stays safe during interpreter shutdown.

Who benefits: Anyone whose cursor construction path can fail (bad handle allocation, transient connection state) and did not want the failure to also produce noisy interpreter-shutdown errors.

Impact: Half-constructed cursors clean up quietly.

PR #646 | GitHub Issue #642

Don't miss a new mssql-python release

NewReleases is sending notifications on new releases.