Skip to main content
Version: 0.2.4

Performance Tuning

Where v0.2.3 stands. Reads stage column-major and batch-decode (−14…−47% client CPU per type family vs v0.2.2); writes encode column-major across parallel bulk-load sessions (up to 6.6×); the default TDS packet size is 16 384 (mssql_tds_packet_size, −28% CPU / −43% wall on read vs the old 4096). For per-phase numbers on your own workload, run with MSSQL_COUNTERS=1 — per-stream and per-COPY counters, including encode-path attribution, with no logging inside the timed phases.

Bulk Data Loading

For loading large datasets into SQL Server, use COPY TO with BCP protocol:

COPY large_dataset TO 'mssql://db/dbo/target' (FORMAT 'bcp');

The defaults are the tuned values. This section used to recommend TABLOCK = true and FLUSH_ROWS = 500000; spec 057 measured both against a live server and both are wrong more often than not.

SettingLeave it alone unlessWhy
mssql_copy_tablockyou know the target is a heap AND nothing else loads into itauto already turns it on for a heap. Forcing it on for anything clustered serialises the load: 2M rows into a clustered columnstore took 8.92 s with the hint against 5.23 s without, one core busy instead of three, and compressed identically either way
mssql_copy_flush_rowsyou are memory-constrained on the serverLowering it below 102400 stops a columnstore target from compressing at all — every row lands in the delta store, which only closes on its own at 1048576. Raising it does not help: the value is a server-side batch boundary, not a client buffer, and throughput measured flat above the threshold
mssql_copy_parallel_writersyou must not open more than one sessionThe bound on this path is SQL Server's ingest rate, and it parallelises across sessions. 44 columns x 1M rows: 10.55 s at one writer, 3.24 s at four, 3.51 s at eight — it plateaus past four, which is why the derived value is capped

Inside an explicit transaction COPY uses one writer regardless: the connection is pinned, and a second would sit outside the transaction. CTAS is not affected — it loads outside the transaction by design.

Connection Pool Tuning

-- High-concurrency workloads
SET mssql_connection_limit = 100; -- More connections (default: 64)
SET mssql_min_connections = 5; -- Pre-warm more connections (default: 0)

-- Long-running analytics
SET mssql_query_timeout = 0; -- No timeout (default: 30s)
SET mssql_idle_timeout = 600; -- Keep connections longer (default: 300s)

-- Debugging connection issues
SET mssql_connection_cache = false; -- Disable pooling for isolation

Owning the session: mssql_reset_connection

Connections are pooled, and by default a connection returning to the pool is flagged for a session reset before the next statement uses it — the TDS RESET_CONNECTION bit, which is what sp_reset_connection does and what ADO.NET and JDBC drivers do for the same reason.

That is why a temp table does not survive between statements:

SELECT mssql_exec('db', 'SELECT 1 AS x INTO ##staging');
SELECT * FROM mssql_scan('db', 'SELECT * FROM ##staging');
-- Invalid object name '##staging'

A ##global table lives exactly as long as the session that created it, and the reset ends that session. It is the same physical connection — @@SPID is unchanged — but a new session on it.

There is no way to keep temp tables while still clearing everything else: the reset is a single bit in the TDS packet header with two variants, and the other one (RESET_CONNECTION_SKIP_TRAN) drops ##global and local #temp tables just the same; it differs only in how it treats transaction state.

So the choice is all or nothing:

SET mssql_reset_connection = false;

SELECT mssql_exec('db', 'SELECT 1 AS x INTO ##staging');
SELECT * FROM mssql_scan('db', 'SELECT * FROM ##staging'); -- 1
SELECT mssql_exec('db', 'DROP TABLE ##staging'); -- yours to clean up

What you are taking on. false does not mean "keep my temp tables". It means you own the session state of every pooled connection, and the reset was clearing more than temp objects:

  • SET options (ANSI_NULLS, DATEFIRST, ARITHABORT, …) and the isolation level set by one statement are inherited by the next, unrelated one;
  • session variables, CONTEXT_INFO, and open cursors persist;
  • an open transaction stays open, and keeps its locks, until that connection is used again — the reset is what would have rolled it back.

Use it when you deliberately want a staging table to outlive the statement that created it, prefer ##global over #local (a # table is reachable only from the session that made it, and a pool gives no guarantee about which connection you get next), and drop what you create.

An alternative that needs no setting, if you have CREATE TABLE permission on the database: attach a second catalog and use an ordinary table.

ATTACH 'Server=...;Database=tempdb;...' AS stg (TYPE mssql);
CREATE TABLE stg.dbo.staging AS SELECT * FROM local_data;

INSERT vs COPY Performance

MethodRows/secBest For
Single INSERT~1KSmall single-row operations
Batched INSERT~50–100KINSERT with RETURNING clause
COPY TO / CTAS (BCP)1M+ (parallel writers)Bulk loading without RETURNING

The big lever above that is mssql_copy_parallel_writers, not TABLOCK: the bound is SQL Server's ingest rate and it parallelises across sessions, measured at 3.3x from one writer to four. TABLOCK is worth nothing extra on the default path — auto already applies it where it helps — and costs about 1.7x where it does not (see the tuning table above).

-- For bulk loads without RETURNING, always prefer COPY
-- Instead of:
INSERT INTO db.dbo.target SELECT * FROM large_source;

-- Use:
COPY (SELECT * FROM large_source) TO 'db.dbo.target' (FORMAT 'bcp');

Query Optimization

-- Enable filter pushdown verification
SET mssql_enable_statistics = true; -- Default

-- For complex queries, use mssql_scan with explicit SQL
-- DuckDB will still optimize joins with local tables
FROM mssql_scan('db', 'SELECT id, name FROM dbo.large_table WHERE region = ''US''')
JOIN local_lookup USING (id);

Memory Management

SettingImpactRecommendation
mssql_copy_flush_rowsServer-side batch boundaryLeave at 102 400 — measured flat on heaps; lowering it silently defeats columnstore compression
mssql_insert_batch_sizeDuckDB batch memoryKeep at 1000 (SQL Server limit)
mssql_dml_batch_sizeUPDATE/DELETE memoryDecrease for wide tables