<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged duckdb</title>
<link>https://coderlegion.com/tag/duckdb</link>
<description>Powered by Question2Answer</description>
<item>
<title>DuckDB 2.0 vs Polars vs Spark on One Machine: 150M Rows Under a 4.5 GiB Cap</title>
<link>https://coderlegion.com/32051/duckdb-2-0-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-4-5-gib-cap</link>
<description>&lt;p&gt;Originally published on DEV.to: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/firfircelik/duckdb-20-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-45-gib-cap-h38&quot;&gt;https://dev.to/firfircelik/duckdb-20-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-45-gib-cap-h38&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;DuckDB 2.0.0 shipped on October 21, 2026 (&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/2026/10/21/announcing-duckdb-200&quot;&gt;announcement&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/duckdb/2.0.0/&quot;&gt;PyPI&lt;/a&gt;). The next morning I ran the same single-machine benchmark I had already run on DuckDB 1.5.6: three engines, one 8 vCPU VM, Parquet in and Parquet out, a hard memory cap, and four queries that look like real data engineering work.&lt;/p&gt;
&lt;p&gt;The thesis I started with, and that the numbers support:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DuckDB beats Spark-on-one-box on memory-bound DE work.&lt;/strong&gt; It finished every run, at every size, 5 to 12 times faster than local PySpark at 150M rows, and it still finished the heavy queries with a 1 GB memory limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polars hits a wall on window dedup.&lt;/strong&gt; At 150M rows the &lt;code&gt;ROW_NUMBER()&lt;/code&gt; style &quot;latest row per customer&quot; query never finished under the cap. Not at 4.5 GiB, not with an out-of-core budget, not at 9 GiB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polars is not slow.&lt;/strong&gt; On filters, joins and a high-cardinality group by it ties DuckDB or beats it, when it fits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything below is measured on that one box. Where I interpret or give an opinion, I say so.&lt;/p&gt;
&lt;h3 id=&quot;the-contenders&quot;&gt;The contenders&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Model &lt;/td&gt;&lt;td&gt;In-process SQL engine &lt;/td&gt;&lt;td&gt;In-process DataFrame engine, lazy + streaming &lt;/td&gt;&lt;td&gt;JVM engine, &lt;code&gt;local[8]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How I drove it &lt;/td&gt;&lt;td&gt;&lt;code&gt;COPY (SQL) TO 'out.parquet'&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;LazyFrame.sink_parquet()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;spark.sql(...).write.parquet()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Memory knob &lt;/td&gt;&lt;td&gt;&lt;code&gt;SET memory_limit = '3GB'&lt;/code&gt; &lt;/td&gt;&lt;td&gt;default (plus one run with an out-of-core budget) &lt;/td&gt;&lt;td&gt;&lt;code&gt;spark.driver.memory = 3g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Threads &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;8 (&lt;code&gt;POLARS_MAX_THREADS&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;8&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Polars 2.0.0 was published on PyPI on &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/polars/2.0.0/&quot;&gt;October 6, 2026&lt;/a&gt;, PySpark 4.2.0 on &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/pyspark/4.2.0/&quot;&gt;July 14, 2026&lt;/a&gt;. DuckDB 2.0.0 wheels landed on PyPI on October 21, the same day as the &lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/release_calendar&quot;&gt;release calendar&lt;/a&gt; entry.&lt;/p&gt;
&lt;h3 id=&quot;setup&quot;&gt;Setup&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Hardware and versions&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;VM: 8 vCPU Intel Xeon, 15 GiB RAM, no swap, Linux 6.12. It is a shared box, so other processes were running. Every run waited until at least 5 GiB was free and CPU busy was under 30% over a 3 second window.&lt;/li&gt;
&lt;li&gt;Python 3.13.5, OpenJDK 21.0.12, pyarrow 26.0.0.&lt;/li&gt;
&lt;li&gt;DuckDB 2.0.0, Polars 2.0.0, PySpark 4.2.0. The &quot;before&quot; numbers are DuckDB 1.5.6 on the same box and data, run on October 19.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Data.&lt;/strong&gt; A seeded synthetic star schema written with pyarrow: a &lt;code&gt;sales&lt;/code&gt; fact table and a 50,000-row &lt;code&gt;products&lt;/code&gt; dimension. Three sizes: 10M rows (213 MB), 50M (1.1 GB) and 150M (3.2 GB of snappy Parquet, 30 files of 5M rows). About 10 orders per customer, so 150M rows means roughly 15M customers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Queries.&lt;/strong&gt; The same SQL for DuckDB and Spark, the same logic as Polars expressions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;q1_filter_agg&lt;/code&gt;: filter on a timestamp and status, group by store and status.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q2_join_agg&lt;/code&gt;: fact-to-dimension join, group by category and brand.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q3_window_latest&lt;/code&gt;: latest order per customer with &lt;code&gt;ROW_NUMBER()&lt;/code&gt;. The classic dedup and SCD pattern.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;q4_highcard_agg&lt;/code&gt;: one row per customer (about 15M groups at 150M).&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- q3_window_latest
SELECT customer_id, order_id, ts, qty * price AS amount
FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id
                                 ORDER BY ts DESC, order_id DESC) AS rn
    FROM sales
) t
WHERE rn = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Method.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every run is a fresh process: interpreter start, import, session, read Parquet, compute, write Parquet. So no warm caches inside the engine, and Spark pays its JVM start every time.&lt;/li&gt;
&lt;li&gt;Engines run strictly one at a time. The engine order rotates per repetition so no engine always goes first.&lt;/li&gt;
&lt;li&gt;5 repetitions at 10M, 3 at 50M and 150M. I report &lt;strong&gt;medians&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A watchdog samples the RSS of the whole process tree every 50 ms and kills the run if it goes above &lt;strong&gt;4.5 GiB&lt;/strong&gt;. That is the cap. It is deliberately tighter than the box, because &quot;fits in a small container&quot; is the question I care about.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wall&lt;/strong&gt; is the whole process. &lt;strong&gt;Query&lt;/strong&gt; is read, compute and write only. The difference is setup.&lt;/li&gt;
&lt;li&gt;Correctness: a validation script compares row counts and rounded checksums of every output. Where an engine produced output, all three engines agreed, for every query and size.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;results-at-150m-rows&quot;&gt;Results at 150M rows&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Query &lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Filter + aggregate &lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.87 s&lt;/strong&gt; (3/3) &lt;/td&gt;&lt;td&gt;0.96 s (3/3) &lt;/td&gt;&lt;td&gt;10.77 s (3/3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Join + aggregate &lt;/td&gt;&lt;td&gt;1.47 s (3/3) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;1.46 s&lt;/strong&gt; (3/3) &lt;/td&gt;&lt;td&gt;16.10 s (3/3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;&lt;strong&gt;7.92 s&lt;/strong&gt; (3/3) &lt;/td&gt;&lt;td&gt;killed (0/3) &lt;/td&gt;&lt;td&gt;46.29 s (2/3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;High-cardinality group by &lt;/td&gt;&lt;td&gt;7.91 s (3/3) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;4.21 s&lt;/strong&gt; (1/3) &lt;/td&gt;&lt;td&gt;39.70 s (3/3)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Median wall seconds, runs that finished under the 4.5 GiB cap in brackets.&lt;/p&gt;
&lt;p&gt;Peak RSS (median of finished runs) tells the other half:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Query &lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Filter + aggregate &lt;/td&gt;&lt;td&gt;0.77 GiB &lt;/td&gt;&lt;td&gt;0.94 GiB &lt;/td&gt;&lt;td&gt;1.85 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Join + aggregate &lt;/td&gt;&lt;td&gt;0.42 GiB &lt;/td&gt;&lt;td&gt;1.96 GiB &lt;/td&gt;&lt;td&gt;1.61 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;3.01 GiB &lt;/td&gt;&lt;td&gt;above 4.5 (killed) &lt;/td&gt;&lt;td&gt;3.74 GiB, one run hit 7.51&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;High-cardinality group by &lt;/td&gt;&lt;td&gt;3.01 GiB &lt;/td&gt;&lt;td&gt;4.49 GiB &lt;/td&gt;&lt;td&gt;4.40 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three things stand out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spark on one box pays twice.&lt;/strong&gt; About 5 seconds of every Spark run is setup (median 5.0 to 5.8 s, JVM plus session) before it reads a byte. Then the query itself is slower: query-only time was 7.4x DuckDB's on the filter, 7.9x on the join, 5.2x on the window dedup and 4.3x on the group by. In wall time that is 5x to 12x. And &lt;code&gt;spark.driver.memory = 3g&lt;/code&gt; did not keep the process at 3 GiB: one window dedup run reached 7.51 GiB of process-tree RSS and was killed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Polars is fast right up to the cap.&lt;/strong&gt; It tied DuckDB on filter and join and was the fastest engine on the high-cardinality group by (4.21 s against 7.91 s). But only 1 of 3 runs finished: that one peaked at 4.49 GiB, and the other two crossed the cap at 4.50 and 4.55 GiB. Fast and close to the edge is a fine place for a notebook and a bad place for a scheduled job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The window dedup is where Polars stops.&lt;/strong&gt; At 150M rows it was killed in all 3 runs, within a few seconds of starting, at 4.52 to 4.66 GiB.&lt;/p&gt;
&lt;h3 id=&quot;polars-and-the-window-wall&quot;&gt;Polars and the window wall&lt;/h3&gt;
&lt;p&gt;I gave Polars two more chances on the 150M window dedup:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Polars 2.0.0, 150M window dedup &lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Default settings, 4.5 GiB cap (Oct 22) &lt;/td&gt;&lt;td&gt;0/3, killed at 4.52 to 4.66 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;POLARS_OOC_MEMORY_BUDGET_MB=3072&lt;/code&gt;, 4.5 GiB cap (Oct 22) &lt;/td&gt;&lt;td&gt;0/3, killed at 4.57 to 4.66 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Default settings, &lt;strong&gt;9 GiB&lt;/strong&gt; cap (Oct 19, same Polars 2.0.0) &lt;/td&gt;&lt;td&gt;0/3, killed at 9.04 to 9.54 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The out-of-core budget variable is one I found in the Polars 2.0.0 source, not a documented knob, so treat that row as &quot;I tried&quot;, not as guidance. The same variable did help the group by: with it, Polars finished 3/3 at 4.67 s and 4.18 to 4.30 GiB.&lt;/p&gt;
&lt;p&gt;At smaller sizes the dedup works, but the shape is already visible:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Window dedup &lt;/th&gt;&lt;th&gt;DuckDB 2.0.0 &lt;/th&gt;&lt;th&gt;Polars 2.0.0 &lt;/th&gt;&lt;th&gt;PySpark 4.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;10M rows &lt;/td&gt;&lt;td&gt;0.60 s, 0.65 GiB &lt;/td&gt;&lt;td&gt;1.83 s, 1.20 GiB &lt;/td&gt;&lt;td&gt;12.74 s, 2.94 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;50M rows &lt;/td&gt;&lt;td&gt;2.33 s, 2.19 GiB &lt;/td&gt;&lt;td&gt;10.36 s, 4.46 GiB &lt;/td&gt;&lt;td&gt;29.06 s, 3.77 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;150M rows &lt;/td&gt;&lt;td&gt;7.92 s, 3.01 GiB &lt;/td&gt;&lt;td&gt;killed &lt;/td&gt;&lt;td&gt;46.29 s, 3.74 GiB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;At 50M Polars already used 4.46 GiB for a query DuckDB did in 2.19 GiB, and took 4.4x longer.&lt;/p&gt;
&lt;p&gt;This is the Polars expression I used. It is the direct translation of the SQL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import polars as pl

amount = pl.col(&quot;qty&quot;).cast(pl.Float64) * pl.col(&quot;price&quot;)
rn = pl.int_range(pl.len()).over(
    &quot;customer_id&quot;, order_by=[&quot;ts&quot;, &quot;order_id&quot;], descending=True
)
(
    pl.scan_parquet(&quot;data/sales_150m/*.parquet&quot;)
    .filter(rn == 0)
    .select(&quot;customer_id&quot;, &quot;order_id&quot;, &quot;ts&quot;, amount.alias(&quot;amount&quot;))
    .sink_parquet(&quot;out/polars/150m_q3_window_latest.parquet&quot;)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fair caveat: I did not test other Polars formulations of the same dedup (a sort followed by &lt;code&gt;unique(keep=&quot;first&quot;)&lt;/code&gt;, or a &lt;code&gt;group_by().agg(...)&lt;/code&gt; with &lt;code&gt;sort_by().last()&lt;/code&gt;). One of them may stream better. The point stands for the common case, though: the first thing a team writes is the window, and that one did not fit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update (Oct 22):&lt;/strong&gt; after this went out, Victor Cid (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Vleyked&quot;&gt;@Vleyked&lt;/a&gt;) tested exactly that in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/1&quot;&gt;engine-bench PR #1&lt;/a&gt;. His &lt;code&gt;sort&lt;/code&gt; + &lt;code&gt;unique(keep=&quot;first&quot;)&lt;/code&gt; version drops the in-memory window fallback from the plan and finished 3/3 at 150M rows under the same 4.5 GiB cap, peaking at 2.50 to 2.74 GiB, but only with &lt;code&gt;POLARS_OOC_MEMORY_BUDGET_MB=1024&lt;/code&gt;. With default settings or a 3072 MB budget it still went over the cap. The price is speed: a 90.58 s median against 17.56 s for DuckDB on the same 4 vCPU runner, so roughly 5x slower. His output matched DuckDB row for row (14,999,347 rows). So Polars can do this dedup under the cap if you rewrite it and tune the spill budget, and with a smarter query it gets close to DuckDB (see below). His runs were on different hardware, so they are not in the tables above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second update:&lt;/strong&gt; in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/2&quot;&gt;engine-bench PR #2&lt;/a&gt; he filtered each customer down to their max timestamp first and only sorted those rows to break ties. That brought the median down to 14.23 s at 150M rows with default Polars settings, peaking at 4.04 GiB under the same cap, with output matching DuckDB row for row. On his runner DuckDB's reference was a single 18.50 s run, so I'm not calling that a repeated cross-engine win. The real lesson is that the first way you write a Polars dedup can decide whether it finishes at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third update (Oct 23):&lt;/strong&gt; Ritchie Vink, the creator of Polars, pointed out that only DuckDB was told about the memory limit, so Polars did not know it had to stay under 4 GiB, and suggested setting POLARS_OOC_MEMORY_BUDGET_MB for a fairer comparison. I reran every Polars query at 150M rows on the same box under the same 4.5 GiB cap with that budget, next to a DuckDB rerun in the same session: the high-cardinality group by finished 3/3 at 6.17 s and 3.46 GiB with a 3072 MB budget, against 9.56 s for DuckDB (the box was busier than on Oct 22 and DuckDB ran slower too, so compare within this session, not with the tables above; the Oct 22 run with the same budget gave 4.67 s). The window dedup with over(order_by) still went over the cap in every run at 3072, 1024 and 512 MB, Victor's sort plus unique version fit only at 1024 MB (31.28 s, 2.29 GiB), and the max-timestamp rewrite finished 3/3 on default settings (5.61 s, 4.02 GiB). Ritchie says Polars main now spills the group by correctly and that this ships in the next release, which was not on PyPI yet when I ran this, so I will rerun when it ships. Raw results and output checks are in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/3&quot;&gt;engine-bench PR #3&lt;/a&gt; and in results/polars_ooc_budget_2026-10-23 on the polars-ooc-budget-2026-10-23 branch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fourth update (Oct 26):&lt;/strong&gt; after the third update I reported the window dedup to Polars as &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pola-rs/polars/issues/30270&quot;&gt;pola-rs/polars#30270&lt;/a&gt;: the over(order_by) window inside filter() ignored POLARS_OOC_MEMORY_BUDGET_MB and ran in an in-memory fallback node. Ritchie Vink fixed it (#30272, #30282 and #30280), and the fixes shipped in Polars 2.1.0. I reran the 150M benchmark on the official 2.1.0 wheel on the same box, with the same harness and the same 4.5 GiB cap, next to a DuckDB rerun in the same session. With a budget set, the window dedup now finishes 3/3 at every budget I tried: 18.78 s and 3.34 GiB at 3072 MB, 20.11 s and 1.29 GiB at 1024 MB, 43.01 s and 2.15 GiB at 512 MB. On 2.0.0 it failed at all three. Memory now follows the budget closely: at 512 MB, Victor's sort plus unique version peaked at 0.69 GiB (73.85 s) and the high-cardinality group by at 0.74 GiB (9.43 s). DuckDB still ran the window dedup faster in the same session, 8.07 s, about 2.3x quicker than Polars at 3072 MB, and the max-timestamp rewrite remains the fastest Polars dedup (5.12 s on default settings). Every finished output matched DuckDB row for row (14,999,347 rows). Two caveats. Without a budget, the window dedup and sort plus unique still went over the cap. My cap is a process watchdog, not a cgroup limit, so the new cgroup-aware default in 2.1.0 had nothing to detect and is untested here; set the budget explicitly. One sort plus unique run at 3072 MB took 285.64 s for no clear reason, so I report medians of 3 runs. Raw results, plans and output checks: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/pull/4&quot;&gt;engine-bench PR #4&lt;/a&gt; and the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench/blob/polars-main-window-fix-2026-10-24/results/polars_2.1.0_2026-10-25/README.md&quot;&gt;2.1.0 results README&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;duckdb-under-pressure-memory-limit-1-gb&quot;&gt;DuckDB under pressure: memory_limit = 1 GB&lt;/h3&gt;
&lt;p&gt;The interesting DuckDB question is not &quot;how fast with room to spare&quot; but &quot;what happens when the box is small&quot;. Same 150M rows, &lt;code&gt;memory_limit&lt;/code&gt; cut to 1 GB:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;150M, &lt;code&gt;memory_limit = 1GB&lt;/code&gt; &lt;/th&gt;&lt;th&gt;Wall (median) &lt;/th&gt;&lt;th&gt;Peak RSS &lt;/th&gt;&lt;th&gt;Finished&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;14.97 s &lt;/td&gt;&lt;td&gt;1.12 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;High-cardinality group by &lt;/td&gt;&lt;td&gt;13.94 s &lt;/td&gt;&lt;td&gt;1.08 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Window dedup, &lt;code&gt;preserve_insertion_order = false&lt;/code&gt; &lt;/td&gt;&lt;td&gt;14.94 s &lt;/td&gt;&lt;td&gt;1.12 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Group by, &lt;code&gt;preserve_insertion_order = false&lt;/code&gt; &lt;/td&gt;&lt;td&gt;13.88 s &lt;/td&gt;&lt;td&gt;1.08 GiB &lt;/td&gt;&lt;td&gt;3/3&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;With a third of the memory, DuckDB spilled to disk and still beat Spark's unconstrained run by about 3x on both queries, at a quarter to a third of Spark's peak RSS. The DuckDB docs list window functions with &lt;code&gt;PARTITION BY ... ORDER BY&lt;/code&gt; among the blocking operators it can process larger than memory (&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/docs/current/guides/performance/how_to_tune_workloads&quot;&gt;How to Tune Workloads&lt;/a&gt;). This run is that sentence, measured.&lt;/p&gt;
&lt;p&gt;The setup is three lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import duckdb

con = duckdb.connect()
con.execute(&quot;SET memory_limit = '1GB'&quot;)
con.execute(&quot;SET threads = 8&quot;)
con.execute(&quot;SET temp_directory = '/mnt/fast-local-disk/duckdb_tmp'&quot;)  # where spills go
con.execute(&quot;&quot;&quot;
    COPY (
        SELECT customer_id, order_id, ts, qty * price AS amount
        FROM (
            SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id
                                         ORDER BY ts DESC, order_id DESC) AS rn
            FROM read_parquet('data/sales_150m/*.parquet')
        )
        WHERE rn = 1
    ) TO 'out/latest_per_customer.parquet' (FORMAT parquet)
&quot;&quot;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;duckdb-1-5-6-vs-2-0-0&quot;&gt;DuckDB 1.5.6 vs 2.0.0&lt;/h3&gt;
&lt;p&gt;Same box, same data, same scripts. 1.5.6 ran on October 19, 2.0.0 on October 22. Median wall seconds at 150M:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Setting &lt;/th&gt;&lt;th&gt;Query &lt;/th&gt;&lt;th&gt;1.5.6 &lt;/th&gt;&lt;th&gt;2.0.0 &lt;/th&gt;&lt;th&gt;Change&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 3GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;9.09 &lt;/td&gt;&lt;td&gt;7.92 &lt;/td&gt;&lt;td&gt;-13%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 3GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Group by &lt;/td&gt;&lt;td&gt;9.61 &lt;/td&gt;&lt;td&gt;7.91 &lt;/td&gt;&lt;td&gt;-18%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 1GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;17.79 &lt;/td&gt;&lt;td&gt;14.97 &lt;/td&gt;&lt;td&gt;-16%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;memory_limit = 1GB&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Group by &lt;/td&gt;&lt;td&gt;22.06 &lt;/td&gt;&lt;td&gt;13.94 &lt;/td&gt;&lt;td&gt;-37%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1GB, no insertion order &lt;/td&gt;&lt;td&gt;Window dedup &lt;/td&gt;&lt;td&gt;14.85 &lt;/td&gt;&lt;td&gt;14.94 &lt;/td&gt;&lt;td&gt;+1%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1GB, no insertion order &lt;/td&gt;&lt;td&gt;Group by &lt;/td&gt;&lt;td&gt;15.15 &lt;/td&gt;&lt;td&gt;13.88 &lt;/td&gt;&lt;td&gt;-8%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The useful finding is the last four rows together. On 1.5.6, &lt;code&gt;SET preserve_insertion_order = false&lt;/code&gt; was worth 16% on the dedup and 31% on the group by under a 1 GB limit. On 2.0.0 the default already lands where the tuned setting did, and the flag changes almost nothing for these two queries. If you carry that setting in job configs as a performance trick, re-measure after upgrading. It still matters for what it was designed for: the docs recommend it when large imports or exports run out of memory, and it lets DuckDB reorder rows without an &lt;code&gt;ORDER BY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Not everything improved. The 50M window dedup was slower on 2.0.0 (1.94 s to 2.33 s), and peak RSS for the light queries went up (150M filter: 0.26 to 0.77 GiB, join: 0.23 to 0.42 GiB), while the heavy queries went down (window dedup: 3.47 to 3.01 GiB). I have not investigated why. The two runs were also three days apart on a shared box, so treat differences under about 10 to 15% as noise. The 1 GB group by drop is far outside that.&lt;/p&gt;
&lt;p&gt;The release itself is mostly a feature release: Quack and &lt;code&gt;CONNECT&lt;/code&gt; for client-server use, asynchronous I/O, a first-class &lt;code&gt;VARIANT&lt;/code&gt;, triggers, a PEG parser by default (&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/2026/10/21/announcing-duckdb-200&quot;&gt;announcement&lt;/a&gt;). It is an LTS line supported until at least October 2027. One migration note matters for pipelines: database files written by 2.0 cannot be read by versions before 2.0. My benchmark only reads and writes Parquet, so it never touched that.&lt;/p&gt;
&lt;h3 id=&quot;where-spark-still-wins&quot;&gt;Where Spark still wins&lt;/h3&gt;
&lt;p&gt;None of this says &quot;replace Spark&quot;. It says Spark on one box is the wrong comparison for small and medium jobs. Spark still wins when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The data does not fit one node's disk and memory, or the deadline does not fit one node's cores.&lt;/strong&gt; This test stops at 3.2 GB of Parquet on purpose. DuckDB spills well, but one machine is still one machine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The platform is built around it.&lt;/strong&gt; On Databricks and Fabric, Spark is where Unity Catalog or OneLake governance, Delta writes with concurrent writers, lineage, job orchestration and streaming already live. A DuckDB step outside that boundary is a step you have to secure, monitor and explain.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cluster is already running.&lt;/strong&gt; The 5 second JVM start I measured per run disappears in a warm interactive cluster or a long job. The query-only gap (4x to 8x here) does not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The team is a Spark team.&lt;/strong&gt; One engine everyone can debug at 02:00 is worth more than a faster engine one person understands.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My opinion as a Lead DE on Azure, Databricks and Fabric: the line is not the engine, it is the size of the job. Most jobs I see are tens of millions of rows dressed up as big data and started on a cluster. For those (file-to-file transforms before landing, validation and reconciliation checks, small dimension builds, CI tests for SQL logic) a single-node engine with a memory limit is the cheaper and more predictable choice. Anything that writes governed Delta tables stays on the platform.&lt;/p&gt;
&lt;h3 id=&quot;failure-modes-i-hit-so-you-don-t-have-to&quot;&gt;Failure modes I hit, so you don't have to&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Engine memory settings are not process memory.&lt;/strong&gt; Spark's 3g driver heap still produced a 7.51 GiB process. DuckDB's whole process stayed close to its &lt;code&gt;memory_limit&lt;/code&gt;: about 3.0 GiB peak at a 3 GB limit, 1.1 GiB at 1 GB. Size containers on measured RSS, not on the setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&quot;Streaming&quot; does not mean bounded.&lt;/strong&gt; Polars streamed the filter, join and group by fine. The window dedup with an &lt;code&gt;over(order_by=...)&lt;/code&gt; did not stay bounded, even with 9 GiB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Close to the cap is a failure that hasn't happened yet.&lt;/strong&gt; Polars' group by finished in 1 of 3 runs, peaking at 4.49 GiB against a 4.5 GiB cap. Same query, same data, same version. Schedule that and it fails some nights.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared hosts lie.&lt;/strong&gt; One Spark run at 10M was killed because free memory on the whole box fell under my 1 GiB safety floor while Spark itself was at 1.6 GiB. That was the neighbours, not Spark. Gate each run on free memory and CPU, and record both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validate outputs, not just timings.&lt;/strong&gt; Every engine produced the same row counts and checksums where it finished. Without that check, a fast wrong answer looks like a win.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;reproduce-it&quot;&gt;Reproduce it&lt;/h3&gt;
&lt;p&gt;Everything is public at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/firfircelik/engine-bench&quot;&gt;firfircelik/engine-bench&lt;/a&gt;: scripts, raw results, pinned versions and the query plans. Here are the parts that matter. The data generator is seeded (&lt;code&gt;SEED = 20261019&lt;/code&gt;) and writes 5M-row chunks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;rng = np.random.default_rng([SEED, chunk_index])
t = pa.table({
    &quot;order_id&quot;: np.arange(lo, lo + n, dtype=np.int64),
    &quot;ts&quot;: pa.array(start + rng.integers(0, 270 * 24 * 3600, n), pa.timestamp(&quot;s&quot;)),
    &quot;customer_id&quot;: rng.integers(0, rows // 10, n, dtype=np.int32),
    &quot;product_id&quot;: rng.integers(0, 50_000, n, dtype=np.int32),
    &quot;store_id&quot;: rng.integers(0, 500, n, dtype=np.int16),
    &quot;qty&quot;: rng.integers(1, 10, n, dtype=np.int16),
    &quot;price&quot;: np.round(rng.uniform(1.0, 500.0, n), 2),
    &quot;status&quot;: rng.choice(STATUSES, n, p=[0.55, 0.20, 0.12, 0.08, 0.05]),
})
pq.write_table(t, f&quot;sales_{rows // 1_000_000}m/part-{chunk_index:04d}.parquet&quot;,
               row_group_size=1_000_000, compression=&quot;snappy&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spark session, the only non-default settings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;spark = (SparkSession.builder.master(&quot;local[8]&quot;)
         .config(&quot;spark.driver.memory&quot;, &quot;3g&quot;)
         .config(&quot;spark.local.dir&quot;, TMP)
         .config(&quot;spark.ui.enabled&quot;, &quot;false&quot;)
         .config(&quot;spark.sql.session.timeZone&quot;, &quot;UTC&quot;)
         .getOrCreate())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the watchdog, which is the part I would copy into any benchmark of your own. It measures the whole process tree, because JVMs and thread pools do not show up in the parent's RSS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import psutil, subprocess, time

GIB = 1024 ** 3

def tree_rss(p):
    total = 0
    for q in [p] + p.children(recursive=True):
        try:
            total += q.memory_info().rss
        except psutil.NoSuchProcess:
            pass
    return total

proc = subprocess.Popen(cmd)
p, peak = psutil.Process(proc.pid), 0
while proc.poll() is None:
    rss = tree_rss(p)
    peak = max(peak, rss)
    if rss &amp;gt; 4.5 * GIB:
        for q in p.children(recursive=True) + [p]:
            q.kill()
        break
    time.sleep(0.05)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;/h3&gt;
&lt;p&gt;On one 8 vCPU machine with a 4.5 GiB cap, DuckDB 2.0.0 was the only engine that finished every run of every query at every size, and it did the heavy ones with 1 GB. Polars matched or beat it on the light queries and the group by, and could not finish the window dedup at 150M even with 9 GiB. Local Spark finished almost everything, 5x to 12x slower, and its memory setting did not bound its memory.&lt;/p&gt;
&lt;p&gt;If your job fits on one machine, measure it on one machine before you start a cluster for it.&lt;/p&gt;
&lt;h3 id=&quot;sources&quot;&gt;Sources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/2026/10/21/announcing-duckdb-200&quot;&gt;Announcing DuckDB 2.0.0 LTS &quot;Cinnamon Teal&quot;&lt;/a&gt;, DuckDB team, October 21, 2026&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/release_calendar&quot;&gt;DuckDB release calendar&lt;/a&gt; (2.0.0 LTS on 2026-10-21, 1.5.6 on 2026-09-28)&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/duckdb/2.0.0/&quot;&gt;duckdb 2.0.0 on PyPI&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/polars/2.0.0/&quot;&gt;polars 2.0.0 on PyPI&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/pyspark/4.2.0/&quot;&gt;pyspark 4.2.0 on PyPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/docs/current/guides/performance/how_to_tune_workloads&quot;&gt;DuckDB: How to Tune Workloads&lt;/a&gt; (larger-than-memory operators, &lt;code&gt;preserve_insertion_order&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/docs/current/sql/dialect/order_preservation&quot;&gt;DuckDB: Order Preservation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;All timings and memory numbers: my own runs on October 19 (DuckDB 1.5.6, and the 9 GiB Polars and Spark runs) and October 22, 2026 (DuckDB 2.0.0, Polars 2.0.0, PySpark 4.2.0).&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32051/duckdb-2-0-vs-polars-vs-spark-on-one-machine-150m-rows-under-a-4-5-gib-cap</guid>
<pubDate>Thu, 22 Oct 2026 08:52:00 +0000</pubDate>
</item>
<item>
<title>Building a Zero-Telemetry Production Log Triage Engine with Python, DuckDB, and Gemini</title>
<link>https://coderlegion.com/30333/building-a-zero-telemetry-production-log-triage-engine-with-python-duckdb-and-gemini</link>
<description>&lt;p&gt;During a Sev-1 incident, every minute spent waiting for an enterprise observability platform's search indexing—or watching a junior SRE run unindexed &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;awk&lt;/code&gt; commands across 20GB raw &lt;code&gt;.jsonl&lt;/code&gt; dumps—directly translates to business downtime.&lt;/p&gt;
&lt;p&gt;Most commercial log analytics vendors charge astronomical data ingestion fees, only to provide rigid dashboards, slow text queries, and generic LLM summaries that hallucinate stack trace causality. &lt;/p&gt;
&lt;p&gt;In this guide, we'll build a &lt;strong&gt;Production Log Triage &amp;amp; Root-Cause Engine&lt;/strong&gt; that runs entirely locally, processes millions of raw log entries in seconds using &lt;strong&gt;DuckDB&lt;/strong&gt;, clusters redundant stack traces deterministically into semantic fault signatures, and leverages &lt;strong&gt;Google Gemini 1.5 Flash / Pro&lt;/strong&gt; to pinpoint root causes and generate zero-telemetry incident post-mortems.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-bottleneck-why-centralized-log-indexing-fails-incident-triage&quot;&gt;1. The Bottleneck: Why Centralized Log Indexing Fails Incident Triage&lt;/h3&gt;
&lt;p&gt;Centralized observability stacks (Datadog, Splunk, Elastic) are designed for long-term retention and metrics aggregation, not real-time ad-hoc crash triage. When services experience cascading failure:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Ingestion Throttling &amp;amp; Lag&lt;/strong&gt;: Log volume spikes 100x during a cascade. Centralized collectors throttle or lag by 5 to 15 minutes right when you need sub-second visibility.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The High-Cardinality Trap&lt;/strong&gt;: Indexing UUIDs, dynamic user IDs, and raw exception messages creates massive index bloat. Querying raw text strings across unindexed columns over multi-gigabyte partitions leads to timeouts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Window Exhaustion&lt;/strong&gt;: Dumping 100,000 raw lines into an LLM context window burns tokens instantly, triggers rate limits, and results in context degradation where the model misses the primary causal trigger buried in repetitive retry loops.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;To solve this, we decouple triage from long-term storage using a local, three-stage pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────┐       ┌────────────────────────┐       ┌─────────────────────────┐       ┌────────────────────┐
│  Raw Log Dumps  │ ----&amp;gt; │  DuckDB SQL Engine     │ ----&amp;gt; │  Semantic Fingerprint   │ ----&amp;gt; │  Gemini 1.5 Triage │
│ (.jsonl/syslog) │       │ (Multi-GB in-memory)   │       │ &amp;amp; Error Clustering      │       │ (Root Cause &amp;amp; PR)  │
└─────────────────┘       └────────────────────────┘       └─────────────────────────┘       └────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-the-architecture&quot;&gt;2. The Architecture&lt;/h3&gt;
&lt;p&gt;The engine operates entirely within an ephemeral execution environment or CLI runner:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;In-Process Analytical Querying (DuckDB)&lt;/strong&gt;: Queries raw compressed &lt;code&gt;.jsonl.gz&lt;/code&gt;, &lt;code&gt;.csv&lt;/code&gt;, or Syslog files directly on disk using vectorized execution without spinning up an external database daemon.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deterministic Masking &amp;amp; Fingerprinting&lt;/strong&gt;: Strips transient parameters (UUIDs, memory addresses, timestamps, numeric IDs, IP addresses) from stack traces to generate a deterministic SHA-256 cluster key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Volumetric Anomaly Scoring&lt;/strong&gt;: Ranks fault signatures by frequency, blast radius (unique services impacted), and temporal proximity to the incident start.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured Gemini Root-Cause Extraction&lt;/strong&gt;: Feeds the top unique signatures, complete with clean contextual stack traces and surrounding debug lines, into Gemini 1.5 Pro/Flash to produce actionable root causes and git diff patches.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-code-amp-implementation&quot;&gt;3. The Code &amp;amp; Implementation&lt;/h3&gt;
&lt;h4 id=&quot;step-1-high-speed-log-filtering-with-duckdb&quot;&gt;Step 1: High-Speed Log Filtering with DuckDB&lt;/h4&gt;
&lt;p&gt;DuckDB allows us to query gigabytes of JSONL logs directly from disk using standard SQL, outperforming raw Python parsing by orders of magnitude.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import duckdb

def extract_fault_candidates(log_file_path: str, min_level: str = &quot;ERROR&quot;):
    &quot;&quot;&quot;
    Directly reads JSONL from disk, filters error-tier rows,
    and extracts stack traces, timestamps, and service tags.
    &quot;&quot;&quot;
    con = duckdb.connect(database=&quot;:memory:&quot;)
    
    # Configure DuckDB for high-throughput disk scan
    con.execute(&quot;SET threads TO 4;&quot;)
    con.execute(&quot;SET preserve_insertion_order = false;&quot;)
    
    query = f&quot;&quot;&quot;
        SELECT 
            timestamp,
            service_name,
            level,
            message,
            COALESCE(stack_trace, '') as stack_trace
        FROM read_json_auto('{log_file_path}')
        WHERE level IN ('{min_level}', 'CRITICAL', 'FATAL')
        ORDER BY timestamp ASC
    &quot;&quot;&quot;
    
    df = con.execute(query).df()
    con.close()
    return df
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-semantic-log-normalization-amp-fingerprinting&quot;&gt;Step 2: Semantic Log Normalization &amp;amp; Fingerprinting&lt;/h4&gt;
&lt;p&gt;To compress 100,000 errors into single-digit unique signatures, we mask high-cardinality noise using deterministic regex rules before generating a cluster hash.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import re
import hashlib
import pandas as pd

class LogNormalizer:
    def __init__(self):
        self.patterns = [
            (re.compile(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'), '&amp;lt;UUID&amp;gt;'),
            (re.compile(r'0x[0-9a-fA-F]+'), '&amp;lt;HEX_ADDR&amp;gt;'),
            (re.compile(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'), '&amp;lt;IP&amp;gt;'),
            (re.compile(r'\b\d+\b'), '&amp;lt;INT&amp;gt;'),
            (re.compile(r'(https?://\S+)'), '&amp;lt;URL&amp;gt;'),
        ]

    def mask(self, text: str) -&amp;gt; str:
        for pattern, replacement in self.patterns:
            text = pattern.sub(replacement, text)
        return text

def cluster_errors(df: pd.DataFrame) -&amp;gt; pd.DataFrame:
    normalizer = LogNormalizer()
    
    # Normalize messages and stack traces
    df['normalized_signature'] = df.apply(
        lambda row: normalizer.mask(f&quot;{row['service_name']}::{row['message']}::{row['stack_trace'][:200]}&quot;),
        axis=1
    )
    
    # Generate deterministic hash
    df['signature_hash'] = df['normalized_signature'].apply(
        lambda sig: hashlib.sha256(sig.encode('utf-8')).hexdigest()[:12]
    )
    
    # Aggregate clusters
    clusters = df.groupby('signature_hash').agg(
        occurrence_count=('timestamp', 'count'),
        first_seen=('timestamp', 'min'),
        last_seen=('timestamp', 'max'),
        sample_service=('service_name', 'first'),
        sample_message=('message', 'first'),
        sample_stack_trace=('stack_trace', 'first')
    ).reset_index().sort_values(by='occurrence_count', ascending=False)
    
    return clusters
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-3-targeted-root-cause-analysis-with-gemini-1-5&quot;&gt;Step 3: Targeted Root-Cause Analysis with Gemini 1.5&lt;/h4&gt;
&lt;p&gt;Now that thousands of noisy lines are clustered into top signatures, we pass the critical clusters to Gemini 1.5 using structured outputs to avoid generic summaries.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
from google import genai
from google.genai import types
from pydantic import BaseModel, Field

class RootCauseAnalysis(BaseModel):
    fault_signature_id: str
    root_cause_summary: str = Field(description=&quot;Concise explanation of the underlying software or infrastructure bug.&quot;)
    failure_domain: str = Field(description=&quot;e.g., Database, Network, Concurrency, Logic, Memory&quot;)
    severity_impact: str = Field(description=&quot;Low, Medium, High, Critical&quot;)
    remediation_patch: str = Field(description=&quot;Code snippet, configuration change, or SQL mitigation&quot;)

def analyze_signature_with_gemini(cluster_row: dict) -&amp;gt; RootCauseAnalysis:
    client = genai.Client(api_key=os.environ[&quot;GEMINI_API_KEY&quot;])
    
    prompt = f&quot;&quot;&quot;
    Analyze this production log fault cluster to isolate the absolute root cause.
    
    OCCURRENCE COUNT: {cluster_row['occurrence_count']}
    SERVICE: {cluster_row['sample_service']}
    FIRST SEEN: {cluster_row['first_seen']}
    LAST SEEN: {cluster_row['last_seen']}
    ERROR MESSAGE: {cluster_row['sample_message']}
    
    RAW STACK TRACE:
    {cluster_row['sample_stack_trace']}
    
    Provide precise diagnostic details and a code-level remediation fix.
    &quot;&quot;&quot;
    
    response = client.models.generate_content(
        model='gemini-1.5-flash',
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type=&quot;application/json&quot;,
            response_schema=RootCauseAnalysis,
            temperature=0.1
        )
    )
    
    return RootCauseAnalysis.model_validate_json(response.text)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-deployment-performance-amp-zero-telemetry-execution&quot;&gt;4. Deployment, Performance &amp;amp; Zero-Telemetry Execution&lt;/h3&gt;
&lt;h4 id=&quot;handling-large-log-dumps-gt-50gb-&quot;&gt;Handling Large Log Dumps (&amp;gt;50GB)&lt;/h4&gt;
&lt;p&gt;By default, DuckDB's streaming parser streams rows out-of-core. If you are operating under tight memory constraints (e.g., a triage agent running in a 2GB RAM container):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;PRAGMA max_memory = '1.5GB';
PRAGMA temp_directory = '/tmp/duckdb_spill';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DuckDB will transparently spill intermediate aggregations to disk, preventing Out-Of-Memory (OOM) crashes even when sorting massive volumes.&lt;/p&gt;
&lt;h4 id=&quot;rate-limiting-amp-token-optimization&quot;&gt;Rate Limiting &amp;amp; Token Optimization&lt;/h4&gt;
&lt;p&gt;Passing full stack traces can quickly eat into context windows if uncapped. We apply three safeguards:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Truncation Threshold&lt;/strong&gt;: Limit raw stack traces to the bottom 15 frames where the crash occurred.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency Limiting&lt;/strong&gt;: Wrap Gemini API calls in an &lt;code&gt;asyncio.Semaphore(5)&lt;/code&gt; to prevent 429 quota exhaustion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model Tiering&lt;/strong&gt;: Use &lt;code&gt;gemini-1.5-flash&lt;/code&gt; for high-volume clusters (&amp;gt;95% of cases), switching to &lt;code&gt;gemini-1.5-pro&lt;/code&gt; only for multi-service cascade anomalies with complex distributed traces.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;exporting-structured-post-mortems&quot;&gt;Exporting Structured Post-Mortems&lt;/h4&gt;
&lt;p&gt;After analysis, the engine compiles a structured Markdown post-mortem document directly into the incident repository:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def export_markdown_report(analyses: list[RootCauseAnalysis], output_path: str = &quot;incident_report.md&quot;):
    with open(output_path, &quot;w&quot;) as f:
        f.write(&quot;# Automated Incident Triage &amp;amp; Root Cause Report\n\n&quot;)
        for item in analyses:
            f.write(f&quot;## Fault Signature: `{item.fault_signature_id}`\n&quot;)
            f.write(f&quot;- **Domain**: {item.failure_domain}\n&quot;)
            f.write(f&quot;- **Severity**: {item.severity_impact}\n\n&quot;)
            f.write(f&quot;### Root Cause\n{item.root_cause_summary}\n\n&quot;)
            f.write(f&quot;### Suggested Remediation\n```\n{item.remediation_patch}\n```\n\n---\n&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-conclusion-amp-ready-to-use-package&quot;&gt;5. Conclusion &amp;amp; Ready-to-Use Package&lt;/h3&gt;
&lt;p&gt;By combining DuckDB's in-process analytical speed with deterministic regex fingerprinting, you compress 100,000+ chaotic log entries into an actionable set of signatures, reducing LLM token consumption by over 98% while eliminating ingestion costs.&lt;/p&gt;
&lt;p&gt;You can implement this architecture using the code snippets above to build an in-house triage CLI for your engineering team.&lt;/p&gt;
&lt;p&gt;If you prefer a pre-built, production-tested CLI tool with automated format detection (Syslog, Docker JSON, Morgan, Winston), multi-threaded clustering, and enterprise Markdown/JSON post-mortem exporters, you can get the turnkey package here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Instant Access on Whop&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://whop.com/mr-studio-7faa/production-log-triage-root-cause-engine-python-duckdb-gemini&quot;&gt;Production Log Triage Engine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Direct Download on Gumroad&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://mannyverse767.gumroad.com/l/unhttt&quot;&gt;Gumroad Download&lt;/a&gt; (Use promo code &lt;strong&gt;&lt;code&gt;EARLYBIRD&lt;/code&gt;&lt;/strong&gt; for 20% off)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/30333/building-a-zero-telemetry-production-log-triage-engine-with-python-duckdb-and-gemini</guid>
<pubDate>Thu, 08 Oct 2026 18:06:58 +0000</pubDate>
</item>
<item>
<title>Building an Autonomous CI/CD Log Triage &amp; Self-Healing PR Engine with Python and DuckDB</title>
<link>https://coderlegion.com/30159/building-an-autonomous-ci-cd-log-triage-self-healing-pr-engine-with-python-and-duckdb</link>
<description>&lt;h2 id=&quot;building-an-autonomous-ci-cd-log-triage-amp-self-healing-pr-engine-with-python-and-duckdb&quot;&gt;Building an Autonomous CI/CD Log Triage &amp;amp; Self-Healing PR Engine with Python and DuckDB&lt;/h2&gt;
&lt;p&gt;Every growing engineering team hits the same wall: CI pipelines fail, developers ignore cryptic 200MB console outputs, and senior engineers spend 15+ hours every week parsing raw text to fix the same 4 recurring issues—broken lockfiles, misconfigured test fixtures, missing environment variables, or dependency drift.&lt;/p&gt;
&lt;p&gt;Observability vendors sell multi-thousand-dollar enterprise suites to ingest these logs, yet they stop at alerting. You still have to open the console, copy the stack trace, diagnose the problem, create a branch, write the fix, and submit a PR.&lt;/p&gt;
&lt;p&gt;In this guide, we'll build a zero-infrastructure, self-healing pipeline that runs natively inside your CI environment (GitHub Actions, GitLab CI, or n8n runner). It parses multi-gigabyte build artifacts using &lt;strong&gt;DuckDB&lt;/strong&gt;, clusters stack traces deterministically without bloated vector stores, prompts an LLM with compact context, and automatically submits self-healing Pull Requests via the GitHub REST API.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-bottleneck-why-traditional-log-triage-fails&quot;&gt;1. The Bottleneck: Why Traditional Log Triage Fails&lt;/h3&gt;
&lt;p&gt;Most CI log monitoring tools fail for three architectural reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Data Transfer Tax &amp;amp; Latency&lt;/strong&gt;: Pushing gigabytes of raw container logs over the wire to external aggregators creates latency and ballooning bandwidth bills.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector DB Overhead&lt;/strong&gt;: Vector embeddings for error logs are overkill. Logs are semi-structured text where 95% of lines are identical runtime noise. Embedding entire raw logs produces polluted semantic spaces that fail to isolate exact trace boundaries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lack of Actionability&lt;/strong&gt;: Alerting on Slack is not a resolution. If a failure stems from a deprecated dependency version or a missing mock key, the remediation step is deterministic.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By executing triage &lt;strong&gt;locally on the CI runner&lt;/strong&gt;, we eliminate network egress, process logs in memory via columnar storage, and resolve routine failures without human intervention.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-system-architecture&quot;&gt;2. System Architecture&lt;/h3&gt;
&lt;p&gt;The engine follows a strict four-stage pipeline running directly on your CI execution container:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ Raw CI Log Files (*.log, *.json) ]
              │
              ▼
┌───────────────────────────────┐
│ DuckDB High-Throughput Ingest │ ──► Columnar filtering of timestamps,
│ &amp;amp; Regex Pattern Clustering    │     log levels, and stack frames
└──────────────┬────────────────┘
              │
              ▼
┌───────────────────────────────┐
│ Deterministic Triage Engine   │ ──► Isolates exact stack trace and
│ (Root-Cause Classifier)       │     queries target source file
└──────────────┬────────────────┘
              │
              ▼
┌───────────────────────────────┐
│ Structured LLM Patch Synthesizer │ ──► Generates unified diff
└──────────────┬────────────────┘
              │
              ▼
[ GitHub REST API: Creates Git Branch + Opens Self-Healing PR ]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DuckDB&lt;/strong&gt;: Reads gigabyte-scale logs directly from disk or gzipped archives using zero-copy SQL queries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pattern Clustering&lt;/strong&gt;: Groups repeated log lines using tokenized signature matching.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Autonomous Git Engine&lt;/strong&gt;: Validates the proposed diff locally, branches out, commits the patch, and triggers a PR.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-code-amp-implementation-1&quot;&gt;3. The Code &amp;amp; Implementation&lt;/h3&gt;
&lt;h4 id=&quot;step-1-sub-second-log-parsing-with-duckdb&quot;&gt;Step 1: Sub-Second Log Parsing with DuckDB&lt;/h4&gt;
&lt;p&gt;Instead of loading logs line-by-line in pure Python, we leverage DuckDB's native regex parsing engine to query the raw log file as a relational table.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import duckdb
import os

def extract_critical_failures(log_path: str) -&amp;gt; list[dict]:
    &quot;&quot;&quot;
    Parses uncompressed or gzipped logs and extracts error frames
    without loading the entire file into Python memory.
    &quot;&quot;&quot;
    con = duckdb.connect(database=&quot;:memory:&quot;)
    
    query = &quot;&quot;&quot;
    WITH raw_lines AS (
        SELECT 
            row_number() OVER () AS line_num,
            column0 AS content
        FROM read_csv(?, header=False, sep='\n', auto_detect=False)
    ),
    filtered_errors AS (
        SELECT 
            line_num, 
            content,
            regexp_extract(content, '(?i)(error|fatal|exception|failed):?\s*(.*)', 2) AS error_msg
        FROM raw_lines
        WHERE regexp_matches(content, '(?i)(ERROR|FATAL|Exception|FAILED)')
    )
    SELECT 
        line_num, 
        content, 
        error_msg
    FROM filtered_errors
    LIMIT 25;
    &quot;&quot;&quot;
    
    results = con.execute(query, [log_path]).fetchall()
    con.close()
    
    return [{&quot;line&quot;: row[0], &quot;raw&quot;: row[1], &quot;error&quot;: row[2]} for row in results]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-extracting-context-around-the-root-failure&quot;&gt;Step 2: Extracting Context Around the Root Failure&lt;/h4&gt;
&lt;p&gt;Once DuckDB pinpoints the fatal line number, we extract the local stack trace window and the affected source code file to pass to our LLM generator.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import re
from pathlib import Path

def isolate_trace_context(log_path: str, error_line: int, window: int = 15) -&amp;gt; str:
    &quot;&quot;&quot;Extracts preceding and succeeding context around an error line.&quot;&quot;&quot;
    start = max(1, error_line - window)
    end = error_line + window
    
    con = duckdb.connect(database=&quot;:memory:&quot;)
    query = &quot;&quot;&quot;
    SELECT list(column0)
    FROM (
        SELECT row_number() OVER () AS rn, column0
        FROM read_csv(?, header=False, sep='\n', auto_detect=False)
    )
    WHERE rn BETWEEN ? AND ?;
    &quot;&quot;&quot;
    lines = con.execute(query, [log_path, start, end]).fetchone()[0]
    con.close()
    return &quot;\n&quot;.join(lines) if lines else &quot;&quot;

def locate_offending_file(trace_text: str) -&amp;gt; tuple[str, str]:
    &quot;&quot;&quot;Locates local source code file and extracts code content.&quot;&quot;&quot;
    file_match = re.search(r'File &quot;([a-zA-Z0-9_\-\./]+\.py)&quot;, line (\d+)', trace_text)
    if not file_match:
        return &quot;&quot;, &quot;&quot;
    
    file_path, line_no = file_match.group(1), int(file_match.group(2))
    if Path(file_path).exists():
        return file_path, Path(file_path).read_text()
    return &quot;&quot;, &quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-3-llm-patch-synthesizer-amp-github-pr-automation&quot;&gt;Step 3: LLM Patch Synthesizer &amp;amp; GitHub PR Automation&lt;/h4&gt;
&lt;p&gt;We feed the trace and target source code into a structured LLM prompt that enforces raw unified diff output, then push the patch directly through the GitHub API.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import json
import urllib.request
import base64

def generate_patch_with_llm(trace: str, source_code: str, file_path: str, api_key: str) -&amp;gt; str:
    &quot;&quot;&quot;Generates a strict unified diff to fix the identified CI breakage.&quot;&quot;&quot;
    system_prompt = (
        &quot;You are an automated CI remediation agent. Analyze the provided test failure &quot;
        &quot;and source code. Output ONLY a valid unified diff (git patch) that fixes the failure. &quot;
        &quot;Do not output markdown code blocks, explanations, or commentary.&quot;
    )
    user_prompt = f&quot;TARGET FILE: {file_path}\n\nSTACK TRACE:\n{trace}\n\nSOURCE CODE:\n{source_code}&quot;
    
    payload = json.dumps({
        &quot;model&quot;: &quot;gpt-4o-mini&quot;,
        &quot;messages&quot;: [
            {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: system_prompt},
            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_prompt}
        ],
        &quot;temperature&quot;: 0.1
    }).encode(&quot;utf-8&quot;)
    
    req = urllib.request.Request(
        &quot;https://api.openai.com/v1/chat/completions&quot;,
        data=payload,
        headers={&quot;Authorization&quot;: f&quot;Bearer {api_key}&quot;, &quot;Content-Type&quot;: &quot;application/json&quot;}
    )
    
    with urllib.request.urlopen(req) as resp:
        data = json.loads(resp.read().decode(&quot;utf-8&quot;))
        return data[&quot;choices&quot;][0][&quot;message&quot;][&quot;content&quot;].strip()

def create_self_healing_pr(repo: str, branch: str, file_path: str, updated_content: str, token: str):
    &quot;&quot;&quot;Applies remediation by opening a Git branch and PR via GitHub REST API.&quot;&quot;&quot;
    headers = {
        &quot;Authorization&quot;: f&quot;Bearer {token}&quot;,
        &quot;Accept&quot;: &quot;application/vnd.github.v3+json&quot;
    }
    base_url = f&quot;https://api.github.com/repos/{repo}&quot;
    
    # 1. Fetch default branch SHA
    req = urllib.request.Request(f&quot;{base_url}/git/ref/heads/main&quot;, headers=headers)
    with urllib.request.urlopen(req) as resp:
        sha = json.loads(resp.read().decode())[&quot;object&quot;][&quot;sha&quot;]
        
    # 2. Create target branch
    branch_payload = json.dumps({&quot;ref&quot;: f&quot;refs/heads/{branch}&quot;, &quot;sha&quot;: sha}).encode()
    req = urllib.request.Request(f&quot;{base_url}/git/refs&quot;, data=branch_payload, headers=headers)
    urllib.request.urlopen(req)
    
    # 3. Fetch file SHA
    req = urllib.request.Request(f&quot;{base_url}/contents/{file_path}?ref={branch}&quot;, headers=headers)
    with urllib.request.urlopen(req) as resp:
        file_sha = json.loads(resp.read().decode())[&quot;sha&quot;]
        
    # 4. Commit updated file
    encoded_content = base64.b64encode(updated_content.encode()).decode()
    commit_payload = json.dumps({
        &quot;message&quot;: &quot;fix(ci): autonomous self-healing patch for test failure&quot;,
        &quot;content&quot;: encoded_content,
        &quot;sha&quot;: file_sha,
        &quot;branch&quot;: branch
    }).encode()
    req = urllib.request.Request(f&quot;{base_url}/contents/{file_path}&quot;, data=commit_payload, headers=headers, method=&quot;PUT&quot;)
    urllib.request.urlopen(req)
    
    # 5. Open Pull Request
    pr_payload = json.dumps({
        &quot;title&quot;: &quot;🤖 [Self-Healing] Fix CI Failure Regression&quot;,
        &quot;head&quot;: branch,
        &quot;base&quot;: &quot;main&quot;,
        &quot;body&quot;: &quot;This Pull Request was automatically generated by the Autonomous CI Log Triage Engine after detecting deterministic failures in the pipeline.&quot;
    }).encode()
    req = urllib.request.Request(f&quot;{base_url}/pulls&quot;, data=pr_payload, headers=headers)
    urllib.request.urlopen(req)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-deployment-timeouts-and-rate-limits&quot;&gt;4. Deployment, Timeouts, and Rate Limits&lt;/h3&gt;
&lt;p&gt;To run this in production without adding latency to green runs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Conditional Triggers&lt;/strong&gt;: Only execute the triage step on &lt;code&gt;if: failure()&lt;/code&gt; in GitHub Actions or &lt;code&gt;when: on_failure&lt;/code&gt; in GitLab CI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run-Time Budgets&lt;/strong&gt;: Restrict the DuckDB scan window to the last 5,000 lines. DuckDB handles this in under 200ms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM Cost Guardrails&lt;/strong&gt;: Strip ANSI escape codes (&lt;code&gt;\x1b\[[0-9;]*m&lt;/code&gt;) before sending payloads to LLMs to prevent token inflation. Truncate context to include only the stack trace boundary and the active function block.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prevent PR Flooding&lt;/strong&gt;: Cache the hash of the error signature (&lt;code&gt;MD5(trace_text)&lt;/code&gt;) in your build cache. If an identical PR is already pending, skip recreation.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-conclusion-amp-ready-to-use-workflow&quot;&gt;5. Conclusion &amp;amp; Ready-to-Use Workflow&lt;/h3&gt;
&lt;p&gt;You can implement this architecture using the code snippets above or integrate it into an automated n8n webhook listener. By replacing third-party SaaS log aggregators with embedded DuckDB queries, you gain deterministic triage while keeping logs completely inside your security perimeter.&lt;/p&gt;
&lt;p&gt;If you prefer a plug-and-play solution, you can get the complete turnkey package containing production GitHub Action workflows, Docker container configurations, synthetic log test fixtures, and pre-built n8n triage workflows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://whop.com/mr-studio-7faa/autonomous-ci-cd-log-triage-self-healing-pr-engine-python-duckdb&quot;&gt;Instant Access on Whop&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://mannyverse767.gumroad.com/l/skxhf&quot;&gt;Direct Download on Gumroad&lt;/a&gt;&lt;/strong&gt; (Use promo code &lt;strong&gt;EARLYBIRD&lt;/strong&gt; for 20% off)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/30159/building-an-autonomous-ci-cd-log-triage-self-healing-pr-engine-with-python-and-duckdb</guid>
<pubDate>Wed, 07 Oct 2026 17:14:08 +0000</pubDate>
</item>
<item>
<title>I added DuckDB to my SQL playground, and it turned out to be a sandboxing problem</title>
<link>https://coderlegion.com/24989/i-added-duckdb-to-my-sql-playground-and-it-turned-out-to-be-a-sandboxing-problem</link>
<description>&lt;h2 id=&quot;i-added-duckdb-to-my-sql-playground-and-it-turned-out-to-be-a-sandboxing-problem&quot;&gt;I added DuckDB to my SQL playground, and it turned out to be a sandboxing problem&lt;/h2&gt;
&lt;p&gt;Sharing a small build-in-public update: &lt;a rel=&quot;nofollow&quot; href=&quot;https://sqlize.online&quot;&gt;sqlize.online&lt;/a&gt;, a free tool for testing SQL queries, checking JOINs, and sharing reproducible examples, just got DuckDB support alongside the ~30 engines it already had (MySQL, Postgres, SQL Server, Oracle, Firebird, ClickHouse, SQLite...).&lt;/p&gt;
&lt;p&gt;I'd wanted to add DuckDB for a while — I use it a lot in my own projects and like having local OLAP around. The holdup was boring: the whole thing runs on PHP, and PHP's PDO talks to MySQL, Postgres, and SQLite out of the box, but had nothing for DuckDB.&lt;/p&gt;
&lt;p&gt;That changed when I found &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/pdo_duckdb&quot;&gt;pdo_duckdb&lt;/a&gt; by Ilia Alshanetsky — a real PDO driver for it. That's the piece that made this worth doing.&lt;/p&gt;
&lt;h3 id=&quot;why-duckdb-specifically&quot;&gt;Why DuckDB specifically&lt;/h3&gt;
&lt;p&gt;Short version: it's &quot;SQLite for analytics.&quot; Vectorized engine, built for heavy aggregations on a single core, not for transactions.&lt;/p&gt;
&lt;p&gt;What actually got me was that it reads Parquet on the fly, from disk or over HTTP, with no separate load step. For a playground, that's great — instead of a two-row toy table, you can hand people something real. So I preloaded NYC's Yellow Taxi trip data for January 2024, about three million rows, sitting in a table called &lt;code&gt;yellow_tripdata&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    PULocationID,
    COUNT(*) AS total_trips,
    ROUND(AVG(total_amount), 2) AS avg_fare
FROM yellow_tripdata
GROUP BY PULocationID
ORDER BY total_trips DESC
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's a table, not a file path — more on why that matters below.&lt;/p&gt;
&lt;h3 id=&quot;building-the-driver-plus-an-unrelated-debian-surprise&quot;&gt;Building the driver, plus an unrelated Debian surprise&lt;/h3&gt;
&lt;p&gt;pdo_duckdb needs PHP 8.1+. My setup runs several PHP versions side by side for different engines, so that already cuts out a couple of pools. Worse, the latest release only ships prebuilt binaries for PHP 8.4/8.5 — on 8.1, where this feature currently lives, I had to build from source: &lt;code&gt;phpize &amp;amp;&amp;amp; ./configure --with-pdo-duckdb=... &amp;amp;&amp;amp; make install&lt;/code&gt;, linked against &lt;code&gt;libduckdb.so&lt;/code&gt;. Not exotic, but not a one-command install either — about half the engines in this project get built by hand at this point.&lt;/p&gt;
&lt;p&gt;While I was at it I hit a completely unrelated bug: bumped that PHP pool to the latest patch release, and the image build broke for no obvious reason. Turned out the base image had quietly moved from Debian bookworm to trixie, which renamed &lt;code&gt;libaio1&lt;/code&gt; to &lt;code&gt;libaio1t64&lt;/code&gt;. Rolled the base image back. Cost me about forty minutes of confusion for something that had nothing to do with DuckDB at all.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-problem-duckdb-runs-inside-the-php-process&quot;&gt;The actual problem: DuckDB runs inside the PHP process&lt;/h3&gt;
&lt;p&gt;MySQL and Postgres live in their own containers and can't see the PHP worker's filesystem at all. DuckDB is embedded — it opens up right inside PHP-FPM. Good for speed, no sockets or IPC in the way. Bad because by default it can reach anything the process can reach: &lt;code&gt;COPY ... TO '/etc/whatever'&lt;/code&gt;, &lt;code&gt;read_csv&lt;/code&gt; from any path, &lt;code&gt;read_parquet&lt;/code&gt; from any URL, &lt;code&gt;INSTALL&lt;/code&gt; for arbitrary extensions.&lt;/p&gt;
&lt;p&gt;Not something you want available to arbitrary user SQL.&lt;/p&gt;
&lt;p&gt;First attempt was the obvious one — set it in SQL directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SET enable_external_access = false;
SET allow_community_extensions = false;
SET lock_configuration = true;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This genuinely works, and &lt;code&gt;lock_configuration = true&lt;/code&gt; really does stop the same session from undoing it later. But the driver has its own separate protection: it flatly refuses a chunk of security-sensitive settings — paths, extension autoload — when they're passed via the DSN or &lt;code&gt;PDO::DUCKDB_ATTR_CONFIG&lt;/code&gt; at connect time. On purpose, so the app using the driver can't accidentally weaken its own config.&lt;/p&gt;
&lt;p&gt;The real mechanism turned out to be PHP's own &lt;code&gt;open_basedir&lt;/code&gt;. Set it — even at runtime, with &lt;code&gt;ini_set()&lt;/code&gt; — and the driver shuts off DuckDB's entire external-access surface: &lt;code&gt;read_csv&lt;/code&gt;, &lt;code&gt;read_parquet&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;ATTACH&lt;/code&gt;, &lt;code&gt;httpfs&lt;/code&gt;, &lt;code&gt;INSTALL&lt;/code&gt;, all of it, no matter which path gets requested. Regular &lt;code&gt;CREATE TABLE&lt;/code&gt;/&lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;SELECT&lt;/code&gt; against the session's own file keep working fine.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;ini_set('open_basedir', '/code:/tmp/sessions:/tmp/databases');
$pdo = new PDO(&quot;duckdb:{$sessionFile}&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I tried stacking both — &lt;code&gt;open_basedir&lt;/code&gt; plus the &lt;code&gt;SET&lt;/code&gt; statements on top, just for extra safety. Didn't work: once &lt;code&gt;open_basedir&lt;/code&gt; is active, the driver rejects &lt;code&gt;allow_community_extensions&lt;/code&gt; outright (&quot;not allowed when open_basedir is set&quot;) and the connection breaks. Had to pick one. Kept &lt;code&gt;open_basedir&lt;/code&gt; — it covers everything on its own.&lt;/p&gt;
&lt;p&gt;And yes, there was a bug. I was applying &lt;code&gt;open_basedir&lt;/code&gt; right before running a query, but not before handing a raw PDO connection to user-written PHP code — which happens when someone runs PHP and SQL together and picks DuckDB as the engine. So: submit a PHP script with no SQL in the separate field, get back a completely unrestricted &lt;code&gt;$pdo&lt;/code&gt;, and &lt;code&gt;$pdo-&amp;gt;exec(&quot;COPY (SELECT 1) TO '/tmp/whatever.csv'&quot;)&lt;/code&gt; just works. Caught it myself before it shipped anywhere public.&lt;/p&gt;
&lt;h3 id=&quot;why-the-dataset-isn-t-loaded-on-every-request&quot;&gt;Why the dataset isn't loaded on every request&lt;/h3&gt;
&lt;p&gt;Three million rows over HTTPS on every session would be brutal — a cold build takes anywhere from 5 to 35 seconds. So it's built once at deploy time, by a separate script using a plain connection with no &lt;code&gt;open_basedir&lt;/code&gt; set (downloading the file there is a legitimate job for that script, not for user SQL), and cached to a shared file. Every session after that is just a &lt;code&gt;copy()&lt;/code&gt; — a fraction of a second.&lt;/p&gt;
&lt;h3 id=&quot;try-to-break-it&quot;&gt;Try to break it&lt;/h3&gt;
&lt;p&gt;If you find a way to read a server file or get around &lt;code&gt;open_basedir&lt;/code&gt;, I'd rather hear about it directly than see the service go down. No bug bounty budget, but I'll credit you publicly if you find something real.&lt;/p&gt;
&lt;p&gt;Try it here: &lt;a rel=&quot;nofollow&quot; href=&quot;https://sqlize.online&quot;&gt;sqlize.online&lt;/a&gt; — pick &quot;DuckDB&quot; or &quot;DuckDB Preloaded&quot; as the SQL version. Happy to answer questions about how any of this is built.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24989/i-added-duckdb-to-my-sql-playground-and-it-turned-out-to-be-a-sandboxing-problem</guid>
<pubDate>Thu, 20 Aug 2026 12:38:29 +0000</pubDate>
</item>
<item>
<title>pdo_duckdb: DuckDB for PHP, Behind the PDO API You Already Know</title>
<link>https://coderlegion.com/20990/pdoduckdb-duckdb-for-php-behind-the-pdo-api-you-already-know</link>
<description>&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://duckdb.org/&quot;&gt;DuckDB&lt;/a&gt; is the closest thing the analytics world has to SQLite. It runs in-process, needs no server, reads and writes a single file, and chews through columnar aggregate queries that would make a row-store sweat. PHP has shipped &lt;code&gt;PDO_SQLite&lt;/code&gt; in core for twenty years. Until now it had no equivalent for DuckDB.&lt;/p&gt;
&lt;p&gt;So I wrote one. &lt;code&gt;pdo_duckdb&lt;/code&gt; is a native PDO driver. You open a DuckDB database with a DSN, prepare statements, bind parameters, and iterate results through the same &lt;code&gt;PDO&lt;/code&gt; API you already use for SQLite, MySQL, and PostgreSQL.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$db = new PDO('duckdb:/path/to/analytics.duckdb');
$db-&amp;gt;setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$stmt = $db-&amp;gt;prepare('SELECT region, SUM(amount) AS total FROM sales WHERE year = ? GROUP BY region');
$stmt-&amp;gt;execute([2026]);
foreach ($stmt as $row) {
    printf(&quot;%s: %s\n&quot;, $row['region'], $row['total']);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is also the first PDO driver I've written in over fifteen years. I was one of the original authors of PDO back in the PHP 5.1 days, so coming back to that extension surface was equal parts muscle memory and culture shock.&lt;/p&gt;
&lt;h3 id=&quot;why-a-pdo-driver-and-not-another-ffi-binding&quot;&gt;Why a PDO Driver and Not Another FFI Binding&lt;/h3&gt;
&lt;p&gt;You can already reach DuckDB from PHP today. It has a C API you can drive through FFI, and there's always shelling out to the CLI. Both work. Both also hand you a brand-new API surface, manual lifetime management, and a data layer that looks nothing like the rest of your application.&lt;/p&gt;
&lt;p&gt;PDO is the abstraction PHP developers already have in their fingers. A PDO driver means prepared statements, parameter binding, the exception error mode, fetch modes, and transactions all behave the way they do for every other PDO backend. Your repository classes and query builders transfer unchanged. The driver is written in C against DuckDB's C API, so there is no FFI boundary at runtime and no per-call marshalling happening up in PHP.&lt;/p&gt;
&lt;p&gt;To be fair to FFI, it has its place. If you need a DuckDB capability the PDO surface doesn't expose, FFI hands you the entire C API and gets out of the way. pdo_duckdb makes the opposite trade on purpose: less breadth, in exchange for fitting into code you've already written.&lt;/p&gt;
&lt;h3 id=&quot;duckdb-in-one-paragraph-if-you-haven-t-met-it&quot;&gt;DuckDB in One Paragraph, If You Haven't Met It&lt;/h3&gt;
&lt;p&gt;DuckDB is an in-process analytical (OLAP) database. The relationship to ClickHouse or Snowflake is roughly what SQLite is to Postgres: same SQL you know, no server to run, embedded directly in your process. It is columnar and vectorized, built for scans and aggregations over a lot of rows rather than single-row OLTP traffic. The DSN tells the driver which database to open:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;duckdb:/path/to/database.duckdb   # file-backed database
duckdb::memory:                   # in-memory database
duckdb:                           # in-memory database (empty path)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An in-memory database is scratch space that vanishes when the connection closes; a file-backed one persists to disk. DuckDB's own extensions load through ordinary SQL, with no special driver API to learn:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$db-&amp;gt;exec('LOAD json');                     // bundled extensions load offline
$db-&amp;gt;exec('INSTALL httpfs; LOAD httpfs;');  // downloadable extensions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reading Parquet, querying a CSV in place, or pulling a file over HTTP is a &lt;code&gt;LOAD&lt;/code&gt; away, all through the same &lt;code&gt;$db-&amp;gt;exec()&lt;/code&gt; you would use for any statement. The driver doesn't wrap or re-expose any of it. DuckDB's SQL surface is the API.&lt;/p&gt;
&lt;h3 id=&quot;bulk-loading-the-appender&quot;&gt;Bulk Loading: The Appender&lt;/h3&gt;
&lt;p&gt;Row-by-row &lt;code&gt;INSERT&lt;/code&gt; is the wrong way to push a lot of data into a columnar store, and DuckDB knows it. The driver exposes DuckDB's native appender through &lt;code&gt;PDO::duckdbAppender()&lt;/code&gt;, which streams rows into a table far faster than a loop of prepared &lt;code&gt;INSERT&lt;/code&gt; statements.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$db-&amp;gt;exec('CREATE TABLE events (id INTEGER, name VARCHAR, ts TIMESTAMP)');

$app = $db-&amp;gt;duckdbAppender('events');      // optional 2nd arg: schema name
foreach ($rows as $r) {
    $app-&amp;gt;appendRow($r['id'], $r['name'], $r['ts']);
}
$app-&amp;gt;flush();                              // or $app-&amp;gt;close() to finalize
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;appendRow(...$values)&lt;/code&gt; takes one argument per column, left to right, and returns the appender so calls can chain. PHP &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt;, and &lt;code&gt;string&lt;/code&gt; map to DuckDB values and get cast to the target column types. This is the one place the driver steps outside the standard PDO surface, and it earns the exception. Bulk ingestion is exactly where the row-at-a-time PDO model leaves the most performance on the table, so it's the one spot worth a driver-specific method.&lt;/p&gt;
&lt;h3 id=&quot;the-parts-of-pdo-that-don-t-map-cleanly&quot;&gt;The Parts of PDO That Don't Map Cleanly&lt;/h3&gt;
&lt;p&gt;This is the honest section. DuckDB is not MySQL, and a faithful driver has to refuse a few things rather than fake them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lastInsertId()&lt;/code&gt; is not supported. DuckDB has no implicit rowid, so there is nothing honest to return. If you need generated keys, use a sequence and &lt;code&gt;currval()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;setAttribute(PDO::ATTR_AUTOCOMMIT, false)&lt;/code&gt; is rejected. DuckDB is autocommit-by-default with no session-level toggle, so instead of pretending otherwise, the driver points you at &lt;code&gt;beginTransaction()&lt;/code&gt; for explicit transactions. From there &lt;code&gt;commit()&lt;/code&gt; and &lt;code&gt;rollBack()&lt;/code&gt; map onto DuckDB's &lt;code&gt;COMMIT&lt;/code&gt; and &lt;code&gt;ROLLBACK&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Placeholders get rewritten. Positional &lt;code&gt;?&lt;/code&gt; and named &lt;code&gt;:name&lt;/code&gt; parameters are both supported, and PDO rewrites them to DuckDB's native &lt;code&gt;$N&lt;/code&gt; form. That creates one sharp edge: because &lt;code&gt;:&lt;/code&gt; introduces a named placeholder, an inline &lt;code&gt;STRUCT&lt;/code&gt; or &lt;code&gt;MAP&lt;/code&gt; literal inside a prepared query needs a space after the colon. Write &lt;code&gt;{'k': 1}&lt;/code&gt;, not &lt;code&gt;{'k':1}&lt;/code&gt;, or the parser reads &lt;code&gt;:1}&lt;/code&gt; as a parameter and gets confused.&lt;/p&gt;
&lt;p&gt;Types come back predictably, with one rule worth internalizing. Integers up to 64-bit signed arrive as PHP &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;FLOAT&lt;/code&gt; and &lt;code&gt;DOUBLE&lt;/code&gt; as &lt;code&gt;float&lt;/code&gt;, and &lt;code&gt;BLOB&lt;/code&gt; as a binary string. Everything else, &lt;code&gt;VARCHAR&lt;/code&gt;, the &lt;code&gt;DATE&lt;/code&gt; and &lt;code&gt;TIME&lt;/code&gt; and &lt;code&gt;TIMESTAMP&lt;/code&gt; family, &lt;code&gt;DECIMAL&lt;/code&gt;, the wide &lt;code&gt;HUGEINT&lt;/code&gt; and &lt;code&gt;UBIGINT&lt;/code&gt; integers, and DuckDB's nested types, comes back in its canonical string form. That keeps values lossless when they wouldn't fit a native PHP scalar, at the cost of a cast on your side when you want a number out of a &lt;code&gt;HUGEINT&lt;/code&gt; column.&lt;/p&gt;
&lt;p&gt;Results are materialized, for now. &lt;code&gt;execute()&lt;/code&gt; buffers the full result set in memory before PDO begins fetching. For the analytical queries DuckDB is built for that is usually fine, but a &lt;code&gt;SELECT&lt;/code&gt; that returns millions of rows is bounded by available memory rather than streamed row by row. True streaming through DuckDB's pending-result API is the next thing on the list.&lt;/p&gt;
&lt;p&gt;One behavior goes the other way, in your favor. When &lt;code&gt;open_basedir&lt;/code&gt; is set, the driver disables DuckDB's SQL-level external file access: &lt;code&gt;read_csv&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;ATTACH&lt;/code&gt;, &lt;code&gt;httpfs&lt;/code&gt;, and the rest. Without that, a single &lt;code&gt;COPY ... TO '/somewhere/outside'&lt;/code&gt; would punch straight through the sandbox &lt;code&gt;open_basedir&lt;/code&gt; is supposed to enforce. The restriction holds at the SQL layer, not just for the path of the database file itself.&lt;/p&gt;
&lt;h3 id=&quot;what-writing-a-pdo-driver-looks-like-in-2026&quot;&gt;What Writing a PDO Driver Looks Like in 2026&lt;/h3&gt;
&lt;p&gt;The PDO driver model itself has barely moved since I last touched it. You fill in a handful of method tables, hang your connection state off the driver object, and PDO drives the rest. What changed is everything around it.&lt;/p&gt;
&lt;p&gt;On PHP 8.4 and up, &lt;code&gt;PDO::connect('duckdb:...')&lt;/code&gt; returns a &lt;code&gt;Pdo\Duckdb&lt;/code&gt; subclass instance, and &lt;code&gt;duckdbAppender()&lt;/code&gt; lives on that subclass. With &lt;code&gt;new PDO('duckdb:...')&lt;/code&gt;, and on PHP 8.1 through 8.3, the method is reachable on the base object directly. PHP 8.5 starts emitting a deprecation when you call a driver-specific method on the base &lt;code&gt;PDO&lt;/code&gt; class, so on 8.4 and newer you'll want &lt;code&gt;PDO::connect()&lt;/code&gt;. The driver supports both paths; the subclass is just the cleaner one going forward.&lt;/p&gt;
&lt;p&gt;The part that actually cost me a release was the link line. DuckDB ships as a C++ library with a C API bolted on top. The driver is C. The prebuilt Linux binary in 0.2.0 loaded fine on my machine and then died on a clean host with &lt;code&gt;undefined symbol: _ZTVN10__cxxabiv120__function_type_infoE&lt;/code&gt;, the vtable for a C++ ABI type that was supposed to be baked into the binary. The bundled DuckDB C++ runtime was never getting statically linked, because the gcc C driver silently ignores &lt;code&gt;-static-libstdc++&lt;/code&gt;, a flag it treats as g++-only. So the module quietly depended on a libstdc++ the target box didn't ship. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/pdo_duckdb/releases/tag/0.2.1&quot;&gt;0.2.1&lt;/a&gt; links the static &lt;code&gt;libstdc++&lt;/code&gt; and &lt;code&gt;libgcc_eh&lt;/code&gt; in explicitly, and the Linux binary is genuinely self-contained now. macOS and Windows were never affected, which is its own special way to lose an afternoon chasing the wrong platform.&lt;/p&gt;
&lt;p&gt;Distribution is the other thing that improved while I was away. On Linux x86_64 and arm64, macOS on Apple Silicon, and Windows x64, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/php/pie&quot;&gt;PIE&lt;/a&gt; downloads a self-contained prebuilt binary with no DuckDB install and no build toolchain required:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;pie install iliaal/pdo_duckdb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On anything else it falls back to a source build that needs &lt;code&gt;libduckdb&lt;/code&gt; and &lt;code&gt;duckdb.h&lt;/code&gt;. The last time I shipped a PDO driver, this would have been a PECL package and a paragraph of &lt;code&gt;phpize&lt;/code&gt; instructions for the reader to follow. Prebuilt binaries as the default install path is the single biggest quality-of-life change in PHP extension distribution since I started writing them.&lt;/p&gt;
&lt;h3 id=&quot;where-it-stands&quot;&gt;Where It Stands&lt;/h3&gt;
&lt;p&gt;pdo_duckdb is an early release, and I'd rather say so than oversell it. The query path, the appender, parameter binding, transactions, and type decoding all work and are covered by tests. Streaming is the main thing still on the bench, and the type-mapping surface will keep growing as people push real workloads through it. I haven't published benchmarks, because the speed here is DuckDB's and the driver's job is to stay out of its way.&lt;/p&gt;
&lt;p&gt;If you've wanted DuckDB's analytical engine reachable from PHP without learning a new client API or hand-rolling FFI, this is that, sitting behind the PDO surface you already know.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;pie install iliaal/pdo_duckdb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/pdo_duckdb&quot;&gt;github.com/iliaal/pdo_duckdb&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20990/pdoduckdb-duckdb-for-php-behind-the-pdo-api-you-already-know</guid>
<pubDate>Fri, 19 Jun 2026 18:52:42 +0000</pubDate>
</item>
</channel>
</rss>