@@ -67,6 +67,67 @@ example SQL Server can still seek on `CAST(datetime AS date)`) while others fall
6767full scan. When a filtered column is indexed and on a hot path, prefer filtering the
6868physical column directly over its cast form.
6969
70+ ## Speed up a large SQL read by partitioning it
71+
72+ When a scan-like extract is bounded by an indexed column, a single ODBC cursor is often the
73+ bottleneck. Pass ` partitions= ` and ` scan_db ` splits the read into independent slices and pulls
74+ them over parallel connections, concatenating the results in order. Build the slices from the
75+ filter you push down with ` by_time ` , ` by_value ` , or ` by_range ` :
76+
77+ ``` python
78+ from polars_io_tools import scan_db, by_time
79+
80+ lf = scan_db(
81+ " SELECT * FROM daily_prices" ,
82+ connection = " Driver={PostgreSQL} ;Server=db.example.com;Database=mkt;Uid=reader;******" ,
83+ partitions = by_time(" price_date" , every = " 1mo" ),
84+ )
85+
86+ # One slice per month, taken from the pushed-down date range:
87+ result = lf.filter(
88+ (pl.col(" price_date" ) >= pl.date(2025 , 1 , 1 )) & (pl.col(" price_date" ) < pl.date(2025 , 7 , 1 ))
89+ ).collect()
90+ ```
91+
92+ - ` by_time(column, every=) ` — calendar windows; ` every ` is an interval string (` "1mo" ` , ` "2w" ` ,
93+ ` "5d" ` , ` "1q" ` , ` "1y" ` ) or an integer number of days.
94+ - ` by_value(column, values=None) ` — one slice per discrete value; with ` values=None ` the values
95+ are read from the ` IN ` filter you push down.
96+ - ` by_range(column, every=) ` — fixed-width numeric buckets over the pushed-down range.
97+
98+ How many slices run at once is capped by a process-wide SQL connection budget
99+ (` POLARS_IO_TOOLS_MAX_SQL_CONNECTIONS ` ; default ` min(pl.thread_pool_size(), 8) ` — a modest 8 on a
100+ normal machine, self-throttling to 1 in fan-out clusters that pin ` POLARS_MAX_THREADS=1 ` , since
101+ these reads are IO-bound and the cap is a connection budget, not the CPU thread pool); pass
102+ ` max_concurrency= ` to throttle below it on a busy server. Partitioning is fully opt-in — with no
103+ ` partitions= ` , or when a partitioner cannot derive a bounded split, ` scan_db ` runs the query over
104+ a single connection. Prefer a handful of medium slices over many tiny ones: each slice is a
105+ separate query with its own planning and round-trip cost.
106+
107+ For hand-built slices, pass an iterable of ` ReadPartition(predicate, key) ` . Predicates that
108+ translate to SQL are pushed to the database; any part that cannot (for example an arbitrary
109+ Python UDF) is still enforced client-side, so each slice stays exact.
110+
111+ Rows are yielded in completion order (whichever slice's batches arrive first), which is not
112+ deterministic; if you need a specific order, sort in Polars with ` .sort() ` after the read. If your
113+ query has a top-level ` ORDER BY ` , partitioning can't re-establish it across slices, so ` scan_db `
114+ logs a warning and ignores it (the rows are still correct) — again, ` .sort() ` after the read.
115+ Clauses that would change the * result* under partitioning — a top-level ` LIMIT ` /` OFFSET ` /` TOP ` /` FETCH ` ,
116+ ` QUALIFY ` , or a window function — raise instead; apply them in Polars after the read (` .head() ` , etc.),
117+ or read without ` partitions= ` .
118+
119+ Each slice is streamed batch-by-batch over its own connection, so peak memory stays proportional to
120+ the connection budget times the arrow batch size — a large slice is never fully buffered in memory.
121+
122+ Each slice opens its own ODBC connection, so a read split into many slices pays that many connects.
123+ If connect overhead dominates (many small slices, or TLS/Kerberos on every connect), prefer a few
124+ larger slices, and/or let the ODBC driver manager recycle physical connections by calling
125+ [ ` arrow_odbc.enable_odbc_connection_pooling() ` ] ( https://arrow-odbc.readthedocs.io/ ) once before your
126+ first read — pooled connects skip the handshake. This is a process-global arrow-odbc / driver-manager
127+ setting (it affects all ODBC use in your process and reuses physical connections, so session-scoped
128+ state such as temp tables can persist across them), so it is left to the caller rather than toggled
129+ by ` scan_db ` .
130+
70131## Read from ClickHouse
71132
72133` scan_clickhouse ` streams query results over ClickHouse's HTTP interface as Arrow IPC.
0 commit comments