Add an optional SQL Server BCP fast path for fast_executemany#1496
Open
rad-pat wants to merge 1 commit into
Open
Add an optional SQL Server BCP fast path for fast_executemany#1496rad-pat wants to merge 1 commit into
rad-pat wants to merge 1 commit into
Conversation
Adds an optional bulk-copy (BCP) path for Cursor.executemany with
fast_executemany against SQL Server. When enabled it routes the insert through
the SQL Server bulk-copy API (bcp_init / bcp_bind / bcp_sendrow / bcp_batch /
bcp_done) instead of parameter-array INSERTs -- dramatically faster for large
loads -- and transparently falls back to the standard fast_executemany path
when BCP cannot be used.
Usage (SQL Server / msodbcsql only):
- open the connection with attrs_before={1219: 1} # SQL_COPT_SS_BCP = ON
- cursor.fast_executemany = True
- cursor.use_bcp_fast = True
- optional: cursor.bcp_batch_rows (rows per committed batch)
cursor.bcp_tablock (pass the TABLOCK hint)
Works with raw pyodbc and with SQLAlchemy (set use_bcp_fast in a
before_cursor_execute listener; see the PR description).
Implementation (src/bcp_support.{h,cpp}, src/params.cpp, src/connection.*,
src/cursor.*):
- Dependency-free, load-on-demand binding of the bcp_* exports from the in-use
msodbcsql driver. On Linux the driver's absolute path is resolved from
/proc/self/maps (SQL_DRIVER_NAME returns a bare versioned soname that dlopen
cannot resolve); RTLD_DEFAULT and well-known paths are fallbacks. Diagnostics
gated behind PYODBC_BCP_DEBUG.
- ExecuteMulti_BCP deduces per-column host types from the first row, binds
once, and streams rows. Fixed-width types bind directly; variable-length
values (varchar, and Decimal/temporal sent as text) use a 4-byte length
prefix. NULLs handled for both.
- Explicit INSERT column lists are honoured: BCP binds by table ordinal, so
each listed column name is mapped to its ordinal via SQLColumnsW (Unicode;
the default schema is resolved when the INSERT omits it, to avoid
cross-schema ambiguity). A partial column list, INSERT...SELECT/EXEC, or
DEFAULT VALUES fall back. No column list binds positionally.
- New cursor attributes use_bcp_fast / bcp_batch_rows / bcp_tablock; the loaded
BcpProcs table is cached on the connection.
Correctness/robustness:
- bcp_init is started only after the rows validate and buffers are allocated,
and every error path afterwards calls bcp_done so a failed row never leaves
the connection mid-bulk-copy.
- DBINT is int (32-bit), not long -- correct binds on LP64, not just Windows.
- SQLINT4 out-of-range and non-finite Decimal are rejected rather than
silently truncated/garbled; duplicate column names fall back.
Notes / limitations (all fall back safely, no data corruption):
- SQL Server + msodbcsql only.
- Temporal types (date/time/datetime2/datetimeoffset) and Decimal are sent as
text; native binary temporal binding is left as a future optimization
(bcp_bind does not convey the fractional-second scale).
- Column types are deduced from the first row.
Tests: test_bcp_fast_executemany (all supported types, NULLs, varlen growth,
>32/64-bit ints, batching) and test_bcp_fast_executemany_column_list (reordered
list and a column subset). utils/bcp_benchmark.py is a standalone benchmark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This adds an optional SQL Server BCP (bulk copy) fast path for
Cursor.executemany()whenfast_executemanyis enabled. When turned on, anINSERT ... VALUES (...)executemany is routed through the SQL Server bulk-copyAPI (
bcp_init/bcp_bind/bcp_sendrow/bcp_batch/bcp_done) insteadof parameter-array
INSERTs. For large loads this is dramatically faster,because it bypasses per-statement query processing.
It is opt-in and safe: if BCP can't be used for a given call (not a
parameterized
VALUESinsert, an unsupported value type, a partial column list,a non-Microsoft driver, etc.) it transparently falls back to the existing
fast_executemanypath. The BCP entry points are loaded on demand from thein-use
msodbcsqldriver, so there is no new build/link dependency.Relationship to #1486
#1486 ("Implement bulk copy support") and this PR tackle bulk copy from
different angles and are complementary, not competing:
connection.bcp(...)method that bulk-copies between adata file and a table (
bcp_init/bcp_readfmt/bcp_exec+ a formatfile), supporting both import and export — the classic
bcputilityexposed programmatically. You bring a file (and a format file).
cursor.executemany()/fast_executemany(and SQLAlchemy) inserts by streaming the Python rowsthrough
bcp_bind/bcp_sendrow— no data file or format file. Import only.The only overlap is low-level plumbing: both enable
SQL_COPT_SS_BCPanddynamically load the driver's
bcp_*entry points (and define the sameSQL_COPT_SS_BCP/SQL_BCP_ONconstants). If #1486 lands first — or themaintainers prefer a single BCP module — I'm happy to rebase this on top and
share that infrastructure (a common
bcp_*symbol loader + constants) ratherthan duplicate it.
How to use (raw pyodbc)
BCP must be enabled on the connection before connecting, via the SQL
Server-specific
SQL_COPT_SS_BCPpre-connect attribute:New
Cursorattributes:use_bcp_fastfast_executemanythrough BCP when possiblebcp_batch_rows0= a single batch)bcp_tablockTABLOCKhint (bulk-update lock; can enable minimal logging)AUTOCOMMITis recommended for BCP loads (the bulk copy is committed bybcp_done/bcp_batch).How to use via SQLAlchemy
Enable BCP on the connection through
connect_args, and flipuse_bcp_fastoneach
executemanywith abefore_cursor_executelistener:SQLAlchemy emits an explicit column list (
INSERT INTO t (cols...) VALUES (...)).That is handled correctly (see below). When SQLAlchemy omits columns from the
list (e.g. an identity PK or a server-default column), the insert is a partial
list and falls back to
fast_executemany(BCP requires every column to bebound). Full-column bulk inserts -- the typical high-volume case -- use BCP.
Column-list handling
BCP loads by table column ordinal, not by name, so an explicit column list
is mapped to ordinals before binding:
INSERT INTO t VALUES (?, ...)): bound positionally.INSERT INTO t (c, a, b) VALUES (...)): eachlisted name is resolved to its table ordinal via
SQLColumnsW, so values landin the right columns regardless of list order. Non-ASCII column names work.
The lookup is pinned to the schema
bcp_inittargets (the declared schema, orSCHEMA_NAME()when omitted) to avoid cross-schema ambiguity.INSERT ... SELECT/EXEC,DEFAULT VALUES, duplicate column names: fall back to the normal path.Supported value types
Host column types are deduced from the first row:
int->INT(orBIGINTwhen it doesn't fit 32 bits; out-of-range values raise)bool->BITfloat->FLOATstr/bytes/bytearray-> characterDecimal-> sent as text (non-finite values rejected)datetime.date/time/datetime(naive and tz-aware) -> sent as ISO text,letting the server convert to
DATE/TIME/DATETIME2/DATETIMEOFFSETNone-> SQLNULLAny other value type (e.g.
uuid.UUID) causes thatexecutemanyto fall back.Behaviour / fallback
executemanyfalls back to the existingfast_executemanyimplementation (noerror) whenever BCP can't be used.
bcp_initis started only after the rowsvalidate and the column buffers are allocated, and every error path afterwards
calls
bcp_done, so a failed row never leaves the connection mid-bulk-copy.What is not done / limitations
msodbcsqlonly. Other drivers/databases fall back.date/time/datetime2/datetimeoffsetare sent as ISO text and converted server-side. The nativelength-prefixed binary bind is implemented but disabled:
bcp_binddoes notcarry the fractional-second scale, so the driver rejects sub-second values.
Decimalis likewise sent as text.(or leave
use_bcp_fastoff) for heterogeneous data.bytesare bound as character data, notVARBINARY.DATETIMEOFFSETcolumn back is unrelated to this change but worthnoting: pyodbc has no native decoder for SQL type
-155.Performance
Measured in CI inserting into a heap table
(INT, FLOAT, VARCHAR(100), DATETIME2(7)). The first method uses fewer rows because the row-by-row path isslow; the bulk methods use 100k rows. Database in FULL recovery:
executemany(row-by-row)fast_executemanyTABLOCKTABLOCKA separate run under SIMPLE recovery produced effectively the same figures.
Takeaways:
fast_executemanyhere.logging:
TABLOCKmade a negligible difference at this size, and the resultheld under both FULL and SIMPLE recovery.
fast_executemanyisabnormally slow (~2,700 rows/s), which inflates the multiple. On capable
hardware the gap will be smaller, but BCP remains clearly faster for bulk loads.
Tests
tests/sqlserver_test.py::test_bcp_fast_executemanycovers all supported types,NULLs, variable-length growth,>32/64-bit integers and batching (plainVALUES, positional binding).test_bcp_fast_executemany_column_listcovers anexplicit list that reorders columns (via BCP) and a column subset (falls back).
utils/bcp_benchmark.pyis a standalone benchmark (kept underutils/so it isnever collected by pytest, per
CLAUDE.md). The full CI matrix (Python3.10-3.14 x SQL Server 2019/2022/2025 + PostgreSQL / MySQL / SQLite) is green.
🤖 Generated with Claude Code