<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Melih Birim</title>
    <description>The latest articles on DEV Community by Melih Birim (@melih_birim_8363f43966da2).</description>
    <link>https://dev.to/melih_birim_8363f43966da2</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4054380%2Fbcc4e0c4-4c9a-4a88-bbca-b10273fd4709.jpg</url>
      <title>DEV Community: Melih Birim</title>
      <link>https://dev.to/melih_birim_8363f43966da2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/melih_birim_8363f43966da2"/>
    <language>en</language>
    <item>
      <title>I Benchmarked My CSV Engine Against DuckDB on Its Own Dataset. It Found Two Bugs in Mine.</title>
      <dc:creator>Melih Birim</dc:creator>
      <pubDate>Thu, 30 Jul 2026 07:03:05 +0000</pubDate>
      <link>https://dev.to/melih_birim_8363f43966da2/i-benchmarked-my-csv-engine-against-duckdb-on-its-own-dataset-it-found-two-bugs-in-mine-e3e</link>
      <guid>https://dev.to/melih_birim_8363f43966da2/i-benchmarked-my-csv-engine-against-duckdb-on-its-own-dataset-it-found-two-bugs-in-mine-e3e</guid>
      <description>&lt;p&gt;&lt;em&gt;csvql queries raw CSV ~2.8x faster than DuckDB on an 8 GB file, using ~6x less memory and zero extra disk — and the benchmark that proved it also surfaced two real bugs I'd never have found otherwise.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: query the same raw CSV, on the same machine
&lt;/h2&gt;

&lt;p&gt;DuckDB publishes &lt;a href="https://duckdb.org/2024/10/16/driving-csv-performance-benchmarking-duckdb-with-the-nyc-taxi-dataset" rel="noopener noreferrer"&gt;its own NYC-taxi CSV benchmark&lt;/a&gt;. That makes it the perfect yardstick: I can't be accused of misconfiguring the competitor when I'm running the competitor's own dataset with the competitor's own queries.&lt;/p&gt;

