0003: mssql-python as default driver, arrow-odbc as fallback¶
Status: accepted. Survey in docs/CONNECTORS.md.
Date: 2026-09-28T16:13:29-03:00 (recorded when the repository was first committed; decided before)
Amended: 2026-09-28T21:44:31-03:00, the (max) caveat measured (lab t8)
Amended: 2026-09-30T16:14:54-03:00, installed with the package instead of the [mssql] extra
Amended: 2026-10-01T17:55:59-03:00, arrow-odbc tested in CI; what it took and what still differs (see Amendment 2)
Amended: 2026-10-05T00:15:23-03:00, the default ships closed binaries too: ADBC's rejection corrected (see Amendment 3)
Amended: 2026-10-08T14:28:19-03:00, arrow-odbc's concurrent fetch measured (lab t8, see Amendment 4)
Amended: 2026-10-08T14:37:54-03:00, the default backend's batches sized by their widest row (see Amendment 5)
Context¶
read() yields Arrow record batches from executors. The driver should fetch natively into
Arrow with bounded memory, install with pip on managed platforms, preserve binary and
decimal types, and bind parameters.
Decision¶
Default to Microsoft's mssql-python (cursor.arrow_batch, since 1.5.0; bundles ODBC
Driver 18). Support arrow-odbc where msodbcsql18 is already installed. Reject
ConnectorX (no parameters, query executed twice, decimal forced to (38,10)), Columnar
ADBC (closed binary, separate installer) and turbodbc (no binary type support).
Consequences¶
- LSNs travel as hex strings in both directions (
CONVERT(..., 1)), so neither backend binds or decodes binary values. mssql-pythonwas said to fall back to row-by-row fetch when a result has(max)columns. Measured with lab t8 (one connection, 91 columns, 200k rows): two(max)columns cost about a quarter of the throughput (26k vs 34k rows/s), and the Arrow fetch stays 2–3x faster thanfetchall()either way. A cost, not a cliff.- On Linux,
mssql-pythonneedslibltdl7,libkrb5-3andlibgssapi-krb5-2.
Amendment: mssql-python is installed with the package¶
mssql-python moves from the [mssql] extra into [project].dependencies: the default
backend should work after pip install mssql-cdc-pyspark, without an extra the user has to
know about. The extra is removed rather than kept as an alias, since nothing has been
released with it. [spark] and [arrow-odbc] stay.
- The import stays lazy inside
MssqlPythonBackend, so importingmssql_cdcon a node without the driver's system libraries still works, andarrow-odbcandfakeusers are unaffected. - Costs: every install pulls
azure-identity(withmsalandcryptography) transitively, andarrow-odbcusers carry a driver they do not use. - The Linux system libraries (
libltdl7,libkrb5-3,libgssapi-krb5-2) are still not installed by pip; platforms whose image lacks them need an init script (docs/DATABRICKS.md).
Amendment 2: arrow-odbc is tested¶
CI's integration job installs unixODBC and msodbcsql18 from Microsoft's repository, then
runs the integration tests a second time with MSSQL_CDC_TEST_BACKEND=arrow-odbc. That run
keeps the tests that take the backend fixture: commit times on a non-UTC server, every
mapped type inferred and read back, a stream resumed from its checkpoint, the least-privilege
login, a purged range, split points, the bootstrap, snapshot tiles over composite, string
and other typed keys, and the move to a newer capture instance with its DDL. Both backends
pass the same assertions (14 tests, locally against SQL Server 2022 with msodbcsql18 18.7).
Making them pass took these changes, which are also what still differs:
- The connection string is the same for both: without a
Driverkeyword the backend addsDriver={ODBC Driver 18 for SQL Server}.connectTimeoutnow applies to both. - arrow-odbc binds parameters only as text. LSNs already crossed as hex strings
(invariant 6); snapshot key bounds now do too, with either backend:
CAST(? AS <type>)from ISO 8601 or plain text, binary as hex throughCONVERT(<type>, ?, 1). Adatetimebound keeps three fractional digits, which its 1/300 s ticks round back to the same value. The seek tests on mssql-python still read only their own rows, withoutCONVERT_IMPLICIT. - Text travels as UTF-16 both ways. Narrow parameters arrive as varchar in the database's
code page (a Greek key became
?), and narrow results depend on the worker's locale. - A column with no upper bound (the
(max)types,text,ntext,xml,image) needs one for arrow-odbc's fetch buffers: 64 KiB per value, in characters or bytes. A longer value fails the read, since arrow-odbc refuses to truncate (checked). mssql-python has no such limit, so tables with larger values should stay on it. datetime2(7)is fetched in microseconds, not nanoseconds: truncated as mssql-python truncates it, and a 9999-12-31 sentinel no longer overflows.datetimeoffsetarrives as SQL Server's text form,2026-09-28 13:50:01.1234567 -03:00, which pyarrow does not parse; the reader turns it into its UTC instant before the cast to the Spark schema (invariant 7). Asked for a timestamp, arrow-odbc returns the wall-clock time without the offset, and asked for one with a time zone it panics.close()drops the connection: arrow-odbc'sConnectionhas noclose()and disconnects when it is freed.- Not run with arrow-odbc: the heartbeat, facts metrics, the re-snapshot, silver, type changes and dropped columns. Throughput is not measured.
Amendment 3: the default ships closed binaries too¶
The Decision rejected Columnar ADBC partly as a "closed binary". The default is no
different: mssql-python depends on mssql-python-odbc, whose wheel holds Microsoft's ODBC
Driver 18 binaries under Microsoft's licenses (its metadata says "Other/Proprietary
License"), so every default install brings in proprietary binaries, while the package
itself is MIT. What still sets ADBC apart is its separate installer: mssql-python installs
with pip on every managed platform. The choice stands; the trade-off is now stated:
- The README's License section and the installation page say so, as license scanners in enterprise pipelines flag the dependency.
- A license-sensitive install leaves it out (
pip install --no-deps mssql-cdc-pyspark, thenpyarrowandarrow-odbc) and usesbackend=arrow-odbcwith ODBC Driver 18 installed separately, where its EULA is accepted explicitly (ACCEPT_EULA=Y). The import ofmssql-pythonis lazy, so nothing else needs it.
Amendment 4: arrow-odbc's concurrent fetch, measured¶
ArrowOdbcBackend passes fetch_concurrently=False, turning off arrow-odbc's default: a
second set of buffers filled on a thread of its own while the caller takes the previous
batch. Lab t8 --concurrency timed it on and off, next to mssql-python, over
dbo.fetch_bench (400k change rows) split by split_points into 1, 2 and 4 ranges, each
read by a process with a connection of its own, as Spark's tasks read them. Median of 5 runs,
rows/s and Arrow MB/s:
| Columns | Partitions | mssql-python | arrow-odbc | arrow-odbc, fetch_concurrently |
|---|---|---|---|---|
91, two (max) |
1 | 30.5k, 54 | 27.0k, 42 | 40.1k, 63 |
91, two (max) |
2 | 56.3k, 99 | 51.3k, 80 | 65.8k, 103 |
91, two (max) |
4 | 89.5k, 157 | 97.2k, 152 | 84.4k, 132 |
89, no (max) |
1 | 49.3k, 71 | 44.9k, 57 | 53.5k, 68 |
89, no (max) |
2 | 82.0k, 118 | 77.6k, 98 | 92.3k, 116 |
89, no (max) |
4 | 127.7k, 184 | 120.4k, 152 | 121.7k, 153 |
SQL Server 2022 CU27 from the compose lab; the client in a Debian 12 container on the same Docker VM (16 CPUs), with unixODBC 2.3.11, msodbcsql18 18.7.1, arrow-odbc 10.4.2, mssql-python 1.15.0 and pyarrow 25.0.1. mssql-python's batches hold 12–14% more Arrow bytes for the same rows, so rows/s is the number to compare.
- Partitions scale both backends alike, 2.6x to 3.6x at 4: each is a connection of its own, so arrow-odbc needs nothing more for them.
- The concurrent fetch is a clear win at 1 and 2 partitions: +48% and +28% with the
(max)columns, +19% without, and ahead of mssql-python. At 4 it gains nothing, and loses 13% with the(max)columns, here where client and server share the VM's CPUs. t8 does nothing with a batch; the reader also casts it and hands it to Spark, more work for the fetch thread to overlap. - It costs a second set of buffers: up to
max_bytes_per_batch, 64 MiB, more per task. - No option: a later release stops turning it off, once a run with client and server on separate machines settles the 4-partition result. The code is unchanged for now.
Amendment 5: the default backend's batches sized by their widest row¶
arrowBatchSize counts rows, and mssql-python fetches as many rows as asked, whatever their
size. Since 0.2.0 the next fetch took as many rows as fit in 64 MiB at the mean row size of
the batch before, after a first fetch of 64 rows. Both left gigabyte batches possible on a
table of (max) values: a first 64 rows of 50 MB, or one long value among short ones (a
mean of a few KB) followed by a run of long ones.
- Each read's first fetch is one row. Each next one takes as many rows as fit in 64 MiB at
the widest row of the batch before (the longest value of each text or binary column, plus
the other columns' bytes per row), and at most twice that batch's rows, up to
arrowBatchSize. - A narrow table reaches
arrowBatchSizefrom its 15th fetch on (1, 2, 4, ..., 8192, then 10,000). A table of(max)values stays under the bound while its rows are no wider than the batch before's: 400 rows of 60,000 characters read with a bound of 256 KiB, every batch under it, on both backends (tests/integration). - ponytail: nothing tells a row's size before it is fetched, so a run of rows much wider than
the batch before still overshoots, by how much wider they are. A hard bound needs a cap per
(max)value, which arrow-odbc has and which fails a longer value. MssqlPythonBackend(..., max_bytes_per_batch)sets the bound, asArrowOdbcBackend's does; no option.