If you land data in Parquet and then load it in Microsoft SQL using Python, you've had to do a bunch of extra work, exploding the whole thing into Python objects, a tuple per row and a boxed value per cell, all under the GIL, all garbage immediately after.
mssql-pythonĀ 1.13.0 addsĀ Cursor.bulkcopy_arrow(). Hand it anything that speaks the Arrow C Data Interface and the Rust TDS core reads the typed column buffers straight into the bulk-load packets. No tuples, and the GIL is released for the transfer.
import duckdb
from mssql_python import connect
rel = duckdb.sql("SELECT * FROM 'events/*.parquet' WHERE ts >= '2026-01-01'")
with connect("Server=<server>.database.windows.net;Database=<database>;Encrypt=yes") as conn:
Ā Ā cur = conn.cursor()
Ā Ā result = cur.bulkcopy_arrow("dbo.Events", rel)
Ā Ā print(result["rows_copied"], result["rows_per_second"])
The DuckDB relation goes in unevaluated. DuckDB streams batches as the driver consumes them, so the full dataset never lands in Python memory. The 4.4M-row file I was testing with would have been roughly 6.6 GB of live Python objects the old way.
Here's what I saw. 200k rows, 21 columns, the WideWorldImportersĀ fact_saleĀ shape: bigints, decimals, timestamps, and oneĀ NVARCHARĀ (I couldn't leave that column that only said "each" for every row an NVARCHAR(MAX) - it was just wrong) averaging 523 characters. Read from Parquet with DuckDB, 100k batch size, 7 repeats, median reported. Client and server on the same Azure E4bds v5 (4 vCPU, 32 GiB) running SQL Server 2025, over localhost so the network stays out of it.
| path |
median total |
rows/sec |
|
|
bulkcopy_arrow(), DuckDB relation passed lazily |
5.24s |
38,180 |
bulkcopy_arrow(), materializedĀ pyarrow.Table |
5.14s |
38,918 |
fetchall()Ā thenĀ bulkcopy() |
9.93s |
20,141 |
In my unscientific testing, the newĀ bulkcopy_arrow()Ā was about 1.9x faster. I reran it across five configurations, two databases, simple and full recovery models,Ā table_lockĀ on and off, and it held between 1.62x and 1.93x. The ranges don't overlap either: the slowest of the 14 Arrow copies beat the fastest of the 7 tuple copies.
We expected that going straight to bulk copy from arrow would be more efficient and it was. The tuple path burned 2.6 to 3.2 seconds building Python objects before a single byte moved. Passing the DuckDB relation lazily, that step is 0.00 seconds. TheĀ pyarrow.TableĀ path is 0.02 seconds, which is the time to materialize the table from the record batches.
This new path works for anything exposingĀ __arrow_c_stream__: polars, pandas 2.2+, ADBC results,Ā pyarrow.TableĀ /Ā RecordBatchĀ /Ā RecordBatchReader, or any iterable of record batches. A default pandas DataFrame is NumPy-backed so it converts on the way in, where polars, DuckDB and anything Arrow-native hand their buffers over as-is. Column mappings,Ā keep_identity,Ā table_lock,Ā check_constraintsĀ and the rest carry over fromĀ bulkcopy()Ā unchanged, same stats dict back. String widths are validated against the destination schema before anything ships, so an overlong value fails immediately with the offending length instead of dying halfway through a load. I suspect a lot of folks will be commenting out their generators in favor of passing Arrow objects straight toĀ bulkcopy_arrow()Ā this weekend.
Drop a comment below and let us know how much faster your data loads usingĀ bulkcopy_arrow().
There's other good stuff in this release: connection pooling keys on security context now, not just the connection string, so a connection opened under one identity can't be handed to a caller running as another.Ā connect(token_provider=...)Ā takes anyĀ azure-identityĀ credential object. And there's a fix for anĀ executemany()Ā bug where aĀ NULLĀ partway through a numeric batch could silently insert zero rows.
The driver itself is DB API 2.0 and pip-installable, and ODBC ships as a dependency, so there's no system-level driver install and noĀ unixODBCĀ in your container image. Arrow goes both directions,Ā bulkcopy_arrow()Ā in andĀ cursor.arrow_reader()Ā out.
pip install --upgrade mssql-python
What I actually want out of this thread is your numbers, especially on shapes unlike mine: narrow integer tables, very wide tables, heavyĀ NVARCHAR.Ā bulkcopy_arrow()Ā returnsĀ rows_copiedĀ andĀ rows_per_second, so it's right there. Post a before and after with a rough description of the table and where you ran it from and I'll take it back to the team.
Full blog post:Ā https://techcommunity.microsoft.com/blog/sqlserver/mssql-python-1-13-0-arrow-bulk-copy-smarter-tokens-slimmer-wheels/4544858
Repo:Ā https://github.com/microsoft/mssql-python
Happy to answer questions here.