&lt;p&gt;The dataset lives on DuckDB's own blob storage (&lt;code&gt;blobs.duckdb.org/data/nyc-taxi-dataset&lt;/code&gt;). Each file is 20 million rows, 51 columns, ~8 GB uncompressed. The four queries are the canonical &lt;a href="https://github.com/pdet/taxi-benchmark" rel="noopener noreferrer"&gt;"Billion Taxi Rides"&lt;/a&gt; aggregations — &lt;code&gt;GROUP BY&lt;/code&gt; counts and averages over cab type, passenger count, year, and rounded trip distance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one rule that makes or breaks a benchmark like this:&lt;/strong&gt; both engines must do the &lt;em&gt;same work&lt;/em&gt;. DuckDB's headline numbers come in two flavors — "with storage" (after loading the CSV into DuckDB's native columnar format) and "without storage" (querying the raw CSV each time). &lt;a href="https://github.com/melihbirim/csvql" rel="noopener noreferrer"&gt;csvql&lt;/a&gt; always queries raw CSV directly; it has no native store. So the fair fight is &lt;strong&gt;both engines reading the raw CSV, cold, every run&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;csvql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;csvql&lt;/span&gt; &lt;span class="nv"&gt;"SELECT ... FROM 'trips.csv' ..."&lt;/span&gt;
&lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;read_csv_auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trips.csv'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;   &lt;span class="c1"&gt;-- NOT a preloaded table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Comparing csvql-on-raw-CSV against DuckDB-on-preloaded-store would be dishonest, and anyone who opened the script would say so. Direct vs direct is the only comparison that survives scrutiny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Machine:&lt;/strong&gt; Apple Silicon, macOS. &lt;strong&gt;DuckDB:&lt;/strong&gt; v1.4.2. &lt;strong&gt;Method:&lt;/strong&gt; best-of-5 runs per query, warm OS page cache (both engines equally).&lt;/p&gt;




&lt;h2&gt;
  
  
  Speed: ~2.8x on 8 GB
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;csvql&lt;/th&gt;
&lt;th&gt;DuckDB&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q01 &lt;code&gt;COUNT(*) GROUP BY cab_type&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.29 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.55 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.8x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q02 &lt;code&gt;AVG(total_amount) GROUP BY passenger_count&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.41 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.81 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.7x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q03 &lt;code&gt;COUNT(*) GROUP BY passenger_count, year&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.36 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.01 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q04 &lt;code&gt;GROUP BY passenger_count, year, ROUND(distance)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.38 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.99 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Query latency, lower is better:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        0s        1s        2s        3s        4s
Q01 csvql  ██████▌ 1.29
    duck   ██████████████████ 3.55
Q02 csvql  ███████ 1.41
    duck   ███████████████████ 3.81
Q03 csvql  ██████▊ 1.36
    duck   ████████████████████ 4.01
Q04 csvql  ██████▉ 1.38
    duck   ████████████████████ 3.99
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the counterintuitive part: &lt;strong&gt;the gap shrinks as files get bigger.&lt;/strong&gt; On a 417 MB / 1M-row sample, csvql is ~10x faster. On 8 GB, ~2.8x. Why? On small files DuckDB's process startup and CSV-reader initialization dominate the wall clock, and csvql's lean startup wins big. On large files the actual parse-and-aggregate throughput dominates, DuckDB's parallel CSV reader catches up, and csvql settles into a steady ~2.8x. Both numbers are true; the honest thing is to publish the whole curve with file sizes attached, not cherry-pick the 10x.&lt;/p&gt;




&lt;h2&gt;
  
  
  Memory: ~6x less
&lt;/h2&gt;

&lt;p&gt;Same 8 GB file, peak memory footprint per query:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;csvql&lt;/th&gt;
&lt;th&gt;DuckDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q01&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;178 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q02&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;210 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q03&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;208 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q04&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;219 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peak memory (MB), 8 GB file
csvql   █▍                          ~30 MB
duckdb  ██████████████████████████  ~200 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;csvql streams the file through a memory-mapped scan and keeps only the group-by hash table in memory — a few dozen groups. It never holds more than a sliver of the 8 GB at once. That's why a 30 MB footprint can chew through an 8 GB file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Storage: the number that actually matters
&lt;/h2&gt;

&lt;p&gt;This is where "raw CSV, no store" stops being a fairness caveat and becomes the whole point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Extra disk required to answer these queries on the 8 GB file:

csvql   0 bytes        — queries the CSV in place, no ingest
duckdb  2.1 GB + 21.7s — must build a native store for its fast path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DuckDB &lt;em&gt;can&lt;/em&gt; be faster than these raw-CSV numbers — but only via its "with storage" path, which first ingests the CSV into a 2.1 GB native store, a one-time cost of ~22 seconds for a single 8 GB file (and minutes for the full multi-file dataset). csvql needs &lt;strong&gt;zero extra disk and zero ingest&lt;/strong&gt; to hit its numbers. For an ad-hoc query against a CSV that landed on your disk five minutes ago, "no ingest" is the difference between an answer now and an answer after a coffee break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest summary:&lt;/strong&gt; same answers, ~2.8x faster, ~6x less memory, zero extra disk, zero ingest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The test I &lt;em&gt;couldn't&lt;/em&gt; run — and why that's the honest part
&lt;/h2&gt;

&lt;p&gt;You'll notice I didn't put csvql's numbers next to DuckDB's &lt;em&gt;published&lt;/em&gt; figures (their blog reports Q01–Q04 at 2.45 / 3.89 / 5.21 / 11.2 s without storage). It would have looked great — csvql's ~1.3 s next to their 11.2 s on Q04 is a juicy 8x. It would also have been junk.&lt;/p&gt;

&lt;p&gt;Two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Different dataset size.&lt;/strong&gt; DuckDB's published query numbers are on the &lt;em&gt;full&lt;/em&gt; ~1.1-billion-row dataset (all 65 files, ~500 GB of uncompressed CSV). Mine are on a single 20-million-row file. That's a 55x difference in rows. Putting them in the same table would be comparing csvql-on-20M against DuckDB-on-1.1B — the benchmarking equivalent of a fixed fight. (You can see the size gap in their own Q04: 11.2 s on 1.1B rows vs the ~4 s my local DuckDB takes on 20M.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Different hardware.&lt;/strong&gt; Their numbers are from an M1 Pro; mine are from a different Apple Silicon box. Borrowing a competitor's number measured on other silicon and dividing it by yours isn't a benchmark, it's a wish.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To &lt;em&gt;legitimately&lt;/em&gt; cite DuckDB's published figures, csvql would have to run the identical ~1.1-billion-row dataset — which means ~500 GB of uncompressed CSV on disk. My machine has 275 GB free. It physically won't fit. So instead of borrowing numbers across hardware and data sizes, I ran &lt;strong&gt;both engines on the same 20 M rows on the same machine with the same OS cache state.&lt;/strong&gt; That controls for everything the cross-hardware comparison doesn't. It's a smaller, less flashy result — and a far more trustworthy one.&lt;/p&gt;

&lt;p&gt;If someone with a 1 TB disk wants to run the full 500 GB and report csvql against DuckDB's exact published table, the script is one flag away (&lt;code&gt;bench_taxi.sh 65&lt;/code&gt;). I'd love to see it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The plot twist: the benchmark found two bugs in &lt;em&gt;my&lt;/em&gt; engine
&lt;/h2&gt;

&lt;p&gt;I built the benchmark to measure speed. It ended up as the best test suite csvql ever had, because running real queries on real data immediately broke two of them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug #1 — &lt;code&gt;GROUP BY ROUND(trip_distance)&lt;/code&gt; crashed with &lt;code&gt;ColumnNotFound&lt;/code&gt;.&lt;/strong&gt; Grouping by a &lt;em&gt;string&lt;/em&gt;-producing function (&lt;code&gt;STRFTIME&lt;/code&gt;) worked; grouping by a &lt;em&gt;numeric&lt;/em&gt;-producing one (&lt;code&gt;ROUND&lt;/code&gt;) didn't — the group-key resolver simply had no case for it. Q04 wouldn't run at all until I added one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug #2 — &lt;code&gt;ORDER BY COUNT(*) DESC&lt;/code&gt; silently returned unsorted rows.&lt;/strong&gt; No error, no crash — just wrong output. The &lt;code&gt;GROUP BY&lt;/code&gt; code path sorted results by the internal hash key and ignored the &lt;code&gt;ORDER BY&lt;/code&gt; clause entirely. This is the scariest class of bug: the kind that returns a confident, wrong answer. A benchmark that only checked timings would have sailed right past it; comparing &lt;em&gt;output&lt;/em&gt; against DuckDB caught it in one diff.&lt;/p&gt;

&lt;p&gt;Both are now fixed, each with a regression test, and the four canonical queries produce byte-identical results to DuckDB (modulo &lt;code&gt;1&lt;/code&gt; vs &lt;code&gt;1.0&lt;/code&gt; float formatting). The moral: &lt;strong&gt;a benchmark you check against a trusted oracle is a correctness test wearing a stopwatch.&lt;/strong&gt; If I'd only ever run csvql against itself, both bugs would still be there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reproduce it yourself
&lt;/h2&gt;

&lt;p&gt;Everything is in the repo. No 500 GB required — the sample mode pulls ~417 MB and runs in seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/melihbirim/csvql
&lt;span class="nb"&gt;cd &lt;/span&gt;csvql &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zig build &lt;span class="nt"&gt;-Doptimize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ReleaseFast

&lt;span class="c"&gt;# quick: 1M-row sample (~417 MB)&lt;/span&gt;
./bench/bench_taxi.sh &lt;span class="nt"&gt;--sample&lt;/span&gt;

&lt;span class="c"&gt;# full: 20M rows (~8 GB download from DuckDB's blobs)&lt;/span&gt;
./bench/bench_taxi.sh 1

&lt;span class="c"&gt;# memory / CPU / storage instead of speed&lt;/span&gt;
./bench/bench_taxi.sh &lt;span class="nt"&gt;--resources&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script downloads DuckDB's own dataset, runs both engines on the raw CSV, and prints the tables above. It's ~130 lines of bash — read it before you trust it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark direct-vs-direct or don't bother.&lt;/strong&gt; The single most important line in the whole exercise was refusing to compare raw-CSV csvql against preloaded DuckDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the whole curve.&lt;/strong&gt; ~10x on 400 MB and ~2.8x on 8 GB are both true. Hiding one to headline the other is how benchmarks earn their bad reputation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The interesting number isn't always speed.&lt;/strong&gt; ~6x less memory and &lt;em&gt;zero ingest&lt;/em&gt; is a bigger deal for ad-hoc CSV work than shaving two seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point your benchmark at a trusted oracle.&lt;/strong&gt; It doubles as the correctness test you were too lazy to write. It found two bugs I'd have shipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;csvql is open source (Zig, single static binary, &lt;a href="https://github.com/melihbirim/csvql" rel="noopener noreferrer"&gt;github.com/melihbirim/csvql&lt;/a&gt;). If you run the full dataset, tell me what you get.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Numbers: Apple Silicon M2Pro, macOS, DuckDB v1.4.2, best-of-5, warm cache. Dataset and queries: &lt;a href="https://github.com/pdet/taxi-benchmark" rel="noopener noreferrer"&gt;DuckDB's NYC-taxi benchmark&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>csv</category>
      <category>duckdb</category>
      <category>database</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
