<?xml version="1.0" encoding="utf-8"?>

<feed xmlns="http://www.w3.org/2005/Atom" >
  <generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator>
  <link href="https://duckdb.org/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://duckdb.org/" rel="alternate" type="text/html" />
  <updated>2026-10-16T13:43:55+00:00</updated>
  <id>https://duckdb.org/feed.xml</id>

  
  
  

  
    <title type="html">DuckDB</title>
  

  
    <subtitle>DuckDB is an in-process SQL database management system focused on analytical query processing. It is designed to be easy to install and easy to use. DuckDB has no external dependencies. DuckDB has bindings for C/C++, Python, R, Java, Node.js, Go and other languages.</subtitle>
  

  

  

  
    <author>
        <name>GitHub User</name>
      
        <email>your-email@domain.com</email>
      
      
    </author>
  

  
  
  
  
  
  
  
  
    <entry>
      

      <title type="html">From JSON Strings to Typed Columns</title>
      <link href="https://duckdb.org/2026/10/16/json-to-typed-columns.html" rel="alternate" type="text/html" title="From JSON Strings to Typed Columns" />
      <published>2026-10-16T00:00:00+00:00</published>
      <updated>2026-10-16T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/16/json-to-typed-columns</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/16/json-to-typed-columns.html"><![CDATA[<p>DuckDB's <a href="/docs/current/data/json/loading_json.html"><code class="language-plaintext highlighter-rouge">read_json</code></a> turns JSON files into tables. It reads through part of the data to work out a schema, then gives you typed columns. However, a lot of JSON does not come as files. It comes as a text column: event payloads in a Postgres table, API responses stored by an ingestion job, a <code class="language-plaintext highlighter-rouge">metadata</code> column in a CSV export.</p>

<p>People regularly <a href="https://github.com/duckdb/duckdb/discussions/9558">ask</a> how to get the <code class="language-plaintext highlighter-rouge">read_json</code> treatment for such a column, and one workaround that comes up is to <a href="/docs/current/sql/statements/copy.html"><code class="language-plaintext highlighter-rouge">COPY</code></a> the column to a file and read the file back.</p>

<p>You do not need the round trip. In this post, we get the same result directly in SQL.</p>

<h2 id="the-data">The Data</h2>

<p>Here is a small table of events, with the payload stored as <code class="language-plaintext highlighter-rouge">VARCHAR</code>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">events</span> <span class="p">(</span><span class="n">id</span> <span class="nb">INTEGER</span><span class="p">,</span> <span class="n">payload</span> <span class="nb">VARCHAR</span><span class="p">);</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">events</span> <span class="k">VALUES</span>
    <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'{"user": {"id": 17, "country": "NL"}, "action": "click", "ts": "2026-10-01 10:00:00"}'</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'{"user": {"id": 23, "country": "BE"}, "action": "view", "ts": "2026-10-01 10:00:05", "duration_ms": 1200}'</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'{"user": {"id": 17}, "action": "purchase", "ts": "2026-10-01 10:02:11", "items": [{"sku": "A-1", "qty": 2}, {"sku": "B-7", "qty": 1}]}'</span><span class="p">);</span>
</code></pre></div></div>

<p>Like most real payloads, the objects do not all have the same keys. Only the second event has a <code class="language-plaintext highlighter-rouge">duration_ms</code>, only the third has <code class="language-plaintext highlighter-rouge">items</code>, and the third has no <code class="language-plaintext highlighter-rouge">country</code>.</p>

<h2 id="picking-out-a-few-fields">Picking out a Few Fields</h2>

<p>If you only need a handful of fields, the <a href="/docs/current/data/json/json_functions.html">JSON extraction operators</a> are enough. <code class="language-plaintext highlighter-rouge">-&gt;</code> returns <code class="language-plaintext highlighter-rouge">JSON</code> and <code class="language-plaintext highlighter-rouge">-&gt;&gt;</code> returns <code class="language-plaintext highlighter-rouge">VARCHAR</code>. Both accept a key name or a JSON path:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">id</span><span class="p">,</span>
    <span class="n">payload</span> <span class="o">-&gt;&gt;</span> <span class="s1">'$.user.country'</span> <span class="k">AS</span> <span class="n">country</span><span class="p">,</span>
    <span class="n">payload</span> <span class="o">-&gt;&gt;</span> <span class="s1">'action'</span> <span class="k">AS</span> <span class="n">action</span><span class="p">,</span>
    <span class="p">(</span><span class="n">payload</span> <span class="o">-&gt;&gt;</span> <span class="s1">'duration_ms'</span><span class="p">)::</span><span class="nb">INTEGER</span> <span class="k">AS</span> <span class="n">duration_ms</span>
<span class="k">FROM</span> <span class="n">events</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌───────┬─────────┬──────────┬─────────────┐
│  id   │ country │  action  │ duration_ms │
│ int32 │ varchar │ varchar  │    int32    │
├───────┼─────────┼──────────┼─────────────┤
│     1 │ NL      │ click    │        NULL │
│     2 │ BE      │ view     │        1200 │
│     3 │ NULL    │ purchase │        NULL │
└───────┴─────────┴──────────┴─────────────┘
</code></pre></div></div>

<p>Missing keys come back as <code class="language-plaintext highlighter-rouge">NULL</code>, which is what you want for optional fields. However, this approach parses the JSON again for every extracted field, and it becomes verbose once you want more than a few columns.</p>

<h2 id="inferring-a-structure-from-the-column">Inferring a Structure from the Column</h2>

<p><a href="/docs/current/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_structure</code></a> returns the structure of a single JSON value. Its aggregate variation, <a href="/docs/current/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_group_structure</code></a>, merges the structures of all values in a column into one. It needs <code class="language-plaintext highlighter-rouge">JSON</code> input, so cast the <code class="language-plaintext highlighter-rouge">VARCHAR</code> column first:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">json_group_structure</span><span class="p">(</span><span class="n">payload</span><span class="p">::</span><span class="nb">JSON</span><span class="p">)</span> <span class="k">AS</span> <span class="n">structure</span>
<span class="k">FROM</span> <span class="n">events</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                      structure                                                                      │
│                                                                        json                                                                         │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ {"user":{"id":"UBIGINT","country":"VARCHAR"},"action":"VARCHAR","ts":"VARCHAR","duration_ms":"UBIGINT","items":[{"sku":"VARCHAR","qty":"UBIGINT"}]} │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<p>Every key that appears in any row is in there, including <code class="language-plaintext highlighter-rouge">duration_ms</code> and the array of <code class="language-plaintext highlighter-rouge">items</code>.</p>

<p>For a large table, you do not have to scan every row for this. Running it over a <a href="/docs/current/sql/query_syntax/sample.html">sample</a>, for example <code class="language-plaintext highlighter-rouge">FROM events USING SAMPLE 10_000 ROWS</code>, is much cheaper, at the risk of missing keys that are very rare.</p>

<h2 id="adjusting-the-types">Adjusting the Types</h2>

<p>The inferred structure needs a few fixes. <code class="language-plaintext highlighter-rouge">ts</code> came out as <code class="language-plaintext highlighter-rouge">VARCHAR</code> because JSON has no timestamp type, and the integers came out as <code class="language-plaintext highlighter-rouge">UBIGINT</code>, which is wider than needed.</p>

<p>Since the structure is itself a JSON document, you can override parts of it with <a href="/docs/current/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_merge_patch</code></a>. Store the result in a <a href="/docs/current/sql/statements/set_variable.html">variable</a> so you can use it in the next query:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="k">VARIABLE</span> <span class="n">payload_structure</span> <span class="o">=</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="nf">json_merge_patch</span><span class="p">(</span>
        <span class="nf">json_group_structure</span><span class="p">(</span><span class="n">payload</span><span class="p">::</span><span class="nb">JSON</span><span class="p">),</span>
        <span class="s1">'{
            "user": {"id": "INTEGER"},
            "ts": "TIMESTAMP",
            "duration_ms": "INTEGER",
            "items": [{"sku": "VARCHAR", "qty": "INTEGER"}]
        }'</span>
    <span class="p">)</span>
    <span class="k">FROM</span> <span class="n">events</span>
<span class="p">);</span>
</code></pre></div></div>

<p>The patch only lists what you want to change. Keys you leave out, such as <code class="language-plaintext highlighter-rouge">user.country</code> and <code class="language-plaintext highlighter-rouge">action</code>, keep their inferred types.</p>

<h2 id="building-the-table">Building the Table</h2>

<p><a href="/docs/current/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_transform</code></a> (also available as <a href="/docs/current/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">from_json</code></a>) converts a JSON value into a DuckDB <a href="/docs/current/sql/data_types/struct.html"><code class="language-plaintext highlighter-rouge">STRUCT</code></a> following the structure you give it. <a href="/docs/current/sql/query_syntax/unnest.html"><code class="language-plaintext highlighter-rouge">unnest</code></a> then expands the top-level fields of that struct into columns:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">events_typed</span> <span class="k">AS</span>
    <span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="nf">unnest</span><span class="p">(</span><span class="nf">json_transform</span><span class="p">(</span><span class="n">payload</span><span class="p">,</span> <span class="nf">getvariable</span><span class="p">(</span><span class="s1">'payload_structure'</span><span class="p">)))</span>
    <span class="k">FROM</span> <span class="n">events</span><span class="p">;</span>

<span class="k">DESCRIBE</span> <span class="n">events_typed</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────┬─────────────────────────────────────┬─────────┬─────────┬─────────┬─────────┐
│ column_name │             column_type             │  null   │   key   │ default │  extra  │
│   varchar   │               varchar               │ varchar │ varchar │ varchar │ varchar │
├─────────────┼─────────────────────────────────────┼─────────┼─────────┼─────────┼─────────┤
│ id          │ INTEGER                             │ YES     │ NULL    │ NULL    │ NULL    │
│ action      │ VARCHAR                             │ YES     │ NULL    │ NULL    │ NULL    │
│ user        │ STRUCT(country VARCHAR, id INTEGER) │ YES     │ NULL    │ NULL    │ NULL    │
│ ts          │ TIMESTAMP                           │ YES     │ NULL    │ NULL    │ NULL    │
│ duration_ms │ INTEGER                             │ YES     │ NULL    │ NULL    │ NULL    │
│ items       │ STRUCT(sku VARCHAR, qty INTEGER)[]  │ YES     │ NULL    │ NULL    │ NULL    │
└─────────────┴─────────────────────────────────────┴─────────┴─────────┴─────────┴─────────┘
</code></pre></div></div>

<p>The JSON is parsed once, at load time. From here on, the columns are regular DuckDB columns, stored and compressed by type, and queries on them do not touch JSON at all:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="nv">"user"</span><span class="n">.id</span> <span class="k">AS</span> <span class="n">user_id</span><span class="p">,</span> <span class="nv">"user"</span><span class="n">.country</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">ts</span>
<span class="k">FROM</span> <span class="n">events_typed</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌───────┬─────────┬─────────┬──────────┬─────────────────────┐
│  id   │ user_id │ country │  action  │         ts          │
│ int32 │  int32  │ varchar │ varchar  │      timestamp      │
├───────┼─────────┼─────────┼──────────┼─────────────────────┤
│     1 │      17 │ NL      │ click    │ 2026-10-01 10:00:00 │
│     2 │      23 │ BE      │ view     │ 2026-10-01 10:00:05 │
│     3 │      17 │ NULL    │ purchase │ 2026-10-01 10:02:11 │
└───────┴─────────┴─────────┴──────────┴─────────────────────┘
</code></pre></div></div>

<h2 id="nested-structs-and-name-collisions">Nested Structs and Name Collisions</h2>

<p>Recursive unnesting with <a href="/docs/current/sql/query_syntax/unnest.html"><code class="language-plaintext highlighter-rouge">unnest(..., recursive := true)</code></a> flattens nested structs in a single pass. By default it names each column after its innermost key, so the <code class="language-plaintext highlighter-rouge">id</code> inside <code class="language-plaintext highlighter-rouge">user</code> ends up as a column called <code class="language-plaintext highlighter-rouge">id</code>, right next to the event's own <code class="language-plaintext highlighter-rouge">id</code>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="nf">unnest</span><span class="p">(</span><span class="nv">"user"</span><span class="p">,</span> <span class="k">recursive</span> <span class="p">:</span><span class="o">=</span> <span class="k">true</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">events_typed</span>
<span class="k">LIMIT</span> <span class="mi">1</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌───────┬─────────┬───────┐
│  id   │ country │  id   │
│ int32 │ varchar │ int32 │
├───────┼─────────┼───────┤
│     1 │ NL      │    17 │
└───────┴─────────┴───────┘
</code></pre></div></div>

<p>With two columns both named <code class="language-plaintext highlighter-rouge">id</code>, a later query cannot tell which one you mean. Pass <a href="/docs/current/sql/query_syntax/unnest.html"><code class="language-plaintext highlighter-rouge">keep_parent_names := true</code></a> to prefix each column with the keys it came from, so names from different levels stay distinct. On the full payload, <code class="language-plaintext highlighter-rouge">user.id</code> and <code class="language-plaintext highlighter-rouge">user.country</code> no longer clash with the top-level columns, and you can select them by their prefixed names:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="nv">"user.id"</span><span class="p">,</span> <span class="nv">"user.country"</span>
<span class="k">FROM</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="nf">unnest</span><span class="p">(</span><span class="nf">json_transform</span><span class="p">(</span><span class="n">payload</span><span class="p">,</span> <span class="nf">getvariable</span><span class="p">(</span><span class="s1">'payload_structure'</span><span class="p">)),</span> <span class="k">recursive</span> <span class="p">:</span><span class="o">=</span> <span class="k">true</span><span class="p">,</span> <span class="n">keep_parent_names</span> <span class="p">:</span><span class="o">=</span> <span class="k">true</span><span class="p">)</span>
    <span class="k">FROM</span> <span class="n">events</span>
    <span class="k">LIMIT</span> <span class="mi">1</span>
<span class="p">);</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌───────┬─────────┬──────────────┐
│  id   │ user.id │ user.country │
│ int32 │  int32  │   varchar    │
├───────┼─────────┼──────────────┤
│     1 │      17 │ NL           │
└───────┴─────────┴──────────────┘
</code></pre></div></div>

<h2 id="arrays-of-objects">Arrays of Objects</h2>

<p>In <code class="language-plaintext highlighter-rouge">events_typed</code>, the <code class="language-plaintext highlighter-rouge">items</code> array is stored as a <code class="language-plaintext highlighter-rouge">STRUCT(sku VARCHAR, qty INTEGER)[]</code>, a list of structs, as the <code class="language-plaintext highlighter-rouge">DESCRIBE</code> output above shows. To get one row per item, <a href="/docs/current/sql/query_syntax/unnest.html"><code class="language-plaintext highlighter-rouge">unnest</code></a> the list in the <code class="language-plaintext highlighter-rouge">FROM</code> clause:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">u.item.sku</span><span class="p">,</span> <span class="n">u.item.qty</span>
<span class="k">FROM</span> <span class="n">events_typed</span><span class="p">,</span> <span class="nf">unnest</span><span class="p">(</span><span class="n">items</span><span class="p">)</span> <span class="k">AS</span> <span class="n">u</span><span class="p">(</span><span class="n">item</span><span class="p">);</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌───────┬─────────┬───────┐
│  id   │   sku   │  qty  │
│ int32 │ varchar │ int32 │
├───────┼─────────┼───────┤
│     3 │ A-1     │     2 │
│     3 │ B-7     │     1 │
└───────┴─────────┴───────┘
</code></pre></div></div>

<p>Events without items do not appear here. If you need to keep them, write the <a href="/docs/current/sql/query_syntax/unnest.html"><code class="language-plaintext highlighter-rouge">unnest</code></a> as a lateral join: <code class="language-plaintext highlighter-rouge">FROM events_typed LEFT JOIN unnest(items) AS u(item) ON true</code>.</p>

<h2 id="values-that-do-not-fit">Values That Do Not Fit</h2>

<p>By default, <a href="/docs/current/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_transform</code></a> does not raise an error on a value it cannot convert. The value becomes <code class="language-plaintext highlighter-rouge">NULL</code> instead:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">json_transform</span><span class="p">(</span><span class="s1">'{"qty": "two"}'</span><span class="p">,</span> <span class="s1">'{"qty": "INTEGER"}'</span><span class="p">)</span> <span class="k">AS</span> <span class="n">result</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────┐
│       result        │
│ struct(qty integer) │
├─────────────────────┤
│ {'qty': NULL}       │
└─────────────────────┘
</code></pre></div></div>

<p>That is convenient for messy data, but it can hide problems. <a href="/docs/current/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_transform_strict</code></a> raises an error instead:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">json_transform_strict</span><span class="p">(</span><span class="s1">'{"qty": "two"}'</span><span class="p">,</span> <span class="s1">'{"qty": "INTEGER"}'</span><span class="p">)</span> <span class="k">AS</span> <span class="n">result</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Invalid Input Error: Failed to cast value to numerical: "two"
</code></pre></div></div>

<p>Use the strict version while you are working out the structure, and switch to the lenient version once you know which oddities you are willing to accept.</p>

<h2 id="should-the-column-be-varchar-or-json">Should the Column Be <code class="language-plaintext highlighter-rouge">VARCHAR</code> or <code class="language-plaintext highlighter-rouge">JSON</code>?</h2>

<p>This question comes up regularly in the DuckDB discussions. The <a href="/docs/current/data/json/json_type.html"><code class="language-plaintext highlighter-rouge">JSON</code> type</a> is stored exactly like a <code class="language-plaintext highlighter-rouge">VARCHAR</code>, so there is no difference in size or scan speed. What it adds is validation on the way in, since an invalid document fails the insert, and the guarantee that the extraction functions do not have to deal with malformed input.</p>

<p>If you will query fields from the column many times, the type of the raw column matters much less than whether you extract the fields at all. Parsing JSON at query time is work that has to be repeated for every query. Turning it into typed columns once, as shown above, does that work a single time.</p>

<h2 id="further-reading">Further Reading</h2>

<p>For more on working with JSON in DuckDB, see the following blog posts and documentation pages:</p>

<ul>
  <li><a href="/2023/03/03/json.html">Shredding Deeply Nested JSON, One Vector at a Time</a>, on how DuckDB reads JSON files</li>
  <li><a href="/2026/08/18/reconciling-json.html">Reconciling JSON in DuckDB, One Patch at a Time</a>, on combining JSON documents with <code class="language-plaintext highlighter-rouge">json_merge_patch</code></li>
  <li><a href="/docs/current/data/json/overview.html">JSON Overview</a> and <a href="/docs/current/data/json/json_functions.html">JSON Functions</a></li>
  <li><a href="/docs/current/data/json/json_type.html">The <code class="language-plaintext highlighter-rouge">JSON</code> Type</a></li>
  <li><a href="/docs/current/sql/query_syntax/unnest.html"><code class="language-plaintext highlighter-rouge">unnest</code></a> and the <a href="/docs/current/sql/data_types/struct.html"><code class="language-plaintext highlighter-rouge">STRUCT</code> data type</a></li>
</ul>]]></content>
      

      
        <author>
          <name>The DuckDB team</name>
        </author>
      

      
        
      

      
        <category term="using-duckdb" />
      

      
      
        <summary type="html"><![CDATA[JSON often arrives as a text column inside a table, not as a file. In this post, we infer a schema from such a column, adjust it, and turn the column into typed columns, without first writing the data to a file.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/json.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/json.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">Benchmarking DuckDB v2.0-dev on NVIDIA Vera</title>
      <link href="https://duckdb.org/2026/10/16/nvidia-vera-benchmark.html" rel="alternate" type="text/html" title="Benchmarking DuckDB v2.0-dev on NVIDIA Vera" />
      <published>2026-10-16T00:00:00+00:00</published>
      <updated>2026-10-16T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/16/nvidia-vera-benchmark</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/16/nvidia-vera-benchmark.html"><![CDATA[<!-- markdownlint-disable MD001 -->

<p>We love running benchmarks on all sorts of new hardware.
This often includes
<a href="/everywhere/raspberry-pi-5/">somewhat</a>
<a href="/everywhere/iphone-17-pro/">exotic</a>
<a href="/everywhere/morefine-m700s/">hardware</a> –
where we sometimes need to compile a new DuckDB binary.
This time, in collaboration with the NVIDIA team,
we got our hands on their new Vera chip, aimed at agentic workloads.</p>

<p><a href="https://www.nvidia.com/en-us/data-center/vera-cpu/">NVIDIA Vera</a> comes with 88 “Olympus” cores, Spatial Multithreading and
up to 1.2 terabytes per second (TB/s) of memory bandwidth.
What's more, Vera runs arm64 binaries – so it runs DuckDB's regular build without any modifications.</p>

<p>To see how this new hardware performs, we ran the full TPC-H benchmark (not just the read queries!) on scale factor 1,000.
Our setup included two server platforms: an x86 baseline (Intel Xeon 6) and NVIDIA Vera.</p>

<h2 id="environment">Environment</h2>

<h3 id="system-setup">System Setup</h3>

<p>Both machines ran Ubuntu 26.04.
Their hardware included 768 GB of memory and a single NVMe SSD with 3+ TB of storage, formatted as ext4.
At the time of writing, DuckDB v2.0 was <a href="/2026/09/02/try-duckdb-20-alpha.html">not released yet</a>, so we used a <a href="/install/?version=preview">preview build</a>, <code class="language-plaintext highlighter-rouge">1.6.0.dev379</code>.</p>

<p>To run the experiments, we used the <a href="https://github.com/duckdb/duckdb-tpch/"><code class="language-plaintext highlighter-rouge">duckdb-tpch</code> benchmark harness</a>.
To re-create our benchmark environment, see our script below.</p>

<!-- markdownlint-disable MD022 MD025 MD034 MD040 -->

<details>
  <summary>
Click here to reveal the setup script.
</summary>
  <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># make sure you have the required tools</span>
<span class="nb">sudo apt </span>install <span class="nb">git g++ cmake </span>python3-pip

<span class="c"># clone the repository at the commit used for the experiments</span>
<span class="nb">git clone </span>https://github.com/duckdb/duckdb-tpch
<span class="nb">cd </span>duckdb-tpch
<span class="nb">git </span>reset <span class="nt">--hard</span> cf096cea8a0a054cefe0e695c842ca5bca6ec04d

<span class="c"># install the required Python dependencies</span>
<span class="nb">python3</span> <span class="nt">-m</span> venv venv
<span class="nb">source </span>venv/bin/activate
<span class="nb">python3</span> <span class="nt">-m</span> <span class="nb">pip </span>install <span class="nt">-r</span> requirements.txt
<span class="nb">python3</span> <span class="nt">-m</span> <span class="nb">pip </span>install <span class="nb">duckdb</span><span class="o">==</span>1.6.0.dev379

<span class="c"># build the dbgen tool</span>
<span class="nb">cd </span>tpch_tools_3.0.1/dbgen/
<span class="nb">make
cd</span> ../..

<span class="c"># generate the dataset, query streams and update streams</span>
<span class="nb">export </span><span class="nv">SF</span><span class="o">=</span>1000
./generate.sh
</code></pre></div>  </div>
</details>

<!-- markdownlint-enable MD022 MD025 MD034 MD040 -->

<blockquote>
  <p>Tip To generate the large-scale TPC-H dataset efficiently,
the repository uses the wonderful <a href="https://pypi.org/project/tpchgen-cli/"><code class="language-plaintext highlighter-rouge">tpchgen-cli</code> tool</a>,
a parallel Rust rewrite of the TPC-H data generator.
It also uses the old generator to produce update and query streams.</p>
</blockquote>

<p>This workload is relatively large-scale (SF1,000) and highly concurrent during the throughput run.
To get the best performance out of DuckDB, we set the following flags:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="n">block_allocator_memory</span> <span class="o">=</span> <span class="s1">'500G'</span><span class="p">;</span>
<span class="k">SET</span> <span class="py">allocator_background_threads</span> <span class="o">=</span> <span class="k">true</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="x86-baseline-intel-xeon-6">x86 Baseline: Intel Xeon 6</h3>

<p>The x86 baseline setup used an Intel Xeon 6 CPU.
The CPU had 96 cores with <a href="https://en.wikipedia.org/wiki/Hyper-threading">hyper-threading</a>,
yielding 192 threads.
However, we found that DuckDB performs best when using 96 threads, so we configured it accordingly:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="py">threads</span> <span class="o">=</span> <span class="mi">96</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="nvidia-vera">NVIDIA Vera</h3>

<p>The second machine had the NVIDIA Vera CPU, which packs 88 cores with <a href="https://developer.nvidia.com/blog/inside-nvidia-vera-cpu-olympus-cores-built-for-maximum-single-threaded-performance-in-agentic-ai/">Spatial Multithreading</a>.
We conducted the benchmark on a dual-socket system but restricted DuckDB to a single socket using the <code class="language-plaintext highlighter-rouge">numactl</code> command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">numactl</span> <span class="nt">--cpunodebind</span><span class="o">=</span>0 <span class="nt">--membind</span><span class="o">=</span>0 <span class="nb">python3 </span>benchmark.py
</code></pre></div></div>

<p>We also pinned the number of threads:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="py">threads</span> <span class="o">=</span> <span class="mi">88</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="benchmark-runs-and-results">Benchmark Runs and Results</h2>

<p>We performed an initial warmup run and discarded its result.
Then, we conducted three complete runs in sequence and took the median value of the <em>QphH@Size</em> composite scores:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>x86 QphH@Size</th>
      <th>Vera QphH@Size</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Run 1</td>
      <td>2,120,588.82</td>
      <td>3,073,292.99</td>
    </tr>
    <tr>
      <td>Run 2</td>
      <td>2,022,795.01</td>
      <td>3,131,306.27</td>
    </tr>
    <tr>
      <td>Run 3</td>
      <td>2,109,782.19</td>
      <td>3,052,102.87</td>
    </tr>
    <tr>
      <td><strong>Median score</strong></td>
      <td><strong>2,109,782.19</strong></td>
      <td><strong>3,073,292.99</strong></td>
    </tr>
  </tbody>
</table>

<p>The results show that the Vera platform has a 46% performance advantage on TPC-H SF1,000 over the x86 baseline (Intel Xeon 6).
In fact, if we were to audit these results through the official TPC-H channels, both would stand a chance to be in the top 3 among the <a href="https://www.tpc.org/tpch/results/tpch_perf_results5.asp?resulttype=noncluster&amp;version=4">TPC-H Top Performance Results</a> on SF1,000.</p>

<p>We attribute the performance advantage of Vera to three key factors.
First, the high memory bandwidth enables fast scans.
Second, combined with low memory latency, it keeps the cores continuously occupied during processing.
Finally, the Olympus cores' large caches allow efficient access to the hash tables built during aggregations and joins.</p>

<p>We are looking forward to continuing this benchmark by using DuckDB v2.0.0's stable build, running on the full dual-socket setup and incorporating different benchmarks.</p>]]></content>
      

      
        <author>
          <name>Gábor Szárnyas</name>
        </author>
      

      
        
      

      
        <category term="benchmark" />
      

      
      
        <summary type="html"><![CDATA[We put NVIDIA's new Vera CPU through its paces using the full TPC-H workload on scale factor 1,000.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/nvidia-vera-benchmark.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/nvidia-vera-benchmark.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">What Is Inside an Iceberg Table?</title>
      <link href="https://duckdb.org/2026/10/13/inside-iceberg-table.html" rel="alternate" type="text/html" title="What Is Inside an Iceberg Table?" />
      <published>2026-10-13T00:00:00+00:00</published>
      <updated>2026-10-13T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/13/inside-iceberg-table</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/13/inside-iceberg-table.html"><![CDATA[<p>An <a href="https://iceberg.apache.org/">Apache Iceberg</a> table looks like a regular table when you query it, but underneath it is a small tree of files: one JSON file at the top, a few layers of Avro files in the middle, and Parquet files at the bottom. When something is slow or looks wrong, it helps to know what is in that tree.</p>

<p>DuckDB can read all three formats, so you can inspect every layer with plain SQL. In this post, we create a small Iceberg table, change it a few times, and then walk through the files from the top down.</p>

<h2 id="getting-started">Getting Started</h2>

<p>You need DuckDB with the <a href="/docs/current/core_extensions/iceberg/overview.html"><code class="language-plaintext highlighter-rouge">iceberg</code> extension</a> and an <a href="https://iceberg.apache.org/">Iceberg REST catalog</a> to attach to. The <a href="/docs/current/core_extensions/iceberg/catalogs.html">catalogs page</a> has instructions for catalogs such as <a href="https://polaris.apache.org/">Apache Polaris</a>, <a href="https://lakekeeper.io/">Lakekeeper</a> and Amazon S3 Tables.</p>

<p>A catalog is needed because the files on their own cannot tell you which version of the table is current. An Iceberg table writes a new <code class="language-plaintext highlighter-rouge">metadata.json</code> on every change and never overwrites the old one. The catalog holds the one piece of mutable state: a pointer to the current metadata file. When you query the table, DuckDB asks the catalog for that pointer first, then follows the tree of files down from there.</p>

<p>For this post, we run a catalog locally with the <a href="https://iceberg.apache.org/">Iceberg REST catalog fixture</a>, a small reference catalog that the Iceberg project publishes as a Docker image. It is the quickest catalog to set up: it runs from a single image, needs no Cloud account and no authentication, and keeps everything on local disk where you can open the files yourself.</p>

<p>We start it like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">docker </span>run <span class="nt">-p</span> 8181:8181 <span class="se">\</span>
    <span class="nt">-v</span> /data/lake:/data/lake <span class="se">\</span>
    <span class="nt">-e</span> <span class="nv">CATALOG_WAREHOUSE</span><span class="o">=</span>/data/lake <span class="se">\</span>
    apache/iceberg-rest-fixture
</code></pre></div></div>

<p>Each part of that command maps to something DuckDB relies on later:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">-p 8181:8181</code> publishes the catalog's REST API on <code class="language-plaintext highlighter-rouge">localhost:8181</code>, the address DuckDB connects to.</li>
  <li><code class="language-plaintext highlighter-rouge">-v /data/lake:/data/lake</code> mounts a host directory into the container, so the metadata, manifest and Parquet files survive after the container stops and you can read them directly.</li>
  <li><code class="language-plaintext highlighter-rouge">-e CATALOG_WAREHOUSE=/data/lake</code> tells the catalog where to write those files, which is why every path in the output below begins with <code class="language-plaintext highlighter-rouge">/data/lake</code>.</li>
</ul>

<blockquote>
  <p>Note Run the examples below in the local DuckDB <a href="/docs/current/clients/cli/overview.html">CLI</a>, not in the browser-based <a href="https://shell.duckdb.org/">DuckDB Shell</a>, because a page served from the web cannot reach a catalog on your own <code class="language-plaintext highlighter-rouge">localhost</code>.</p>
</blockquote>

<p>With the fixture running, attach it like this:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ATTACH</span> <span class="s1">'warehouse'</span> <span class="k">AS</span> <span class="n">lake</span> <span class="p">(</span>
    <span class="k">TYPE</span> <span class="k">iceberg</span><span class="p">,</span>
    <span class="k">ENDPOINT</span> <span class="s1">'http://localhost:8181'</span><span class="p">,</span>
    <span class="k">AUTHORIZATION_TYPE</span> <span class="s1">'none'</span>
<span class="p">);</span>
</code></pre></div></div>

<p>This opens a catalog connection named <code class="language-plaintext highlighter-rouge">lake</code> that you query like any attached database.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">TYPE iceberg</code> tells DuckDB to speak the Iceberg REST protocol.</li>
  <li><code class="language-plaintext highlighter-rouge">ENDPOINT</code> is the address published by the <code class="language-plaintext highlighter-rouge">-p</code> flag above.</li>
  <li><code class="language-plaintext highlighter-rouge">AUTHORIZATION_TYPE 'none'</code> matches the fixture, which expects no credentials.</li>
  <li>The first argument is the warehouse name. The fixture serves a single warehouse, so any name works.</li>
  <li>With object storage, the paths in the output below start with <a href="/docs/current/core_extensions/httpfs/s3api.html"><code class="language-plaintext highlighter-rouge">s3://</code></a> instead of <code class="language-plaintext highlighter-rouge">/data/lake</code>, but everything else is the same.</li>
</ul>

<h2 id="a-table-with-history">A Table with History</h2>

<p>We create a table of train services, partitioned by date, and change it three times: an insert of three days of data, an insert of a fourth day, and a delete.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">SCHEMA</span> <span class="n">lake.demo</span><span class="p">;</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">lake.demo.services</span> <span class="p">(</span>
    <span class="n">service_id</span> <span class="nb">BIGINT</span><span class="p">,</span>
    <span class="n">service_date</span> <span class="nb">DATE</span><span class="p">,</span>
    <span class="n">station</span> <span class="nb">VARCHAR</span><span class="p">,</span>
    <span class="n">delay_min</span> <span class="nb">INTEGER</span>
<span class="p">)</span>
<span class="k">PARTITIONED</span> <span class="k">BY</span> <span class="p">(</span><span class="n">service_date</span><span class="p">);</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">lake.demo.services</span>
    <span class="k">SELECT</span>
        <span class="n">i</span><span class="p">,</span>
        <span class="nb">DATE</span> <span class="s1">'2026-10-01'</span> <span class="o">+</span> <span class="p">(</span><span class="n">i</span> <span class="o">%</span> <span class="mi">3</span><span class="p">)::</span><span class="nb">INTEGER</span><span class="p">,</span>
        <span class="p">[</span><span class="s1">'Utrecht Centraal'</span><span class="p">,</span> <span class="s1">'Delft'</span><span class="p">,</span> <span class="s1">'Leiden Centraal'</span><span class="p">][</span><span class="mi">1</span> <span class="o">+</span> <span class="p">(</span><span class="n">i</span> <span class="o">%</span> <span class="mi">3</span><span class="p">)::</span><span class="nb">INTEGER</span><span class="p">],</span>
        <span class="p">(</span><span class="n">i</span> <span class="o">%</span> <span class="mi">9</span><span class="p">)::</span><span class="nb">INTEGER</span>
    <span class="k">FROM</span> <span class="nf">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">9_001</span><span class="p">)</span> <span class="n">t</span><span class="p">(</span><span class="n">i</span><span class="p">);</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">lake.demo.services</span>
    <span class="k">SELECT</span> <span class="n">i</span><span class="p">,</span> <span class="nb">DATE</span> <span class="s1">'2026-10-04'</span><span class="p">,</span> <span class="s1">'Den Haag HS'</span><span class="p">,</span> <span class="p">(</span><span class="n">i</span> <span class="o">%</span> <span class="mi">5</span><span class="p">)::</span><span class="nb">INTEGER</span>
    <span class="k">FROM</span> <span class="nf">range</span><span class="p">(</span><span class="mi">9_001</span><span class="p">,</span> <span class="mi">12_001</span><span class="p">)</span> <span class="n">t</span><span class="p">(</span><span class="n">i</span><span class="p">);</span>

<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">lake.demo.services</span>
<span class="k">WHERE</span> <span class="n">service_date</span> <span class="o">=</span> <span class="nb">DATE</span> <span class="s1">'2026-10-02'</span> <span class="k">AND</span> <span class="n">delay_min</span> <span class="o">=</span> <span class="mi">4</span><span class="p">;</span>
</code></pre></div></div>

<p>Each of the two inserts and the delete commits on its own. A write does not change the table in place: DuckDB reads the current snapshot, writes new data and metadata files alongside the old ones, and then asks the catalog to move the table pointer from that snapshot to the new one. That move is the commit.</p>

<p>The commit names the snapshot it expects to replace, so two writers that start from the same snapshot cannot both succeed. The first commit moves the pointer, and the second is rejected, because the snapshot it meant to replace is no longer current. The rejected write then retries on top of the new snapshot. When run in sequence from a single session, the three statements here never conflict, because each one starts from the snapshot the previous one committed.</p>

<p>The table now has 11,000 rows: the first insert added 9,000, the second added 3,000, and the delete removed the 1,000 rows of October 2 that had a delay of 4 minutes. Each of the three statements created a new <a href="https://iceberg.apache.org/spec/#snapshots">snapshot</a>, which <a href="/docs/current/core_extensions/iceberg/iceberg_functions.html#iceberg_snapshots"><code class="language-plaintext highlighter-rouge">iceberg_snapshots</code></a> lists as follows:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">sequence_number</span><span class="p">,</span> <span class="n">snapshot_id</span><span class="p">,</span> <span class="n">operation</span>
<span class="k">FROM</span> <span class="nf">iceberg_snapshots</span><span class="p">(</span><span class="n">lake.demo.services</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">sequence_number</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────┬─────────────────────┬───────────┐
│ sequence_number │     snapshot_id     │ operation │
│     uint64      │       uint64        │  varchar  │
├─────────────────┼─────────────────────┼───────────┤
│               1 │ 7523747767380427861 │ append    │
│               2 │ 3062515218093523051 │ append    │
│               3 │ 6456433563311493009 │ delete    │
└─────────────────┴─────────────────────┴───────────┘
</code></pre></div></div>

<h2 id="the-tree">The Tree</h2>

<p>Before going through the layers one by one, here is the whole structure. Each layer points to the files in the layer below it:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>metadata.json                    one per table version
└── manifest list (Avro)         one per snapshot
    └── manifests (Avro)         one per write (for simplicity)
        ├── data files (Parquet)
        └── delete files (Parquet)
</code></pre></div></div>

<p>All of these files are immutable. Every change writes new files and a new <code class="language-plaintext highlighter-rouge">metadata.json</code>, then commits by moving the catalog pointer to it.</p>

<h2 id="layer-1-the-metadata-file">Layer 1: The Metadata File</h2>

<p>The catalog stores a single pointer per table: the location of the current metadata file. <code class="language-plaintext highlighter-rouge">iceberg_load_table_response</code> returns what the catalog sent back when DuckDB loaded the table, including that location:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">metadata_location</span>
<span class="k">FROM</span> <span class="n">iceberg_load_table_response</span><span class="p">(</span><span class="n">lake.demo.services</span><span class="p">);</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────────────────────────────────────────────────────────────────────────────────────┐
│                                     metadata_location                                      │
│                                          varchar                                           │
├────────────────────────────────────────────────────────────────────────────────────────────┤
│ /data/lake/demo/services/metadata/00003-b79b5ffd-f118-44fd-9d8e-3551c147ef3b.metadata.json │
└────────────────────────────────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<p>The file name starts with <code class="language-plaintext highlighter-rouge">00003</code> because this is the fourth version of the metadata, counting from <code class="language-plaintext highlighter-rouge">00000</code> when the table was created.</p>

<p>It is a JSON document, so <a href="/docs/current/data/json/loading_json.html"><code class="language-plaintext highlighter-rouge">read_json</code></a> can read it. We store the location in a <a href="/docs/current/sql/statements/set_variable.html">variable</a> to keep the queries short:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="k">VARIABLE</span> <span class="n">metadata_file</span> <span class="o">=</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">metadata_location</span> <span class="k">FROM</span> <span class="n">iceberg_load_table_response</span><span class="p">(</span><span class="n">lake.demo.services</span><span class="p">)</span>
<span class="p">);</span>

<span class="k">SELECT</span>
    <span class="nv">"format-version"</span><span class="p">,</span>
    <span class="nv">"current-snapshot-id"</span><span class="p">,</span>
    <span class="nf">len</span><span class="p">(</span><span class="n">snapshots</span><span class="p">)</span> <span class="k">AS</span> <span class="n">snapshots</span><span class="p">,</span>
    <span class="nf">len</span><span class="p">(</span><span class="nv">"metadata-log"</span><span class="p">)</span> <span class="k">AS</span> <span class="n">previous_metadata_files</span>
<span class="k">FROM</span> <span class="nf">read_json</span><span class="p">(</span><span class="nf">getvariable</span><span class="p">(</span><span class="s1">'metadata_file'</span><span class="p">));</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────────┬─────────────────────┬───────────┬─────────────────────────┐
│ format-version │ current-snapshot-id │ snapshots │ previous_metadata_files │
│     int64      │        int64        │   int64   │          int64          │
├────────────────┼─────────────────────┼───────────┼─────────────────────────┤
│              2 │ 6456433563311493009 │         3 │                       3 │
└────────────────┴─────────────────────┴───────────┴─────────────────────────┘
</code></pre></div></div>

<p>Besides the schema and partition spec, the metadata file holds the list of all snapshots that are still around, and each snapshot has a <code class="language-plaintext highlighter-rouge">summary</code> with counters. These counters are a cheap way to see what each change did, without reading any data:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">s</span><span class="p">.</span><span class="nv">"sequence-number"</span> <span class="k">AS</span> <span class="n">seq</span><span class="p">,</span>
    <span class="n">s.summary.operation</span><span class="p">,</span>
    <span class="n">s.summary</span><span class="p">.</span><span class="nv">"added-records"</span><span class="p">,</span>
    <span class="n">s.summary</span><span class="p">.</span><span class="nv">"added-position-deletes"</span><span class="p">,</span>
    <span class="n">s.summary</span><span class="p">.</span><span class="nv">"total-records"</span><span class="p">,</span>
    <span class="n">s.summary</span><span class="p">.</span><span class="nv">"total-data-files"</span><span class="p">,</span>
    <span class="n">s.summary</span><span class="p">.</span><span class="nv">"total-delete-files"</span>
<span class="k">FROM</span> <span class="p">(</span><span class="k">SELECT</span> <span class="nf">unnest</span><span class="p">(</span><span class="n">snapshots</span><span class="p">)</span> <span class="k">AS</span> <span class="n">s</span> <span class="k">FROM</span> <span class="nf">read_json</span><span class="p">(</span><span class="nf">getvariable</span><span class="p">(</span><span class="s1">'metadata_file'</span><span class="p">)))</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">seq</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌───────┬───────────┬───────────────┬────────────────────────┬───────────────┬──────────────────┬────────────────────┐
│  seq  │ operation │ added-records │ added-position-deletes │ total-records │ total-data-files │ total-delete-files │
│ int64 │  varchar  │    varchar    │        varchar         │    varchar    │     varchar      │      varchar       │
├───────┼───────────┼───────────────┼────────────────────────┼───────────────┼──────────────────┼────────────────────┤
│     1 │ append    │ 9000          │ NULL                   │ 9000          │ 3                │ 0                  │
│     2 │ append    │ 3000          │ NULL                   │ 12000         │ 4                │ 0                  │
│     3 │ delete    │ NULL          │ 1000                   │ 12000         │ 4                │ 1                  │
└───────┴───────────┴───────────────┴────────────────────────┴───────────────┴──────────────────┴────────────────────┘
</code></pre></div></div>

<p>In the last row, after the delete, <code class="language-plaintext highlighter-rouge">total-records</code> is still 12,000 and there are still four data files. The delete did not touch the data files. It added one <em>delete file</em> with 1,000 entries. We will get to that file in a moment.</p>

<blockquote>
  <p>Note The Iceberg spec treats these <code class="language-plaintext highlighter-rouge">summary</code> fields as optional, so an engine is free to leave any of them out. The counters above are populated by DuckDB, but a table written by another engine may be missing some of them, in which case the field reads as <code class="language-plaintext highlighter-rouge">NULL</code>. Treat them as a convenient hint rather than a guarantee.</p>
</blockquote>

<blockquote>
  <p>Tip The metadata file grows with every commit, because it keeps the snapshot list and a log of previous metadata files. On a table that receives thousands of small commits, it can become large enough to slow down loading the table. Expiring old snapshots, which most catalogs and engines can do, keeps it in check.</p>
</blockquote>

<h2 id="layer-2-the-manifest-list">Layer 2: The Manifest List</h2>

<p>Each snapshot points to a <em>manifest list</em>, an Avro file that lists the manifests making up that snapshot. The <a href="/docs/current/core_extensions/avro.html"><code class="language-plaintext highlighter-rouge">avro</code> extension</a> reads it with <code class="language-plaintext highlighter-rouge">read_avro</code>. We take the manifest list of the latest snapshot:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="k">VARIABLE</span> <span class="n">manifest_list</span> <span class="o">=</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">manifest_list</span>
    <span class="k">FROM</span> <span class="nf">iceberg_snapshots</span><span class="p">(</span><span class="n">lake.demo.services</span><span class="p">)</span>
    <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">sequence_number</span> <span class="k">DESC</span>
    <span class="k">LIMIT</span> <span class="mi">1</span>
<span class="p">);</span>

<span class="k">SELECT</span>
    <span class="nf">regexp_replace</span><span class="p">(</span><span class="n">manifest_path</span><span class="p">,</span> <span class="s1">'.*/metadata/'</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span> <span class="k">AS</span> <span class="n">manifest</span><span class="p">,</span>
    <span class="n">content</span><span class="p">,</span>
    <span class="n">sequence_number</span><span class="p">,</span>
    <span class="n">added_files_count</span><span class="p">,</span>
    <span class="n">added_rows_count</span>
<span class="k">FROM</span> <span class="nf">read_avro</span><span class="p">(</span><span class="nf">getvariable</span><span class="p">(</span><span class="s1">'manifest_list'</span><span class="p">));</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌──────────────────────────────────────────────┬─────────┬─────────────────┬───────────────────┬──────────────────┐
│                   manifest                   │ content │ sequence_number │ added_files_count │ added_rows_count │
│                   varchar                    │  int32  │      int64      │       int32       │      int64       │
├──────────────────────────────────────────────┼─────────┼─────────────────┼───────────────────┼──────────────────┤
│ ffbf7076-8d36-46f0-aaa2-76e7d95442f0-m0.avro │       0 │               1 │                 3 │             9000 │
│ 74fa5b1f-0908-4e34-964a-504d946671de-m0.avro │       0 │               2 │                 1 │             3000 │
│ 5282235d-42b4-4b5d-a190-147a867994b7-m0.avro │       1 │               3 │                 1 │             1000 │
└──────────────────────────────────────────────┴─────────┴─────────────────┴───────────────────┴──────────────────┘
</code></pre></div></div>

<p>We created one manifest with every write. <code class="language-plaintext highlighter-rouge">content</code> is 0 for manifests that track data files and 1 for manifests that track delete files. The manifest list also stores the range of partition values in each manifest (not shown here), so a reader can skip entire manifests when a query filters on the partition column.</p>

<p>The latest snapshot still includes the manifests written by the two earlier snapshots. To make a commit cheap to write, an engine can add a new manifest for every write, as happened here. This comes at the cost of read performance, because of the rapidly accumulating small manifests. An alternative method merges new additions into an existing manifest, which is more expensive for the writer but more optimized for the reader.</p>

<h2 id="layer-3-the-manifests">Layer 3: The Manifests</h2>

<p>The manifests list the actual files, one entry per file, with the partition values, the row count and per-column statistics. They are Avro too:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">status</span><span class="p">,</span>
    <span class="n">data_file.content</span><span class="p">,</span>
    <span class="nf">regexp_replace</span><span class="p">(</span><span class="n">data_file.file_path</span><span class="p">,</span> <span class="s1">'.*/data/'</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span> <span class="k">AS</span> <span class="n">file</span><span class="p">,</span>
    <span class="n">data_file.record_count</span><span class="p">,</span>
    <span class="n">data_file.partition</span>
<span class="k">FROM</span> <span class="nf">read_avro</span><span class="p">(</span><span class="s1">'/data/lake/demo/services/metadata/*-m*.avro'</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">file</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────┬─────────┬──────────────────────────────────────────────────────────────────────────────┬──────────────┬─────────────────────────────────────────┐
│ status │ content │                                     file                                     │ record_count │                partition                │
│ int32  │  int32  │                                   varchar                                    │    int64     │  struct(identity_service_date_2 date)   │
├────────┼─────────┼──────────────────────────────────────────────────────────────────────────────┼──────────────┼─────────────────────────────────────────┤
│      1 │       0 │ service_date=2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet         │         3000 │ {'identity_service_date_2': 2026-10-01} │
│      1 │       0 │ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet         │         3000 │ {'identity_service_date_2': 2026-10-02} │
│      1 │       1 │ service_date=2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │         1000 │ {'identity_service_date_2': 2026-10-02} │
│      1 │       0 │ service_date=2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet         │         3000 │ {'identity_service_date_2': 2026-10-03} │
│      1 │       0 │ service_date=2026-10-04/01a12bb2-adbe-790f-bde9-50db3acba4b2.parquet         │         3000 │ {'identity_service_date_2': 2026-10-04} │
└────────┴─────────┴──────────────────────────────────────────────────────────────────────────────┴──────────────┴─────────────────────────────────────────┘
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">status = 1</code> means the file was added in the snapshot that wrote the manifest. <code class="language-plaintext highlighter-rouge">content</code> is 0 for a data file and 1 for a position delete file. The partition value is stored per file, which is what lets a reader skip files without opening them.</p>

<p>The extension has a function that does this for you. <a href="/docs/current/core_extensions/iceberg/iceberg_functions.html#iceberg_metadata"><code class="language-plaintext highlighter-rouge">iceberg_metadata</code></a> follows the current snapshot through the manifest list and the manifests and returns one row per file:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">manifest_content</span><span class="p">,</span>
    <span class="nf">regexp_replace</span><span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="s1">'.*/data/'</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span> <span class="k">AS</span> <span class="n">file</span><span class="p">,</span>
    <span class="n">record_count</span>
<span class="k">FROM</span> <span class="nf">iceberg_metadata</span><span class="p">(</span><span class="n">lake.demo.services</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">file</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌──────────────────┬──────────────────────────────────────────────────────────────────────────────┬──────────────┐
│ manifest_content │                                     file                                     │ record_count │
│     varchar      │                                   varchar                                    │    int64     │
├──────────────────┼──────────────────────────────────────────────────────────────────────────────┼──────────────┤
│ DATA             │ service_date=2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet         │         3000 │
│ DATA             │ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet         │         3000 │
│ DELETE           │ service_date=2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │         1000 │
│ DATA             │ service_date=2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet         │         3000 │
│ DATA             │ service_date=2026-10-04/01a12bb2-adbe-790f-bde9-50db3acba4b2.parquet         │         3000 │
└──────────────────┴──────────────────────────────────────────────────────────────────────────────┴──────────────┘
</code></pre></div></div>

<p>Reading the Avro files directly is useful when you want to see fields that the function does not return, or when you are looking at an older snapshot or a table that is not in a catalog.</p>

<h2 id="column-statistics">Column Statistics</h2>

<p>The manifests also store, for every data file, the lower and upper bound of each column. <a href="/docs/current/core_extensions/iceberg/reference.html"><code class="language-plaintext highlighter-rouge">iceberg_column_stats</code></a> returns them in a readable form:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">column_name</span><span class="p">,</span>
    <span class="nf">regexp_replace</span><span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="s1">'.*/data/'</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span> <span class="k">AS</span> <span class="n">file</span><span class="p">,</span>
    <span class="n">lower_bound</span><span class="p">,</span>
    <span class="n">upper_bound</span>
<span class="k">FROM</span> <span class="n">iceberg_column_stats</span><span class="p">(</span><span class="n">lake.demo.services</span><span class="p">)</span>
<span class="k">WHERE</span> <span class="n">column_name</span> <span class="o">=</span> <span class="s1">'service_id'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">file</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────┬──────────────────────────────────────────────────────────────────────────────┬─────────────┬─────────────┐
│ column_name │                                     file                                     │ lower_bound │ upper_bound │
│   varchar   │                                   varchar                                    │   varchar   │   varchar   │
├─────────────┼──────────────────────────────────────────────────────────────────────────────┼─────────────┼─────────────┤
│ service_id  │ service_date=2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet         │ 3           │ 9000        │
│ service_id  │ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet         │ 1           │ 8998        │
│ service_id  │ service_date=2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │ NULL        │ NULL        │
│ service_id  │ service_date=2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet         │ 2           │ 8999        │
│ service_id  │ service_date=2026-10-04/01a12bb2-adbe-790f-bde9-50db3acba4b2.parquet         │ 9001        │ 12000       │
└─────────────┴──────────────────────────────────────────────────────────────────────────────┴─────────────┴─────────────┘
</code></pre></div></div>

<p>These bounds are what DuckDB uses to skip files when you filter on a column that is not the partition column. A query with <code class="language-plaintext highlighter-rouge">WHERE service_id &gt; 10000</code> only has to open the file for October 4, because the bounds of the other three files rule them out.</p>

<p>The ranges for the first three days overlap almost completely, because the rows were spread over the days round-robin. Data that is written in the order of a commonly filtered column gets narrower bounds and more skipping.</p>

<h2 id="partition-statistics">Partition Statistics</h2>

<p>The bounds above are per column, per data file. For the partition column there is a coarser view, one row per manifest, that the manifest list keeps so a reader can skip an entire manifest before opening any of its files. <a href="/docs/current/core_extensions/iceberg/reference.html"><code class="language-plaintext highlighter-rouge">iceberg_partition_stats</code></a> returns it:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">partition_field_name</span><span class="p">,</span>
    <span class="n">partition_field_transform</span><span class="p">,</span>
    <span class="n">lower_bound</span><span class="p">,</span>
    <span class="n">upper_bound</span>
<span class="k">FROM</span> <span class="n">iceberg_partition_stats</span><span class="p">(</span><span class="n">lake.demo.services</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">lower_bound</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────┬───────────────────────────┬─────────────┬─────────────┐
│  partition_field_name   │ partition_field_transform │ lower_bound │ upper_bound │
│         varchar         │          varchar          │   varchar   │   varchar   │
├─────────────────────────┼───────────────────────────┼─────────────┼─────────────┤
│ identity_service_date_2 │ identity                  │ 2026-10-01  │ 2026-10-03  │
│ identity_service_date_2 │ identity                  │ 2026-10-02  │ 2026-10-02  │
│ identity_service_date_2 │ identity                  │ 2026-10-04  │ 2026-10-04  │
└─────────────────────────┴───────────────────────────┴─────────────┴─────────────┘
</code></pre></div></div>

<p>There is one row per manifest, so the bounds trace the three writes: the first insert spread three days across one manifest, the second insert added October 4, and the delete touched only October 2. The <code class="language-plaintext highlighter-rouge">partition_field_transform</code> is <code class="language-plaintext highlighter-rouge">identity</code> because we partitioned on the date column directly. A query with <code class="language-plaintext highlighter-rouge">WHERE service_date = DATE '2026-10-04'</code> can rule out the first and third manifests on these bounds alone, without reading a single manifest entry.</p>

<h2 id="layer-4-data-files-and-delete-files">Layer 4: Data Files and Delete Files</h2>

<p>At the bottom of the tree are Parquet files. The data files are regular Parquet that any reader can open. The delete file works differently. It is a <em>position delete</em> file, and it lists rows to ignore by file and row position:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">regexp_replace</span><span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="s1">'.*/data/'</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span> <span class="k">AS</span> <span class="n">data_file</span><span class="p">,</span> <span class="n">pos</span>
<span class="k">FROM</span> <span class="nf">read_parquet</span><span class="p">(</span>
    <span class="s1">'/data/lake/demo/services/data/*/*-deletes.parquet'</span><span class="p">,</span>
    <span class="k">hive_partitioning</span> <span class="o">=</span> <span class="k">false</span>
<span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">pos</span>
<span class="k">LIMIT</span> <span class="mi">5</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌──────────────────────────────────────────────────────────────────────┬───────┐
│                              data_file                               │  pos  │
│                               varchar                                │ int64 │
├──────────────────────────────────────────────────────────────────────┼───────┤
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │     1 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │     4 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │     7 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │    10 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │    13 │
└──────────────────────────────────────────────────────────────────────┴───────┘
</code></pre></div></div>

<p>This is how the extension implements <code class="language-plaintext highlighter-rouge">DELETE</code> and <code class="language-plaintext highlighter-rouge">UPDATE</code>: it leaves the data file alone and records the positions of the deleted rows, a strategy called <a href="/docs/current/core_extensions/iceberg/writing_to_iceberg.html#limitations-for-update-and-delete">merge-on-read</a>.</p>

<p>Every reader then has to combine the two files to get the correct rows. Writes are cheap, and reads pay a small cost, which grows with the number of delete files. We set <code class="language-plaintext highlighter-rouge">hive_partitioning = false</code> because the <code class="language-plaintext highlighter-rouge">service_date=...</code> directory would otherwise be added as an extra column.</p>

<p>For tables with <code class="language-plaintext highlighter-rouge">format-version</code> 3, the same information is stored as a compact binary deletion vector in a <a href="https://iceberg.apache.org/puffin-spec/">Puffin</a> file instead of a Parquet file.</p>

<h2 id="why-look-at-this-at-all">Why Look at This at All?</h2>

<p>Most of the time, you do not need to. <code class="language-plaintext highlighter-rouge">FROM lake.demo.services</code> returns the right 11,000 rows, and the extension handles the tree. It becomes useful when something does not behave as you expect:</p>

<ul>
  <li><strong>A query reads more files than you expected.</strong> Compare the partition values and column bounds in the manifests with your filter.</li>
  <li><strong>A table gets slower over time.</strong> Count the manifests and delete files. Many small commits and many deletes both add files that every query has to process.</li>
  <li><strong>Row counts look off.</strong> The snapshot summaries show how many rows each commit added and deleted.</li>
  <li><strong>Another engine wrote the table.</strong> The manifests show what it wrote, including properties that DuckDB does not use.</li>
</ul>

<p>Since every layer is a file in a format DuckDB already reads, you can answer these questions with the same tool you use to query the data.</p>

<h2 id="further-reading">Further Reading</h2>

<ul>
  <li><a href="/2025/11/28/iceberg-writes-in-duckdb.html">Writes in DuckDB-Iceberg</a></li>
  <li><a href="/2025/12/16/iceberg-in-the-browser.html">Iceberg in the Browser</a></li>
  <li><a href="/2026/05/29/new-iceberg-features.html">New DuckDB-Iceberg Features in v1.5.3</a></li>
</ul>]]></content>
      

      
        <author>
          <name>The DuckDB team</name>
        </author>
      

      
        
      

      
        <category term="deep-dive" />
      

      
      
        <summary type="html"><![CDATA[An Iceberg table is a tree of JSON, Avro and Parquet files. DuckDB can read every one of them, so you can walk the tree yourself with SQL: from the metadata file, through the manifest list and manifests, down to the data files and delete files.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/iceberg-writes.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/iceberg-writes.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">Agent Mode in the DuckDB CLI</title>
      <link href="https://duckdb.org/2026/10/09/agent-mode.html" rel="alternate" type="text/html" title="Agent Mode in the DuckDB CLI" />
      <published>2026-10-09T00:00:00+00:00</published>
      <updated>2026-10-09T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/09/agent-mode</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/09/agent-mode.html"><![CDATA[<h2 id="lets-ask-claude">Let's Ask Claude</h2>

<p>We asked Claude to measure the effect of agent mode. In the experiment, Claude Code answered the 22 TPC-H questions on a scale factor 100 dataset. The questions were phrased in plain English, without any SQL. Each question was answered three times with agent mode and three times with <code class="language-plaintext highlighter-rouge">-no-agent</code>. All 132 runs produced the correct answer. With agent mode, the DuckDB output the model read went from 123.6k to 50.8k tokens, a 59% reduction. Claude summarized the result as follows:</p>

<blockquote>
  <p>Quote Agent mode fixes the things about a terminal shell that quietly mislead a model. When I answered the 22 TPC-H questions through the DuckDB CLI, agent mode cut the tokens I read back by 60 percent, and every answer was still right.</p>

  <p>– Claude Fable 5.1</p>
</blockquote>

<p>The savings did not show up in the total cost or runtime. Each turn re-reads the agent’s system prompt, which is much larger than the query output, so the saved tokens amount to about 0.5% of the total input. The agent mode runs also took slightly more turns (237 vs. 224). Claude wrote up the setup, the full results and a follow-up experiment in a <a href="https://github.com/hannes/agent-mode-experiment/blob/main/REPORT.md">report</a>.</p>

<h2 id="background">Background</h2>

<p>The DuckDB CLI was built for a person sitting at a terminal. Its default renderer pads columns so they line up, draws borders around the result and prints the column types under the names:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────┬───────┬────────┬───────────────┐
│    name    │   n   │ total  │  avg_amount   │
│  varchar   │ int64 │ int128 │ decimal(10,2) │
├────────────┼───────┼────────┼───────────────┤
│ customer_0 │     3 │    777 │        259.00 │
│ customer_1 │     3 │    888 │        296.00 │
└────────────┴───────┴────────┴───────────────┘
</code></pre></div></div>

<p>For a large result, it shows the first and last 20 rows with a row of dots in the middle:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌───────────────┬───────┬────────┬───────────────┐
│     name      │   n   │ total  │  avg_amount   │
│    varchar    │ int64 │ int128 │ decimal(10,2) │
├───────────────┼───────┼────────┼───────────────┤
│ customer_0    │     3 │    777 │        259.00 │
│ customer_1    │     3 │    888 │        296.00 │
│       ·       │   ·   │   ·    │       ·       │
│       ·       │   ·   │   ·    │       ·       │
│ customer_4998 │     4 │   1203 │        300.75 │
│ customer_4999 │     2 │    510 │        255.00 │
├───────────────┴───────┴────────┴───────────────┤
│ 5000 rows (40 shown)                4 columns  │
└────────────────────────────────────────────────┘
</code></pre></div></div>

<p>A person sees the dots and the <code class="language-plaintext highlighter-rouge">(40 shown)</code> footer and knows that rows are missing.</p>

<p>Today, many CLI invocations do not come from a person at all. Coding agents such as Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot run <code class="language-plaintext highlighter-rouge">duckdb -c "..."</code> once per shell call, capture stdout through a pipe and pass the text to a language model. This has quickly become a common way to use DuckDB.</p>

<p>This reader differs from a person in a few ways:</p>

<ul>
  <li>It has no terminal, so there is no scrolling and no pager.</li>
  <li>It cannot ask for more. Whatever was printed is all it sees.</li>
  <li>It pays for every token it reads, including padding and border characters.</li>
  <li>It handles errors by pattern matching on text.</li>
</ul>

<p>For this reader, the box renderer is a poor fit. The alignment padding costs tokens and carries no information for a model. A truncated result also looks a lot like a complete one. The footer says <code class="language-plaintext highlighter-rouge">(40 shown)</code>, but a model can skim past that line and then draw conclusions from data it never saw.</p>

<p>Agent mode changes the CLI’s defaults for this reader. This post describes how the CLI detects an agent, what changes in the output and how to try it.</p>

<blockquote>
  <p>Agent mode ships with DuckDB v2.0.</p>
</blockquote>

<h2 id="detecting-an-agent">Detecting an Agent</h2>

<p>Agents mark the commands they run with environment variables. There is no single convention for this yet, so the shell checks a list of them:</p>

<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Set by</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AI_AGENT</code>, <code class="language-plaintext highlighter-rouge">AGENT</code></td>
      <td>Claude Code, Goose, Amp</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CLAUDECODE</code></td>
      <td>Claude Code</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CODEX_CI</code>, <code class="language-plaintext highlighter-rouge">CODEX_SANDBOX</code>, <code class="language-plaintext highlighter-rouge">CODEX_THREAD_ID</code></td>
      <td>Codex</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CURSOR_AGENT</code></td>
      <td>Cursor</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GEMINI_CLI</code></td>
      <td>Gemini CLI</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">COPILOT_AGENT</code>, <code class="language-plaintext highlighter-rouge">COPILOT_CLI</code>, <code class="language-plaintext highlighter-rouge">COPILOT_AGENT_SESSION_ID</code></td>
      <td>GitHub Copilot</td>
    </tr>
  </tbody>
</table>

<p>Agent mode turns on when three things are true: one of these variables is set, stdout is not a terminal, and no output format was given on the command line. If you pass <code class="language-plaintext highlighter-rouge">-csv</code>, <code class="language-plaintext highlighter-rouge">-json</code> or <code class="language-plaintext highlighter-rouge">-markdown</code>, you asked for a specific format, and agent mode stays out of the way.</p>

<p>Two flags override detection. <code class="language-plaintext highlighter-rouge">-agent</code> forces the mode on and <code class="language-plaintext highlighter-rouge">-no-agent</code> forces it off. The two combine with format flags, so <code class="language-plaintext highlighter-rouge">duckdb -agent -csv</code> keeps the agent behavior and only changes how the rows are printed. The <code class="language-plaintext highlighter-rouge">DUCKDB_AGENT_MODE</code> environment variable does the same from the environment, for when the command line cannot be changed, such as a script run from an agent’s shell. For example, to manually enable agent mode, run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DUCKDB_AGENT_MODE</span><span class="o">=</span>1 <span class="nb">duckdb</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">.show</code> command reports whether the mode is active and which agent was detected:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent: claude-code
</code></pre></div></div>

<p>Not every agent sets a variable. For those, a run that fails while writing to a pipe prints one hint line on <code class="language-plaintext highlighter-rouge">stderr</code>, as long as no agent was identified and neither flag was given:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hint: -agent renders errors as JSON and results compactly for AI coding agents (duckdb -help lists all options)
</code></pre></div></div>

<p>An agent that nobody told about the mode can find it this way the first time something goes wrong.</p>

<h2 id="what-changes-in-agent-mode">What Changes in Agent Mode</h2>

<p>Everything below is a default. Explicit settings such as <code class="language-plaintext highlighter-rouge">.mode</code>, <code class="language-plaintext highlighter-rouge">.maxrows</code>, <code class="language-plaintext highlighter-rouge">EXPLAIN (FORMAT ...)</code> or anything in your <code class="language-plaintext highlighter-rouge">.duckdbrc</code> still take precedence.</p>

<h3 id="compact-tables">Compact Tables</h3>

<p>Results are printed as Markdown tables without alignment padding, with the column type in the header cell:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>| name:VARCHAR | n:BIGINT | total:HUGEINT | avg_amount:DECIMAL(10,2) |
|---|---|---|---|
| customer_0 | 3 | 777 | 259.00 |
| customer_1 | 3 | 888 | 296.00 |
</code></pre></div></div>

<p>A model reads the <code class="language-plaintext highlighter-rouge">|</code> separators and does not need the columns lined up. Since this is still valid Markdown, a person reading the agent’s transcript gets a rendered table. On typical results this format is 25 to 65% smaller than the box output, with the largest savings on wide results.</p>

<h3 id="large-results">Large Results</h3>

<p>Printing every row would avoid silent truncation, but a million rows is 17 MB on the pipe. Agent mode prints a result of up to 1,000 rows and 10,000 bytes in full. Anything larger becomes a sample of the first and last 20 rows around an explicit marker row, with a footer that says what happened:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>| 19 | customer_19 | 13.3 |
| … 4960 rows omitted … |
| 4980 | customer_30 | 86.0 |
...
first 20 and last 20 of 5000 rows (.maxbytes 0 for all), hash 7389c2f5ff3cefb6
</code></pre></div></div>

<p>The footer also tells the reader how to get the rest. The limits can be changed with <code class="language-plaintext highlighter-rouge">.maxrows N</code> and <code class="language-plaintext highlighter-rouge">.maxbytes N</code>, where <code class="language-plaintext highlighter-rouge">-1</code> or <code class="language-plaintext highlighter-rouge">0</code> means no limit. Long cell values are cut at 500 characters with a visible marker such as <code class="language-plaintext highlighter-rouge">…(+4500 chars)</code>, adjustable with <code class="language-plaintext highlighter-rouge">.maxcellwidth N</code>.</p>

<h3 id="stopping-early">Stopping Early</h3>

<p>The compact table needs no column widths, so the result can be streamed. Once the cap is reached, the remaining rows are only counted and hashed. After 100,000 more rows, the query is stopped and the count is reported as a lower bound. For example:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span> <span class="nf">range</span><span class="p">(</span><span class="mi">1_000_000_000</span><span class="p">);</span>
</code></pre></div></div>

<p>This returns in 30 milliseconds with the footer <code class="language-plaintext highlighter-rouge">first 20 of at least 102400 rows (query stopped early; .maxrows -1 for all)</code>.</p>

<h3 id="a-hash-of-the-result">A Hash of the Result</h3>

<p>The footer includes a hash of the entire result, covering the rows past the cap. The hash does not depend on row order and distinguishes <code class="language-plaintext highlighter-rouge">NULL</code> from the string <code class="language-plaintext highlighter-rouge">'NULL'</code>. An agent that wants to know whether a result changed after an edit can compare two footers instead of printing two results.</p>

<p>The footer is printed for empty results, for results of 10 rows or more, and whenever the output was capped. It is left out when the query was stopped early, because the hash would not cover the whole result.</p>

<h3 id="errors-as-json">Errors as JSON</h3>

<p>Errors go to <code class="language-plaintext highlighter-rouge">stderr</code> as JSON, using the existing <code class="language-plaintext highlighter-rouge">errors_as_json</code> setting. Errors raised by the shell itself, such as an unknown dot command, are wrapped in the same structure:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"exception_type"</span><span class="p">:</span><span class="s2">"Binder"</span><span class="p">,</span><span class="nl">"exception_message"</span><span class="p">:</span><span class="s2">"Referenced column </span><span class="se">\"</span><span class="s2">foo</span><span class="se">\"</span><span class="s2"> not found in FROM clause!</span><span class="se">\n</span><span class="s2">Candidate bindings: </span><span class="se">\"</span><span class="s2">bar</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="nl">"error_subtype"</span><span class="p">:</span><span class="s2">"COLUMN_NOT_FOUND"</span><span class="p">,</span><span class="nl">"location"</span><span class="p">:</span><span class="s2">"[7,3]"</span><span class="p">,</span><span class="nl">"position"</span><span class="p">:</span><span class="s2">"7"</span><span class="p">,</span><span class="nl">"name"</span><span class="p">:</span><span class="s2">"foo"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The shell drops the <code class="language-plaintext highlighter-rouge">candidates</code> field, because the message already lists the candidates. Without that change, a single “no matching function” error weighs 2.7 kB.</p>

<h3 id="compact-query-plans">Compact Query Plans</h3>

<p><code class="language-plaintext highlighter-rouge">EXPLAIN</code> defaults to a new <code class="language-plaintext highlighter-rouge">compact</code> format with one operator per line, indented by depth. Estimates, actual row counts and timings go in parentheses, followed by the operator’s properties:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UNGROUPED_AGGREGATE (est=1) Aggregates: count_star()
  HASH_JOIN (est=0) Join Type: INNER; Conditions: range = range
    FILTER (est=2) Expression: (r &gt; 2)
      RANGE (est=10) Function: RANGE
    FILTER (est=1) Expression: (s &gt; 2)
      RANGE (est=5) Function: RANGE
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">EXPLAIN ANALYZE</code> starts with a summary line such as <code class="language-plaintext highlighter-rouge">QUERY (time=0.0012s, read=1.2 MB)</code>. This is a regular format, so you can use <code class="language-plaintext highlighter-rouge">EXPLAIN (FORMAT compact)</code> outside agent mode too.</p>

<h3 id="cost-estimates-and-progress">Cost Estimates and Progress</h3>

<p>When the planner expects a statement to read a million rows or more, its estimate goes to <code class="language-plaintext highlighter-rouge">stderr</code> before execution starts:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>estimate: ~7501215 rows read (lineitem ~6001215, orders ~1500000), ~4 rows returned
</code></pre></div></div>

<p>The agent can then decide whether to wait or to interrupt and rewrite the query. Smaller queries print nothing, since the line would only be noise. While a long query runs, the terminal progress bar is replaced by a plain line on <code class="language-plaintext highlighter-rouge">stderr</code> every 5 seconds:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>progress: 42% (elapsed 12.0s, remaining ~16.5s)
progress: done (elapsed 28.7s)
</code></pre></div></div>

<h3 id="listing-tables">Listing Tables</h3>

<p><code class="language-plaintext highlighter-rouge">.tables</code> prints one line per table, with an approximate row count and the columns:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>memory.main.t1 (table, ~15 rows): a INTEGER PK, b VARCHAR
</code></pre></div></div>

<p>The usual box layout is hard to read through a pipe, and this gives an agent the schema in a single call.</p>

<h2 id="the-startup-line">The Startup Line</h2>

<p>When agent mode turns on by detection, the shell prints one line on <code class="language-plaintext highlighter-rouge">stderr</code> before running anything:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>duckdb agent mode on: CLAUDECODE is set and stdout is not a terminal; -no-agent turns it off, .help agent explains the output
</code></pre></div></div>

<p>The line states why the mode is on and how to turn it off.</p>

<p>The first version printed three lines of explanation (634 bytes) on every run. That was larger than most query results, and a model keeps its context between runs anyway, so it only needs the explanation once.</p>

<p>The longer text is available via <code class="language-plaintext highlighter-rouge">.help agent</code>. It describes the output rules and settings, and it points to a few features an agent might not find by itself:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">SET max_execution_time = &lt;ms&gt;</code> to put an upper bound on a query’s runtime</li>
  <li><code class="language-plaintext highlighter-rouge">DESCRIBE &lt;query&gt;</code> to get the result columns without running the query</li>
  <li><code class="language-plaintext highlighter-rouge">SUMMARIZE</code> for a quick profile of a table or query</li>
  <li><code class="language-plaintext highlighter-rouge">.tables</code> for the schema</li>
  <li><code class="language-plaintext highlighter-rouge">duckdb_functions()</code> for function documentation</li>
</ul>

<p>If you do not want the startup line, add <code class="language-plaintext highlighter-rouge">.startup_text none</code> to your <code class="language-plaintext highlighter-rouge">~/.duckdbrc</code>.</p>

<h2 id="trying-it-out">Trying It Out</h2>

<p>If you use a coding agent, there is nothing to configure. Agents such as Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot already set one of the environment variables from the table above, and they capture a command's output through a pipe rather than a terminal. Those are two of the three conditions for agent mode.</p>

<p>So as long as the <code class="language-plaintext highlighter-rouge">duckdb</code> binary on the agent’s path is v2.0 or newer and the agent does not request a specific output format with a flag such as <code class="language-plaintext highlighter-rouge">-csv</code>, <code class="language-plaintext highlighter-rouge">-json</code> or <code class="language-plaintext highlighter-rouge">-markdown</code>, the mode turns on by itself. A format flag is treated as a deliberate choice, so it switches agent mode off: the CLI assumes you want exactly that format and does not override it.</p>

<p>To see what the agent sees, run a query through a pipe with the flag set:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">duckdb</span> <span class="nt">-agent</span> <span class="nt">-c</span> <span class="s2">"SUMMARIZE FROM 'my_data.parquet'"</span> | <span class="nb">cat</span>
</code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>With agent mode, an agent spends fewer tokens per call and does not act on partial results as if they were complete. That means fewer steps to a correct result.</p>

<p>The current behavior is a first attempt, and the conventions around agents are a work in progress. If your agent is not detected, or the output confuses it, please <a href="https://github.com/duckdb/duckdb/issues">open an issue</a> or comment on the <a href="https://github.com/duckdb/duckdb/pull/26167">pull request</a>. Transcripts of an agent being confused are especially useful.</p>

<h2 id="further-reading">Further Reading</h2>

<ul>
  <li><a href="https://github.com/duckdb/duckdb/pull/26167">Shell: agent mode, render for AI coding agents when one is detected</a> (pull request #26167)</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/26416">Shell: <code class="language-plaintext highlighter-rouge">DUCKDB_AGENT_MODE=1/0</code> forces agent mode on or off</a> (pull request #26416)</li>
  <li><a href="https://github.com/carlopi/duckdb/pull/117">Shell: proposal to add <code class="language-plaintext highlighter-rouge">.mode llm</code></a> (Carlo Piovesan’s proposal, the basis for the byte budget and early stop)</li>
</ul>]]></content>
      

      
        <author>
          <name>The DuckDB team</name>
        </author>
      

      
        
      

      
        <category term="using-duckdb" />
      

      
      
        <summary type="html"><![CDATA[The DuckDB v2.0 CLI has an agent mode that gets AI coding agents to a correct result faster and with fewer tokens. When the CLI detects an agent, it prints compact Markdown tables instead of padded boxes, says clearly when a result was cut, stops runaway queries early, and reports errors as JSON. Long queries announce their expected cost before they start, so the agent can decide whether to wait.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/agent-mode.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/agent-mode.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">A DuckDB Database with No Data in It</title>
      <link href="https://duckdb.org/2026/10/07/view-only-mode.html" rel="alternate" type="text/html" title="A DuckDB Database with No Data in It" />
      <published>2026-10-07T00:00:00+00:00</published>
      <updated>2026-10-07T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/07/view-only-mode</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/07/view-only-mode.html"><![CDATA[<p>A DuckDB database file does not have to contain any data. It can store nothing but <a href="/docs/preview/sql/statements/create_view.html">view</a> definitions that point at files stored somewhere else, such as Parquet on object storage. The file stays a few hundred kilobytes no matter how large the data it describes, and anyone who attaches it sees a set of named relations that are ready to query.</p>

<p>Nikolas Goebel described this idea in <a href="https://www.nikolasgoebel.com/2024/05/28/duckdb-doesnt-need-data">DuckDB doesn't need data</a>, which argues that modern databases have separated where data is stored from how it is read, to the point that most databases no longer need to hold any data of their own. From that perspective, DuckDB becomes a browser for the data cloud, where you access a dataset through a URL rather than a copy on your own disk.</p>

<p>In this blog post, we turn that idea into a small, shareable catalog.</p>

<h2 id="the-idea">The Idea</h2>

<p>A DuckDB table stores its rows inside the database file. A view stores only the text of a query. When you read the view, DuckDB runs that query and returns the current result. If the query reads a remote Parquet file, the data lives in object storage and the view holds only the instruction for how to read it.</p>

<p>A database file made entirely of such views contains no rows of its own. It is a catalog: a set of named, ready-to-query relations created over data that stays in its original location and format. You can publish that catalog as a curated semantic layer over a data lake, where the view definitions encode the joins, filters, and column names that consumers should use, without copying or moving the underlying data.</p>

<h2 id="building-a-catalog-of-views">Building a Catalog of Views</h2>

<p>The examples use DuckDB's public train services dataset, which records one row for every stop a Dutch railway train makes. The data comes from the open datasets published by the <a href="https://www.rijdendetreinen.nl/en/open-data/">Rijden de Treinen <em>(Are the trains running?)</em> application</a>, and you can read it straight from its URL.</p>

<p>First, install the <a href="/docs/preview/core_extensions/httpfs/overview.html"><code class="language-plaintext highlighter-rouge">httpfs</code> extension</a> so that DuckDB can read over the network. This only needs to be run once.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSTALL</span><span class="n"> httpfs</span><span class="p">;</span>
</code></pre></div></div>

<p>Now create a persistent database file and define a view over the remote data. Use a fully qualified location, a remote URL or an absolute path, so that the view resolves regardless of the client's working directory.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ATTACH</span> <span class="s1">'rail_catalog.duckdb'</span> <span class="k">AS</span> <span class="n">rail</span><span class="p">;</span>
<span class="k">USE</span> <span class="n">rail</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">VIEW</span> <span class="n">services</span> <span class="k">AS</span>
    <span class="k">SELECT</span> <span class="o">*</span>
    <span class="k">FROM</span> <span class="s1">'https://blobs.duckdb.org/train_services.parquet'</span><span class="p">;</span>
</code></pre></div></div>

<p>The catalog now exposes a <code class="language-plaintext highlighter-rouge">services</code> relation that reads from the remote file whenever it is queried.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">departure_time</span><span class="p">,</span> <span class="n">station_name</span><span class="p">,</span> <span class="n">type</span>
<span class="k">FROM</span> <span class="n">rail.services</span>
<span class="k">LIMIT</span> <span class="mi">3</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>departure_time</th>
      <th>station_name</th>
      <th>type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2023-05-15 00:00:00</td>
      <td>Rotterdam Centraal</td>
      <td>Intercity</td>
    </tr>
    <tr>
      <td>2023-05-15 00:13:00</td>
      <td>Delft</td>
      <td>Intercity</td>
    </tr>
    <tr>
      <td>2023-05-15 00:29:00</td>
      <td>Den Haag HS</td>
      <td>Intercity</td>
    </tr>
  </tbody>
</table>

<p>Because the file stores only the view definition, it stays small no matter how large the referenced dataset grows. The <code class="language-plaintext highlighter-rouge">train_services.parquet</code> file holds 380,959 rows, yet the catalog that reads it is a few hundred kilobytes.</p>

<h2 id="hosting-the-catalog">Hosting the Catalog</h2>

<p>Upload <code class="language-plaintext highlighter-rouge">rail_catalog.duckdb</code> to any location that DuckDB can read over the network, for example an <a href="/docs/preview/guides/network_cloud_storage/s3_import.html"><code class="language-plaintext highlighter-rouge">s3://</code></a> bucket or an <a href="/docs/preview/guides/network_cloud_storage/http_import.html"><code class="language-plaintext highlighter-rouge">https://</code></a> endpoint. The same bucket that already holds the Parquet files is a convenient place to put it.</p>

<p>Sharing the catalog then means sharing one URL instead of a list of individual file paths. The recipient does not need to know how many files back each view or where they are stored, only the address of the catalog.</p>

<h2 id="attaching-the-catalog-read-only">Attaching the Catalog Read-Only</h2>

<p>Consumers attach the catalog <a href="/docs/preview/guides/network_cloud_storage/duckdb_over_https_or_s3.html">read-only</a> and query the views as if they were local tables. Each query reads the current data from the referenced files, so the result reflects whatever is in object storage at that moment.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ATTACH</span> <span class="s1">'https://example.com/rail_catalog.duckdb'</span> <span class="k">AS</span> <span class="n">rail</span> <span class="p">(</span><span class="k">READ_ONLY</span><span class="p">);</span>

<span class="k">SELECT</span> <span class="n">station_name</span><span class="p">,</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">calls</span>
<span class="k">FROM</span> <span class="n">rail.services</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">station_name</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">calls</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="mi">3</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>station_name</th>
      <th>calls</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Utrecht Centraal</td>
      <td>7663</td>
    </tr>
    <tr>
      <td>Amsterdam Centraal</td>
      <td>7591</td>
    </tr>
    <tr>
      <td>Zwolle</td>
      <td>5013</td>
    </tr>
  </tbody>
</table>

<p>Consumers need the <code class="language-plaintext highlighter-rouge">httpfs</code> extension and, for <code class="language-plaintext highlighter-rouge">s3://</code> sources, <a href="/docs/preview/guides/network_cloud_storage/s3_import.html#credentials-and-configuration">credentials</a> that grant access to the underlying data. DuckDB reads only the columns and row groups a query needs, so a filtered or aggregated query over a large remote dataset does not download the whole thing.</p>

<h2 id="surviving-changes-to-the-data-layout">Surviving Changes to the Data Layout</h2>

<p>Because a query reads the view rather than the files directly, the publisher can change the files without changing the query. The consumer runs the same query before and after.</p>

<p>Suppose the single Parquet file becomes too large and the publisher splits it into one file per year. Updating the view to read a directory of files is a one-line change in the catalog, and consumers notice nothing.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">VIEW</span> <span class="n">services</span> <span class="k">AS</span>
    <span class="k">SELECT</span> <span class="o">*</span>
    <span class="k">FROM</span> <span class="nf">read_parquet</span><span class="p">(</span><span class="s1">'s3://my-bucket/rail/services/year=*/*.parquet'</span><span class="p">);</span>
</code></pre></div></div>

<p>A view can also keep the schema stable when the files change. If a later batch of files renames <code class="language-plaintext highlighter-rouge">station_name</code> to <code class="language-plaintext highlighter-rouge">station</code>, the view can map the new column back to the name consumers already use.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">VIEW</span> <span class="n">services</span> <span class="k">AS</span>
    <span class="k">SELECT</span>
        <span class="o">*</span> <span class="k">EXCLUDE</span> <span class="p">(</span><span class="n">station</span><span class="p">),</span>
        <span class="n">station</span> <span class="k">AS</span> <span class="n">station_name</span>
    <span class="k">FROM</span> <span class="nf">read_parquet</span><span class="p">(</span><span class="s1">'s3://my-bucket/rail/services/*.parquet'</span><span class="p">);</span>
</code></pre></div></div>

<p>If you repartition the files or move them to a new bucket, you only have to update the catalog. Anyone querying it keeps running the same queries as before.</p>

<h2 id="when-to-use-it">When to Use It</h2>

<p>Use this pattern when you want to share a dataset without sending the recipient a long list of individual file paths, and when the data is already in a format DuckDB reads well, such as <a href="/docs/preview/data/parquet/overview.html">Parquet</a>. Readers get a set of named relations to query, and the publisher can change the file layout without breaking those queries.</p>

<p>Note the following limitations:</p>

<ul>
  <li>Consumers must be able to access both the catalog file and every data source that its views reference.</li>
  <li>Connections over HTTPS and the S3 API are <a href="/docs/preview/guides/network_cloud_storage/duckdb_over_https_or_s3.html#limitations">read-only</a>, so the catalog cannot be modified in place. To change a view, edit a local copy and upload it again.</li>
  <li>Query performance depends on the <a href="/docs/preview/guides/performance/file_formats.html">network and the layout of the referenced files</a>. The tips for <a href="/docs/preview/guides/performance/how_to_tune_workloads.html#querying-remote-files">querying remote files</a> apply.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>A DuckDB file that holds only views is a small catalog you can share as a single URL. The data stays in its original location, the publisher can change how it is stored, and the reader gets a stable set of relations to query.</p>

<p>For the full reference, see the <a href="/docs/preview/guides/network_cloud_storage/duckdb_views_only.html">Share a DuckDB Database That Only Stores Views</a> guide.</p>]]></content>
      

      
        <author>
          <name>The DuckDB team</name>
        </author>
      

      
        
      

      
        <category term="using-duckdb" />
      

      
      
        <summary type="html"><![CDATA[A DuckDB database file does not have to hold any data. It can store nothing but view definitions that point at Parquet files on object storage, so a few hundred kilobytes behave like a shared catalog over a whole data lake. This post shows how to build one, host it, and attach it read-only.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/no-data.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/no-data.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">Importing Data using Java Table Functions</title>
      <link href="https://duckdb.org/2026/10/05/import-data-with-java.html" rel="alternate" type="text/html" title="Importing Data using Java Table Functions" />
      <published>2026-10-05T00:00:00+00:00</published>
      <updated>2026-10-05T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/05/import-data-with-java</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/05/import-data-with-java.html"><![CDATA[<blockquote>
  <p>Guest blog post by a DuckDB user.</p>
</blockquote>

<h2 id="the-analytics-screen-problem">The Analytics Screen Problem</h2>

<p>At the company where I work, we store vulnerability data for our clients as a collection of documents in MongoDB. Initially, our analytics screen performed well, but as our data volume increased, response times began to slow. For many months we did a lot of query optimization, restructuring, index additions, and saw significant improvement. However, execution times for certain scenarios remained in the tens of seconds, for example, when returning counts of closed/open vulnerabilities or when getting the top 10 most vulnerable business units for last six months. The main issue was in our MS Excel export feature which was taking 2-3 minutes for 50,000 records, and performance was often inconsistent. So we needed to find an alternative.</p>

<p>The plan was to import one year of records into an alternative database and run analytical queries on it. The requirements were to run all the queries in one second or less and export 50,000 records in Excel format under ten seconds. Anything more than that would be handled in background.</p>

<p>Due to internal requirements, the analytical database had to run on the same server as MongoDB. It needed to be actively developed, free, open-source and capable of working well with limited resources - and DuckDB checked all the boxes.</p>

<h2 id="sample-data-set-and-code-snippets">Sample Data Set and Code Snippets</h2>

<p>To share my performance measurement results, I have prepared an anonymized dataset that roughly corresponds to the data used in our production environment. The size of this dataset has been scaled down significantly from the original to allow for faster experimentation.</p>

<p>The dataset is available within this link: <a href="https://github.com/duckdb/duckdb/issues/24935">vulnerability_sample.csv.zst</a>, it contains 485168 records with 24 fields. Two notable fields are the <code class="language-plaintext highlighter-rouge">VARCHAR</code> “description” and “references”, which contain long strings with median lengths of 5,000 and 2,500 characters, respectively. Some records contain strings as large as 32,000 characters, making many operations on this dataset computationally expensive.</p>

<p>Additionally, along with the DuckLabs team, I have prepared several code snippets to illustrate data import in Java. While these snippets are significantly simplified compared to the code we run, their performance characteristics more or less matched the real ones. The code snippets are available in this GitHub repository <a href="https://github.com/staticlibs/duckdb_java_data_import">staticlibs/duckdb_java_data_import</a>.</p>

<h2 id="mongodb-community-extension">MongoDB Community Extension</h2>

<p>While exploring options to copy the data from MongoDB, I discovered the <a href="https://duckdb.org/community_extensions/extensions/mongo"><code class="language-plaintext highlighter-rouge">mongo</code> community extension</a>. My first proof-of-concept was created using this extension, and it was very simple to setup and use, the implementation looked like this:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSTALL</span><span class="n"> mongo</span> <span class="k">FROM</span> <span class="n">community</span><span class="p">;</span>
<span class="k">ATTACH</span> <span class="s1">'host=localhost port=27017 database=db1'</span> <span class="k">AS</span> <span class="n">m</span> <span class="p">(</span><span class="k">TYPE</span> <span class="n">MONGO</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">TABLE</span> <span class="n">m.vuln1</span> <span class="k">AS</span> <span class="k">FROM</span> <span class="s1">'vulnerability_sample.csv.zst'</span><span class="p">;</span>
</code></pre></div></div>

<p>Using the sample dataset, this query took 55 seconds.</p>

<p>I was quickly able to get it up and running and import the data into DuckDB. I ran many of our analytical queries and it was very fast, under 100ms for <code class="language-plaintext highlighter-rouge">GROUP BY</code> and <code class="language-plaintext highlighter-rouge">COUNT</code> queries and I was able to export 50,000 records in less than 5 seconds. The only exception was when projecting large text columns, which caused a performance degradation; I have raised an <a href="https://github.com/duckdb/duckdb/issues/24935">issue</a> about that.</p>

<p>Sorting the data provided further query performance improvements, and using <code class="language-plaintext highlighter-rouge">SET storage_compatibility_version = 'latest'</code> resulted in a significant reduction in the database file size.</p>

<p>Ultimately, the <code class="language-plaintext highlighter-rouge">mongo</code> community extension appears to be the easiest, most painless, and most performant way to set up a MongoDB-to-DuckDB pipeline.</p>

<p>However, we decided against using it due to the lack of “future-proof” guarantees associated with community extensions. DuckDB is a fast- moving project which is constantly improving, and we did not want to be held back from updating to new DuckDB version simply because an extension was not updated and can no longer be built.</p>

<p>The only real option for using the community extension would have been to build it locally - effectively maintaining an internal fork of it. While possible, we decided against this for two reasons. First, the burden of maintaining a C++ codebase was a major concern. Second, we prefer our data ingestion pipelines to be written in Java, that is much easier for us to maintain, and to be able to reuse parts of this pipeline across other internal data sources that we use from Java and that do not have ready-to-use DuckDB extensions.</p>

<p>Below, I describe the three ingestion approaches I tested, using the <code class="language-plaintext highlighter-rouge">mongo</code> extension's performance numbers as a baseline.</p>

<p>Besides these three, there were other attempts that were abandoned early. The notable one being the intermediate export from MongoDB into NDJSON/CSV/Parquet files and then importing those files directly from DuckDB. While promising at first, this approach was aborted due to the lack of disk space for the intermediate data copies.</p>

<h2 id="standard-jdbc-batch-inserts">Standard JDBC Batch Inserts</h2>

<p>The initial attempt at importing data with Java utilized <code class="language-plaintext highlighter-rouge">INSERT</code> queries via the standard JDBC <code class="language-plaintext highlighter-rouge">executeBatch</code> API.</p>

<p>For simplicity, in this and subsequent examples, the data import period is divided into day-sized batches. With each batch fetched from MongoDB separately. This approach is imperfect (the number of records per day may be quite different, a single query from Mongo may be more performant), but is very easy to implement in parallel form and was found to not skew the results too much. Consequently, the following worker can be used to run <code class="language-plaintext highlighter-rouge">executeBatch</code>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">InsertWorker</span> <span class="kd">implements</span> <span class="nc">Runnable</span> <span class="o">{</span>
    <span class="nc">Queue</span><span class="o">&lt;</span><span class="nc">LocalDate</span><span class="o">&gt;</span> <span class="n">queue</span><span class="o">;</span> <span class="c1">// concurrent queue distributed between worker threads</span>
    <span class="nc">MongoCollection</span><span class="o">&lt;</span><span class="nc">Document</span><span class="o">&gt;</span> <span class="n">collection</span><span class="o">;</span> <span class="c1">// Mongo query interface</span>
    <span class="nc">Connection</span> <span class="n">connection</span><span class="o">;</span> <span class="c1">// DuckDB connection</span>
    <span class="nc">PreparedStatement</span> <span class="n">ps</span><span class="o">;</span> <span class="c1">// DuckDB prepared statement</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">run</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">ps</span> <span class="o">=</span> <span class="n">connection</span><span class="o">.</span><span class="na">prepareStatement</span><span class="o">(</span><span class="s">"INSERT INTO staging VALUES(?, ?, ...)"</span><span class="o">);</span>
        <span class="k">for</span> <span class="o">(;;)</span> <span class="o">{</span>
            <span class="nc">LocalDate</span> <span class="n">day</span> <span class="o">=</span> <span class="n">queue</span><span class="o">.</span><span class="na">poll</span><span class="o">();</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">day</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span><span class="c1">// input queue exhausted</span>
                <span class="k">break</span><span class="o">;</span>
            <span class="o">}</span>
            <span class="n">insertDayRecords</span><span class="o">(</span><span class="n">day</span><span class="o">,</span> <span class="n">collection</span><span class="o">,</span> <span class="n">ps</span><span class="o">);</span>
        <span class="o">}</span> 
        <span class="c1">// error handling omitted</span>
    <span class="o">}</span>

    <span class="kd">static</span> <span class="kt">void</span> <span class="nf">insertDayRecords</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">day</span><span class="o">,</span> <span class="nc">MongoCollection</span><span class="o">&lt;</span><span class="nc">Document</span><span class="o">&gt;</span> <span class="n">collection</span><span class="o">,</span> <span class="nc">PreparedStatement</span> <span class="n">ps</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">MongoCursor</span><span class="o">&lt;</span><span class="nc">Document</span><span class="o">&gt;</span> <span class="n">docs</span> <span class="o">=</span> <span class="n">collection</span><span class="o">.</span><span class="na">find</span><span class="o">(</span><span class="nc">Filters</span><span class="o">.</span><span class="na">and</span><span class="o">(</span> <span class="c1">// other filters omittted </span>
                <span class="nc">Filters</span><span class="o">.</span><span class="na">gte</span><span class="o">(</span><span class="s">"updated_date"</span><span class="o">,</span> <span class="n">day</span><span class="o">.</span><span class="na">atStartOfDay</span><span class="o">().</span><span class="na">toInstant</span><span class="o">(</span><span class="nc">ZoneOffset</span><span class="o">.</span><span class="na">UTC</span><span class="o">)),</span>
                <span class="nc">Filters</span><span class="o">.</span><span class="na">lt</span><span class="o">(</span><span class="s">"updated_date"</span><span class="o">,</span> <span class="n">day</span><span class="o">.</span><span class="na">plusDays</span><span class="o">(</span><span class="mi">1</span><span class="o">).</span><span class="na">atStartOfDay</span><span class="o">().</span><span class="na">toInstant</span><span class="o">(</span><span class="nc">ZoneOffset</span><span class="o">.</span><span class="na">UTC</span><span class="o">))</span>
        <span class="o">)).</span><span class="na">iterator</span><span class="o">();</span>
        <span class="kt">int</span> <span class="n">count</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
        <span class="k">while</span> <span class="o">(</span><span class="n">docs</span><span class="o">.</span><span class="na">hasNext</span><span class="o">())</span> <span class="o">{</span>
            <span class="nc">Document</span> <span class="n">doc</span> <span class="o">=</span> <span class="n">docs</span><span class="o">.</span><span class="na">next</span><span class="o">();</span>
            <span class="n">ps</span><span class="o">.</span><span class="na">setString</span><span class="o">(</span><span class="mi">1</span><span class="o">,</span> <span class="n">doc</span><span class="o">.</span><span class="na">getObjectId</span><span class="o">(</span><span class="s">"_id"</span><span class="o">).</span><span class="na">toString</span><span class="o">());</span>
            <span class="n">ps</span><span class="o">.</span><span class="na">setString</span><span class="o">(</span><span class="mi">2</span><span class="o">,</span> <span class="n">doc</span><span class="o">.</span><span class="na">getString</span><span class="o">(</span><span class="s">"vendor"</span><span class="o">));</span>
            <span class="c1">// other 22 fields omitted</span>
            <span class="n">ps</span><span class="o">.</span><span class="na">addBatch</span><span class="o">();</span>
            <span class="n">count</span><span class="o">++;</span>
        <span class="o">}</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">count</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">ps</span><span class="o">.</span><span class="na">executeBatch</span><span class="o">();</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Note, that it is not necessary to change anything about transactions handling, like disabling auto-commit, because calling <code class="language-plaintext highlighter-rouge">executeBatch</code> on all records for a given day automatically handles the start and commit/rollback of a single transaction.</p>

<p>By running eight of these workers in parallel, we import data into a staging DuckDB database file. We then copy the staging table into the final database file using an <code class="language-plaintext highlighter-rouge">ORDER BY updated_date DESC</code> clause, because sorting was found to drastically improve the resulting database file size.</p>

<blockquote>
  <p>Sorting the data in a post-processing step allowed us to easily run the import in parallel. However this requires an intermediate copy of the data, and the sorting step itself is relatively expensive due to large <code class="language-plaintext highlighter-rouge">VARCHAR</code> fields.</p>
</blockquote>

<p>With this approach, the total execution time on the sample data was 3 minutes and 37 seconds, with the final <code class="language-plaintext highlighter-rouge">CREATE TABLE... AS... ORDER BY</code> operation taking approximately 30 seconds.</p>

<p>The performance being 3.5x slower compared to the <code class="language-plaintext highlighter-rouge">mongo</code> extension is largely due to the lack of native “batch” insert support in DuckDB. The <code class="language-plaintext highlighter-rouge">executeBatch</code> is implemented on Java level. And while the statement is prepared only once per batch, the each entry in the batch is run in a separate database-level <code class="language-plaintext highlighter-rouge">INSERT</code>, thereby incurring a per-row overhead.</p>

<h2 id="java-appender-interface">Java Appender Interface</h2>

<p>The next attempt was to use the <a href="https://duckdb.org/docs/current/clients/java/data_import#appender">Appender</a> interface, that is exposed in Java with the <code class="language-plaintext highlighter-rouge">Connection#createAppender</code> method. The worker for Appender can be structured very similar to the batch insert worker above:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">AppendWorker</span> <span class="kd">implements</span> <span class="nc">Runnable</span> <span class="o">{</span>
    <span class="nc">Queue</span><span class="o">&lt;</span><span class="nc">LocalDate</span><span class="o">&gt;</span> <span class="n">queue</span><span class="o">;</span> <span class="c1">// concurrent queue distributed between worker threads</span>
    <span class="nc">MongoCollection</span><span class="o">&lt;</span><span class="nc">Document</span><span class="o">&gt;</span> <span class="n">collection</span><span class="o">;</span> <span class="c1">// Mongo query interface</span>
    <span class="nc">Connection</span> <span class="n">connection</span><span class="o">;</span> <span class="c1">// DuckDB connection</span>
    <span class="nc">DuckDBAppender</span> <span class="n">appender</span><span class="o">;</span> <span class="c1">// Appender instance</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">run</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">appender</span> <span class="o">=</span> <span class="n">connection</span><span class="o">.</span><span class="na">createAppender</span><span class="o">(</span><span class="s">"staging_db"</span><span class="o">,</span> <span class="s">"main"</span><span class="o">,</span> <span class="s">"vuln1"</span><span class="o">);</span>
        <span class="k">for</span> <span class="o">(;;)</span> <span class="o">{</span>
            <span class="nc">LocalDate</span> <span class="n">day</span> <span class="o">=</span> <span class="n">queue</span><span class="o">.</span><span class="na">poll</span><span class="o">();</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">day</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span><span class="c1">// input queue exhausted</span>
                <span class="k">break</span><span class="o">;</span>
            <span class="o">}</span>
            <span class="n">appendDayRecords</span><span class="o">(</span><span class="n">day</span><span class="o">,</span> <span class="n">collection</span><span class="o">,</span> <span class="n">appender</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="c1">// error handling omitted</span>
    <span class="o">}</span>

    <span class="kd">static</span> <span class="kt">void</span> <span class="nf">appendDayRecords</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">day</span><span class="o">,</span> <span class="nc">MongoCollection</span><span class="o">&lt;</span><span class="nc">Document</span><span class="o">&gt;</span> <span class="n">collection</span><span class="o">,</span> <span class="nc">DuckDBAppender</span> <span class="n">appender</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">Exception</span> <span class="o">{</span>
        <span class="nc">MongoCursor</span><span class="o">&lt;</span><span class="nc">Document</span><span class="o">&gt;</span> <span class="n">docs</span> <span class="o">=</span> <span class="n">collection</span><span class="o">.</span><span class="na">find</span><span class="o">(...).</span><span class="na">iterator</span><span class="o">();</span>
        <span class="kt">int</span> <span class="n">count</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
        <span class="k">while</span> <span class="o">(</span><span class="n">docs</span><span class="o">.</span><span class="na">hasNext</span><span class="o">())</span> <span class="o">{</span>
            <span class="nc">Document</span> <span class="n">doc</span> <span class="o">=</span> <span class="n">docs</span><span class="o">.</span><span class="na">next</span><span class="o">();</span>
            <span class="n">appender</span><span class="o">.</span><span class="na">beginRow</span><span class="o">();</span>
            <span class="n">appender</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">doc</span><span class="o">.</span><span class="na">getObjectId</span><span class="o">(</span><span class="s">"_id"</span><span class="o">).</span><span class="na">toString</span><span class="o">());</span>
            <span class="n">appender</span><span class="o">.</span><span class="na">append</span><span class="o">(</span><span class="n">doc</span><span class="o">.</span><span class="na">getString</span><span class="o">(</span><span class="s">"vendor"</span><span class="o">));</span>
            <span class="c1">// other 22 fields omitted</span>
            <span class="n">appender</span><span class="o">.</span><span class="na">endRow</span><span class="o">();</span>
            <span class="n">count</span><span class="o">++;</span>
        <span class="o">}</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">count</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">appender</span><span class="o">.</span><span class="na">flush</span><span class="o">();</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Unlike <code class="language-plaintext highlighter-rouge">executeBatch</code>, the DuckDB Appender performs true batching: a <a href="https://duckdb.org/docs/lts/clients/c/data_chunk">Data Chunk</a> of 2,048 rows is prepared in Java and then flushed to DuckDB in a single operation.</p>

<p>With this approach, the total execution time on the sample data was 2 minutes and 2 seconds. This remains 2x slower than the <code class="language-plaintext highlighter-rouge">mongo</code> extension (or 1.5x if the final CTAS with sorting is excluded). On real data this performance was not acceptable so I continued the experiments.</p>

<h2 id="copy-over-a-java-table-function">COPY over a Java Table Function</h2>

<p>After accepting defeat with the <code class="language-plaintext highlighter-rouge">Appender</code> and on the verge of giving up, thinking I cannot beat the <code class="language-plaintext highlighter-rouge">mongo</code> extension, I did what I should have done much earlier. I looked inside the <code class="language-plaintext highlighter-rouge">mongo</code> community extension code to find out what it is doing internally so I can do the same in Java. It appeared that the extension was using user-defined table functions. So I searched properly regarding table functions for the first time and found two following resources that appeared to be very helpful:</p>

<ul>
  <li><a href="https://sqg.dev/blog/java-duckdb-benchmark/">Benchmarking DuckDB From Java: Fast INSERT, UPDATE, and DELETE</a></li>
  <li><a href="https://duckdb.org/2026/08/25/table-functions-in-java">DuckDB Table Functions in Java</a></li>
</ul>

<p>Based on them the following parallel table function can be used with the sample dataset:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">DuckDBFunctions</span><span class="o">.</span><span class="na">tableFunction</span><span class="o">()</span>
            <span class="o">.</span><span class="na">withName</span><span class="o">(</span><span class="s">"vuln_import"</span><span class="o">)</span>
            <span class="o">.</span><span class="na">withParameters</span><span class="o">(</span><span class="nc">LocalDate</span><span class="o">.</span><span class="na">class</span><span class="o">,</span> <span class="nc">LocalDate</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
            <span class="o">.</span><span class="na">withFunction</span><span class="o">(</span><span class="k">new</span> <span class="nc">ImportFunction</span><span class="o">())</span>
            <span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">conn</span><span class="o">);</span>
<span class="n">stmt</span><span class="o">.</span><span class="na">execute</span><span class="o">(</span><span class="s">"SET preserve_insertion_order = FALSE"</span><span class="o">);</span> <span class="c1">// necessary to allow parallel import</span>
<span class="n">stmt</span><span class="o">.</span><span class="na">execute</span><span class="o">(</span><span class="s">"CREATE TABLE staging.vuln1 AS FROM vuln_import('2026-02-13'::DATE, '2026-08-13'::DATE)"</span><span class="o">);</span>
</code></pre></div></div>

<p>Where the key parts of the <code class="language-plaintext highlighter-rouge">ImportFunction</code> are (see the full example in <a href="https://github.com/staticlibs/duckdb_java_data_import">duckdb_java_data_import</a> repo):</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">ImportFunction</span> <span class="kd">implements</span> <span class="nc">DuckDBTableFunction</span><span class="o">&lt;</span><span class="nc">BindData</span><span class="o">,</span> <span class="nc">GlobalData</span><span class="o">,</span> <span class="nc">LocalData</span><span class="o">&gt;</span> <span class="o">{</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">BindData</span> <span class="nf">bind</span><span class="o">(</span><span class="nc">DuckDBTableFunctionBindInfo</span> <span class="n">info</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">LocalDate</span> <span class="n">from</span> <span class="o">=</span> <span class="n">info</span><span class="o">.</span><span class="na">getParameter</span><span class="o">(</span><span class="mi">0</span><span class="o">).</span><span class="na">getLocalDate</span><span class="o">();</span>
        <span class="nc">LocalDate</span> <span class="n">to</span> <span class="o">=</span> <span class="n">info</span><span class="o">.</span><span class="na">getParameter</span><span class="o">(</span><span class="mi">1</span><span class="o">).</span><span class="na">getLocalDate</span><span class="o">();</span>

        <span class="n">info</span><span class="o">.</span><span class="na">addResultColumn</span><span class="o">(</span><span class="s">"_id"</span><span class="o">,</span> <span class="nc">String</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
        <span class="n">info</span><span class="o">.</span><span class="na">addResultColumn</span><span class="o">(</span><span class="s">"vendor"</span><span class="o">,</span> <span class="nc">String</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
        <span class="c1">// other 22 columns omitted</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">BindData</span><span class="o">(</span><span class="n">from</span><span class="o">,</span> <span class="n">to</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">GlobalData</span> <span class="nf">init</span><span class="o">(</span><span class="nc">DuckDBTableFunctionInitInfo</span> <span class="n">info</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">BindData</span> <span class="n">bdata</span> <span class="o">=</span> <span class="n">info</span><span class="o">.</span><span class="na">getBindData</span><span class="o">();</span>
        <span class="n">info</span><span class="o">.</span><span class="na">setMaxThreads</span><span class="o">(</span><span class="mi">8</span><span class="o">);</span>
        <span class="c1">// open connection to Mongo, prepare days queue</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">GlobalData</span><span class="o">(</span><span class="n">client</span><span class="o">,</span> <span class="n">collection</span><span class="o">,</span> <span class="n">queue</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">long</span> <span class="nf">apply</span><span class="o">(</span><span class="nc">DuckDBTableFunctionCallInfo</span> <span class="n">info</span><span class="o">,</span> <span class="nc">DuckDBDataChunkWriter</span> <span class="n">output</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">GlobalData</span> <span class="n">gdata</span> <span class="o">=</span> <span class="n">info</span><span class="o">.</span><span class="na">getInitData</span><span class="o">();</span>
        <span class="nc">LocalData</span> <span class="n">ldata</span> <span class="o">=</span> <span class="n">info</span><span class="o">.</span><span class="na">getLocalInitData</span><span class="o">();</span>

        <span class="k">while</span> <span class="o">(</span><span class="n">ldata</span><span class="o">.</span><span class="na">cursor</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="o">!</span><span class="n">ldata</span><span class="o">.</span><span class="na">cursor</span><span class="o">.</span><span class="na">hasNext</span><span class="o">())</span> <span class="o">{</span>
            <span class="nc">LocalDate</span> <span class="n">day</span> <span class="o">=</span> <span class="n">gdata</span><span class="o">.</span><span class="na">queue</span><span class="o">.</span><span class="na">poll</span><span class="o">();</span>
            <span class="k">if</span> <span class="o">(</span><span class="n">day</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span> <span class="c1">// input queue exhausted</span>
                <span class="k">return</span> <span class="mi">0</span><span class="o">;</span>
            <span class="o">}</span>
            <span class="c1">// fetch the day data from Mongo</span>
            <span class="n">ldata</span><span class="o">.</span><span class="na">cursor</span> <span class="o">=</span> <span class="n">gdata</span><span class="o">.</span><span class="na">collection</span><span class="o">.</span><span class="na">find</span><span class="o">(...).</span><span class="na">iterator</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="kt">long</span> <span class="n">row</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
        <span class="k">for</span> <span class="o">(;</span> <span class="n">ldata</span><span class="o">.</span><span class="na">cursor</span><span class="o">.</span><span class="na">hasNext</span><span class="o">()</span> <span class="o">&amp;&amp;</span> <span class="n">row</span> <span class="o">&lt;</span> <span class="n">output</span><span class="o">.</span><span class="na">capacity</span><span class="o">();</span> <span class="n">row</span><span class="o">++)</span> <span class="o">{</span>
            <span class="nc">Document</span> <span class="n">doc</span> <span class="o">=</span> <span class="n">ldata</span><span class="o">.</span><span class="na">cursor</span><span class="o">.</span><span class="na">next</span><span class="o">();</span>
            <span class="n">output</span><span class="o">.</span><span class="na">vector</span><span class="o">(</span><span class="mi">0</span><span class="o">).</span><span class="na">setString</span><span class="o">(</span><span class="n">row</span><span class="o">,</span> <span class="n">doc</span><span class="o">.</span><span class="na">getObjectId</span><span class="o">(</span><span class="s">"_id"</span><span class="o">).</span><span class="na">toString</span><span class="o">());</span>
            <span class="n">output</span><span class="o">.</span><span class="na">vector</span><span class="o">(</span><span class="mi">1</span><span class="o">).</span><span class="na">setString</span><span class="o">(</span><span class="n">row</span><span class="o">,</span> <span class="n">doc</span><span class="o">.</span><span class="na">getString</span><span class="o">(</span><span class="s">"vendor"</span><span class="o">));</span>
            <span class="c1">// other 22 fields omitted</span>
        <span class="o">}</span>
        <span class="k">return</span> <span class="n">row</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>

</code></pre></div></div>

<p>In this implementation, we use our table function as the data source for the <code class="language-plaintext highlighter-rouge">CREATE TABLE</code> query. The <code class="language-plaintext highlighter-rouge">apply</code> method is invoked by DuckDB repeateadly, each time it can write up to 2048 (<code class="language-plaintext highlighter-rouge">output.capacity()</code>) rows to the output.  The source is considered exhausted once the input queue is empty and the <code class="language-plaintext highlighter-rouge">apply</code> method returns a record count of zero.</p>

<p>To make the query over this function to run in parallel we need two bits:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">info.setMaxThreads(8)</code> in <code class="language-plaintext highlighter-rouge">globalInit</code>: tells DuckDB that function supports multiple concurrent <code class="language-plaintext highlighter-rouge">apply</code> calls</li>
  <li><code class="language-plaintext highlighter-rouge">SET preserve_insertion_order = FALSE</code>: tells DuckDB that it is not necessary to maintain the original order of records as they are read from the table function during insertion. If this flag is not set to FALSE, DuckDB will restrict apply calls to a single thread to preserve order. Since we perform sorting as a post-processing step after the import, preserving the insertion order is unnecessary.</li>
</ul>

<p>With this approach, the total execution time on the sample data was 1 minute and 27 seconds. While it does not achieve the <code class="language-plaintext highlighter-rouge">mongo</code> extension time (it is not an apples-to-apples comparison due to day-sized queries and post-processing sorting) - it incurs the least overhead among all Java approaches I tried.</p>

<p>On real data, due to storage space restrictions, I ended up with a non-parallel version of the table function, where <code class="language-plaintext highlighter-rouge">ORDER BY</code> was run in the same <code class="language-plaintext highlighter-rouge">CREATE TABLE</code> query without intermediate copies. It allowed me to achieve the import times on-par with the <code class="language-plaintext highlighter-rouge">mongo</code> extension. And as a result we got a robust ingestion pipeline written in pure Java that can be adapted for other data sources.</p>]]></content>
      

      
        <author>
          <name>Guest Author</name>
        </author>
      
        <author>
          <name>Geertjan Wielenga</name>
        </author>
      
        <author>
          <name>Alex Kasko</name>
        </author>
      

      
        
      

      
        <category term="using-duckdb" />
      

      
      
        <summary type="html"><![CDATA[When the analytics screen running on our main operational database got too slow, we moved a year of data into DuckDB on the same server. Getting the data in was the hard part. This is the story of every method we tried, and why a table function written in pure Java is the one we shipped.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/mongodb.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/mongodb.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">Faster String Aggregations with Dimension Tables</title>
      <link href="https://duckdb.org/2026/10/02/dimension-tables.html" rel="alternate" type="text/html" title="Faster String Aggregations with Dimension Tables" />
      <published>2026-10-02T00:00:00+00:00</published>
      <updated>2026-10-02T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/02/dimension-tables</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/02/dimension-tables.html"><![CDATA[<p>Analytical workloads are full of repeated strings: product names, country names, station names, user agents, category labels. Take DuckDB's public train services dataset, which has one row for every stop a Dutch railway train makes. The data comes from the open datasets published by the <a href="https://www.rijdendetreinen.nl/en/open-data/">Rijden de Treinen <em>(Are the trains running?)</em> application</a>. You can query it straight from its URL:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">departure_time</span><span class="p">,</span> <span class="n">station_name</span><span class="p">,</span> <span class="n">type</span>
<span class="k">FROM</span> <span class="s1">'https://blobs.duckdb.org/train_services.parquet'</span>
<span class="k">LIMIT</span> <span class="mi">5</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>departure_time</th>
      <th>station_name</th>
      <th>type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2023-05-15 00:00:00</td>
      <td>Rotterdam Centraal</td>
      <td>Intercity</td>
    </tr>
    <tr>
      <td>2023-05-15 00:13:00</td>
      <td>Delft</td>
      <td>Intercity</td>
    </tr>
    <tr>
      <td>2023-05-15 00:29:00</td>
      <td>Den Haag HS</td>
      <td>Intercity</td>
    </tr>
    <tr>
      <td>2023-05-15 00:45:00</td>
      <td>Leiden Centraal</td>
      <td>Intercity</td>
    </tr>
    <tr>
      <td>2023-05-15 01:03:00</td>
      <td>Schiphol Airport</td>
      <td>Intercity</td>
    </tr>
  </tbody>
</table>

<p>The table has 380,959 rows but only 537 distinct station names, so each name is stored again in thousands of rows: <code class="language-plaintext highlighter-rouge">Amsterdam Centraal</code> (18 bytes) alone appears in 7,591 of them. Load it into a table to follow along, in the <a href="https://shell.duckdb.org/">web shell</a> or the <a href="/docs/current/clients/cli/overview.html">DuckDB CLI</a>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">train_services</span> <span class="k">AS</span>
    <span class="k">FROM</span> <span class="s1">'https://blobs.duckdb.org/train_services.parquet'</span><span class="p">;</span>
</code></pre></div></div>

<p>When you <a href="/docs/current/sql/query_syntax/groupby.html"><code class="language-plaintext highlighter-rouge">GROUP BY</code></a> the station name, DuckDB has to process the full string for every row. For example, this query counts how many trains call at each station:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">station_name</span><span class="p">,</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">calls</span>
<span class="k">FROM</span> <span class="n">train_services</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">station_name</span><span class="p">;</span>
</code></pre></div></div>

<p>Consider what happens for a handful of rows that call at just two stations:</p>

<table>
  <thead>
    <tr>
      <th>Row</th>
      <th>station_name</th>
      <th>Work when grouping on <code class="language-plaintext highlighter-rouge">station_name</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Amsterdam Centraal</td>
      <td>hash 18 bytes; new group, so copy 18 bytes into the hash table</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Rotterdam Centraal</td>
      <td>hash 18 bytes; new group, so copy 18 bytes into the hash table</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Amsterdam Centraal</td>
      <td>hash 18 bytes; existing group, so compare 18 bytes</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Amsterdam Centraal</td>
      <td>hash 18 bytes; existing group, so compare 18 bytes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>Rotterdam Centraal</td>
      <td>hash 18 bytes; existing group, so compare 18 bytes</td>
    </tr>
  </tbody>
</table>

<p>The string work repeats for every row, even though there are only two distinct stations here, and only 537 across the whole table.</p>

<p>This post shows how to do that work on small integers instead, by giving each distinct string a number and looking up the strings only at the end.</p>

<h2 id="background">Background</h2>

<p>The approach is the <a href="https://en.wikipedia.org/wiki/Star_schema">star schema</a> from data warehousing, used here for query performance. It came up when we looked at a user report of <a href="https://github.com/duckdb/duckdb/issues/14584">heavy memory use in a high-cardinality grouping</a>.</p>

<p>That report turned out not to involve strings, but Richard Wesley pointed out that in his own work, building dimension tables and joining the strings back at the end made a large difference to string-heavy aggregations.</p>

<p>We have since added the pattern to the <a href="/docs/current/guides/performance/schema.html">schema section of the Performance Guide</a>.</p>

<h2 id="why-strings-are-expensive-to-group-on">Why Strings Are Expensive to Group On</h2>

<p>DuckDB computes a <code class="language-plaintext highlighter-rouge">GROUP BY</code> with a hash aggregation, keeping one hash table entry per group, as described in the <a href="/2022/03/07/aggregate-hashtable.html">Parallel Grouped Aggregation in DuckDB</a> blog post (2022). The aggregation uses the hash of each group key to find the key's slot in a hash table, then compares the key with the one stored in that slot. For integers, this takes a few CPU instructions. For strings, the cost grows with the length of the string.</p>

<p>In DuckDB, a string value is a <a href="/docs/current/clients/c/vector.html#strings">16-byte structure</a>. Strings of up to 12 bytes are stored inline. Longer strings store a 4-byte prefix plus a pointer to the actual characters. That design keeps short strings cheap, but many real-world labels are longer than 12 bytes.</p>

<p>For these longer strings, hashing reads every byte of every string in every row. A match can't be confirmed from the prefix alone, so DuckDB follows the pointer and compares the full string. When a new group appears, its string is copied into the hash table's own memory, which makes the table larger than it would be with fixed-width keys.</p>

<p>Copying strings also affects memory use. A wider hash table fits less well into CPU caches, and in larger-than-memory aggregations it reaches the memory limit sooner and has to spill more data to disk. DuckDB does apply dictionary encoding to string columns on disk, as described in the <a href="/2022/10/28/lightweight-compression.html">Lightweight Compression in DuckDB</a> blog post (2022), but that is a storage optimization: once a column is read into an aggregation, each group key is a full string again.</p>

<p>Integer keys avoid these costs, as their fixed width makes hashing and comparing them cheap. When the key range is small, DuckDB can avoid hashing altogether: if the statistics show that the keys fit in a small enough domain, the optimizer picks a <a href="/docs/current/configuration/overview.html#global-configuration-options">perfect hash aggregate</a>, controlled by the <code class="language-plaintext highlighter-rouge">perfect_ht_threshold</code> setting, which uses the key value directly as an index into an array.</p>

<h2 id="building-a-dimension-table-with-sorted-narrow-keys">Building a Dimension Table with Sorted, Narrow Keys</h2>

<p>The examples build on the <code class="language-plaintext highlighter-rouge">train_services</code> table loaded above. Each row records one stop: a <code class="language-plaintext highlighter-rouge">service_id</code>, the <code class="language-plaintext highlighter-rouge">date</code>, the service <code class="language-plaintext highlighter-rouge">type</code>, the <code class="language-plaintext highlighter-rouge">train_number</code>, the <code class="language-plaintext highlighter-rouge">station_code</code> and <code class="language-plaintext highlighter-rouge">station_name</code>, and the <code class="language-plaintext highlighter-rouge">departure_time</code> and <code class="language-plaintext highlighter-rouge">arrival_time</code>. The repeated string we want to encode is <code class="language-plaintext highlighter-rouge">station_name</code>.</p>

<h3 id="step-1-measure-the-cardinality">Step 1: Measure the Cardinality</h3>

<p>First, find out how many distinct values the column has, since that count sets how narrow the key can be.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">count</span><span class="p">(</span><span class="k">DISTINCT</span> <span class="n">station_name</span><span class="p">)</span> <span class="k">AS</span> <span class="n">num_stations</span>
<span class="k">FROM</span> <span class="n">train_services</span><span class="p">;</span>
</code></pre></div></div>

<p>This returns 537, and that count decides how wide the key needs to be. You want the narrowest integer type whose range still covers every distinct value, because a narrower key means fewer bytes per row in the fact table and a smaller entry in the hash table you group on.</p>

<p>The keys come from <a href="/docs/current/sql/functions/window_functions.html#row_numberorder-by-ordering"><code class="language-plaintext highlighter-rouge">row_number()</code></a>, which starts at 1 and only counts upward, so an unsigned type is the right fit, spending none of its range on negative values. <a href="/docs/current/sql/data_types/numeric.html#fixed-width-integer-types"><code class="language-plaintext highlighter-rouge">UTINYINT</code></a> (1 byte) holds up to 255 distinct values, <a href="/docs/current/sql/data_types/numeric.html#fixed-width-integer-types"><code class="language-plaintext highlighter-rouge">USMALLINT</code></a> (2 bytes) up to 65,535, and <a href="/docs/current/sql/data_types/numeric.html#fixed-width-integer-types"><code class="language-plaintext highlighter-rouge">UINTEGER</code></a> (4 bytes) up to about 4.3 billion.</p>

<p>The 537 station names do not fit in a <code class="language-plaintext highlighter-rouge">UTINYINT</code>, so <code class="language-plaintext highlighter-rouge">USMALLINT</code> is the narrowest that works, and that is the type the next step casts to. If the number of distinct values will grow, choose a larger type so you don't run out of keys.</p>

<h3 id="step-2-build-the-dimension-table">Step 2: Build the Dimension Table</h3>

<p>Next, assign each distinct string an integer key. The important detail is the <a href="/docs/current/sql/query_syntax/orderby.html"><code class="language-plaintext highlighter-rouge">ORDER BY</code></a> <code class="language-plaintext highlighter-rouge">station_name</code> inside the window: the keys are assigned in string order.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">TABLE</span> <span class="n">stations</span> <span class="k">AS</span>
    <span class="k">SELECT</span>
        <span class="n">station_name</span><span class="p">,</span>
        <span class="p">(</span><span class="nf">row_number</span><span class="p">()</span> <span class="k">OVER</span> <span class="p">(</span><span class="k">ORDER</span> <span class="k">BY</span> <span class="n">station_name</span><span class="p">))::</span><span class="nb">USMALLINT</span> <span class="k">AS</span> <span class="n">station_id</span>
    <span class="k">FROM</span> <span class="p">(</span><span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="n">station_name</span> <span class="k">FROM</span> <span class="n">train_services</span> <span class="k">WHERE</span> <span class="n">station_name</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="nb">NULL</span><span class="p">);</span>
</code></pre></div></div>

<p>Sorted keys have two advantages. First, <code class="language-plaintext highlighter-rouge">ORDER BY station_id</code> produces the same order as <code class="language-plaintext highlighter-rouge">ORDER BY station_name</code>, so you can sort on the cheap integer. Second, the key assignment is deterministic: rebuilding the table from the same data yields the same keys.</p>

<h3 id="step-3-store-the-key-in-the-fact-table">Step 3: Store the Key in the Fact Table</h3>

<p>Finally, replace the string column in the fact table with its key, a rewrite of the table that you pay for once. The dimension table leaves out <a href="/docs/current/sql/data_types/nulls.html"><code class="language-plaintext highlighter-rouge">NULL</code></a>, so the <a href="/docs/current/sql/query_syntax/from.html#joins"><code class="language-plaintext highlighter-rouge">LEFT JOIN</code></a> keeps the rows without a station and gives them a <code class="language-plaintext highlighter-rouge">NULL</code> key.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">TABLE</span> <span class="n">train_services_encoded</span> <span class="k">AS</span>
    <span class="k">SELECT</span> <span class="n">ts</span><span class="p">.</span><span class="o">*</span> <span class="k">EXCLUDE</span> <span class="p">(</span><span class="n">station_name</span><span class="p">),</span> <span class="n">s.station_id</span>
    <span class="k">FROM</span> <span class="n">train_services</span> <span class="n">ts</span>
    <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">stations</span> <span class="n">s</span> <span class="k">USING</span> <span class="p">(</span><span class="n">station_name</span><span class="p">);</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">stations</code> dimension table holds each name once, keyed in alphabetical order:</p>

<table>
  <thead>
    <tr>
      <th>station_id</th>
      <th>station_name</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>'s-Hertogenbosch</td>
    </tr>
    <tr>
      <td>2</td>
      <td>'s-Hertogenbosch Oost</td>
    </tr>
    <tr>
      <td>3</td>
      <td>'t Harde</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Aachen Hbf</td>
    </tr>
  </tbody>
</table>

<p>The fact table now stores the 2-byte <code class="language-plaintext highlighter-rouge">USMALLINT</code> key, so grouping on it is cheap:</p>

<table>
  <thead>
    <tr>
      <th>Row</th>
      <th>station_id</th>
      <th>Work when grouping on <code class="language-plaintext highlighter-rouge">station_id</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>28</td>
      <td>hash 2 bytes; new group, so store 2 bytes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>403</td>
      <td>hash 2 bytes; new group, so store 2 bytes</td>
    </tr>
    <tr>
      <td>3</td>
      <td>28</td>
      <td>hash 2 bytes; existing group, so compare 2 bytes</td>
    </tr>
    <tr>
      <td>4</td>
      <td>28</td>
      <td>hash 2 bytes; existing group, so compare 2 bytes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>403</td>
      <td>hash 2 bytes; existing group, so compare 2 bytes</td>
    </tr>
  </tbody>
</table>

<p>The keys follow string order: <code class="language-plaintext highlighter-rouge">Amsterdam Centraal</code> sorts before <code class="language-plaintext highlighter-rouge">Rotterdam Centraal</code>, so it gets the smaller key (28 versus 403). With a key range this small and dense, DuckDB can use a perfect hash aggregate, indexing directly by the key instead of hashing.</p>

<p>You can also leave out this step and join the dimension table on the fly inside each query. That still keeps the aggregation's hash table narrow, but every query then pays for hashing the strings once in the join. Storing the key in the fact table removes string processing from the query.</p>

<h2 id="querying-the-encoded-table">Querying the Encoded Table</h2>

<p>With the key in place, the query aggregates on integers and only looks up the strings once the result is small. The <a href="/docs/current/sql/query_syntax/from.html#joins"><code class="language-plaintext highlighter-rouge">LEFT JOIN</code></a> keeps the group of rows without a station.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">WITH</span> <span class="n">rollup</span> <span class="k">AS</span> <span class="p">(</span>
    <span class="k">SELECT</span>
        <span class="n">station_id</span><span class="p">,</span>
        <span class="n">date</span><span class="p">,</span>
        <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">calls</span>
    <span class="k">FROM</span> <span class="n">train_services_encoded</span>
    <span class="k">GROUP</span> <span class="k">BY</span> <span class="k">ALL</span>
<span class="p">)</span>
<span class="k">SELECT</span> <span class="n">s.station_name</span><span class="p">,</span> <span class="n">rollup</span><span class="p">.</span><span class="o">*</span> <span class="k">EXCLUDE</span> <span class="p">(</span><span class="n">station_id</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">rollup</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">stations</span> <span class="n">s</span> <span class="k">USING</span> <span class="p">(</span><span class="n">station_id</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">station_id</span><span class="p">,</span> <span class="n">date</span><span class="p">;</span>
</code></pre></div></div>

<p><a href="/docs/current/sql/query_syntax/groupby.html#group-by-all"><code class="language-plaintext highlighter-rouge">GROUP BY ALL</code></a> groups by every selected column that is not aggregated, here <code class="language-plaintext highlighter-rouge">station_id</code> and <code class="language-plaintext highlighter-rouge">date</code>, so you don't repeat the list. The final join runs against the aggregated result, which has one row per group rather than one row per event. If the aggregation reduces a billion rows to a few hundred thousand, the join only looks up a few hundred thousand strings. Because the keys are sorted, <code class="language-plaintext highlighter-rouge">ORDER BY station_id</code> also sorts alphabetically by station name.</p>

<p>For top-N queries, apply the <a href="/docs/current/sql/query_syntax/limit.html"><code class="language-plaintext highlighter-rouge">LIMIT</code></a> before the join, so that only ten strings are looked up:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">WITH</span> <span class="n">top_stations</span> <span class="k">AS</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">station_id</span><span class="p">,</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">calls</span>
    <span class="k">FROM</span> <span class="n">train_services_encoded</span>
    <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">station_id</span>
    <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">calls</span> <span class="k">DESC</span>
    <span class="k">LIMIT</span> <span class="mi">10</span>
<span class="p">)</span>
<span class="k">SELECT</span> <span class="n">s.station_name</span><span class="p">,</span> <span class="n">top_stations.calls</span>
<span class="k">FROM</span> <span class="n">top_stations</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">stations</span> <span class="n">s</span> <span class="k">USING</span> <span class="p">(</span><span class="n">station_id</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">calls</span> <span class="k">DESC</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>station_name</th>
      <th>calls</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Utrecht Centraal</td>
      <td>7663</td>
    </tr>
    <tr>
      <td>Amsterdam Centraal</td>
      <td>7591</td>
    </tr>
    <tr>
      <td>Zwolle</td>
      <td>5013</td>
    </tr>
    <tr>
      <td>Schiphol Airport</td>
      <td>4961</td>
    </tr>
    <tr>
      <td>Amsterdam Sloterdijk</td>
      <td>4854</td>
    </tr>
    <tr>
      <td>…</td>
      <td>…</td>
    </tr>
  </tbody>
</table>

<p>To filter on the string, look up its key in the dimension table and filter the fact table on the integer.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">train_services_encoded</span>
<span class="k">WHERE</span> <span class="n">station_id</span> <span class="k">IN</span> <span class="p">(</span><span class="k">SELECT</span> <span class="n">station_id</span> <span class="k">FROM</span> <span class="n">stations</span> <span class="k">WHERE</span> <span class="n">station_name</span> <span class="k">LIKE</span> <span class="s1">'%Centraal%'</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="measuring-the-effect">Measuring the Effect</h2>

<p>How much you gain depends on your data, mainly on how long the strings are and how many distinct values they have. On this 380,000-row sample the difference is small, and it grows as row counts and string lengths increase. To find out for your own data, run the same aggregation both ways and compare:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">.timer</span> <span class="n">on</span>

<span class="c1">-- Group on the string</span>
<span class="k">SELECT</span> <span class="n">station_name</span><span class="p">,</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">calls</span>
<span class="k">FROM</span> <span class="n">train_services</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">station_name</span><span class="p">;</span>

<span class="c1">-- Group on the key, then join the strings back</span>
<span class="k">WITH</span> <span class="n">rollup</span> <span class="k">AS</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">station_id</span><span class="p">,</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">calls</span>
    <span class="k">FROM</span> <span class="n">train_services_encoded</span>
    <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">station_id</span>
<span class="p">)</span>
<span class="k">SELECT</span> <span class="n">s.station_name</span><span class="p">,</span> <span class="n">rollup.calls</span>
<span class="k">FROM</span> <span class="n">rollup</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">stations</span> <span class="n">s</span> <span class="k">USING</span> <span class="p">(</span><span class="n">station_id</span><span class="p">);</span>
</code></pre></div></div>

<p>To see where the time goes, prefix each query with <a href="/docs/current/sql/statements/profiling.html#explain-analyze"><code class="language-plaintext highlighter-rouge">EXPLAIN ANALYZE</code></a> and compare the timings of the aggregation operators. To compare memory use, set a lower <a href="/docs/current/configuration/overview.html#global-configuration-options"><code class="language-plaintext highlighter-rouge">memory_limit</code></a> with <a href="/docs/current/sql/statements/set.html"><code class="language-plaintext highlighter-rouge">SET</code></a> and check which query starts spilling to disk first.</p>

<h2 id="variations">Variations</h2>

<p>So far the examples use a single string column with a fixed set of values. The variations below cover a built-in alternative to the manual dimension table, encoding more than one string column, and keeping the keys current as new data arrives.</p>

<h3 id="comparison-with-enum">Comparison with ENUM</h3>

<p>DuckDB's <a href="/docs/current/sql/data_types/enum.html"><code class="language-plaintext highlighter-rouge">ENUM</code> type</a> is dictionary encoding built into the type system: values are stored as small integers, and DuckDB picks the integer width for you. The <a href="/2021/11/26/duck-enum.html">Lord of the Enums</a> blog post (2021) benchmarks this, with a <code class="language-plaintext highlighter-rouge">GROUP BY</code> on an <code class="language-plaintext highlighter-rouge">ENUM</code> column running faster than the same grouping on the raw strings. You can create one from a query:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TYPE</span> <span class="n">station_enum</span> <span class="k">AS</span> <span class="nb">ENUM</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="n">station_name</span> <span class="k">FROM</span> <span class="n">train_services</span> <span class="k">WHERE</span> <span class="n">station_name</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="nb">NULL</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">station_name</span>
<span class="p">);</span>
</code></pre></div></div>

<p>If the set of values is known up front and rarely changes, an <code class="language-plaintext highlighter-rouge">ENUM</code> gives you most of the benefit with none of the extra joins. The dimension table is the better choice when:</p>

<ul>
  <li><strong>New values keep arriving.</strong> An <code class="language-plaintext highlighter-rouge">ENUM</code>'s values are fixed when the type is created, so inserting an unknown value fails. A dimension table can grow.</li>
  <li><strong>You need attributes.</strong> A dimension table can carry extra columns, such as the station's city or the line it sits on, that you can group or filter on without parsing strings.</li>
  <li><strong>The data leaves DuckDB.</strong> Integer keys and a lookup table can be exported to <a href="/docs/current/data/parquet/overview.html">Parquet</a> or <a href="/docs/current/data/csv/overview.html">CSV</a> and used in other tools.</li>
</ul>

<h3 id="several-string-columns">Several String Columns</h3>

<p>The pattern applies per column: build one dimension table for each high-repetition string column. In this dataset both <code class="language-plaintext highlighter-rouge">station_name</code> and the service <code class="language-plaintext highlighter-rouge">type</code> qualify. Each key then gets its own integer type, sized to the cardinality of its column, and a query only joins back the dimensions it needs. The <code class="language-plaintext highlighter-rouge">type</code> column has only 15 distinct values, so its key fits in a <code class="language-plaintext highlighter-rouge">UTINYINT</code>, while <code class="language-plaintext highlighter-rouge">station_name</code> still needs a <code class="language-plaintext highlighter-rouge">USMALLINT</code>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">TABLE</span> <span class="n">service_types</span> <span class="k">AS</span>
    <span class="k">SELECT</span>
        <span class="n">type</span><span class="p">,</span>
        <span class="p">(</span><span class="nf">row_number</span><span class="p">()</span> <span class="k">OVER</span> <span class="p">(</span><span class="k">ORDER</span> <span class="k">BY</span> <span class="n">type</span><span class="p">))::</span><span class="nb">UTINYINT</span> <span class="k">AS</span> <span class="n">type_id</span>
    <span class="k">FROM</span> <span class="p">(</span><span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="n">type</span> <span class="k">FROM</span> <span class="n">train_services</span> <span class="k">WHERE</span> <span class="n">type</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="nb">NULL</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">TABLE</span> <span class="n">train_services_encoded</span> <span class="k">AS</span>
    <span class="k">SELECT</span> <span class="n">ts</span><span class="p">.</span><span class="o">*</span> <span class="k">EXCLUDE</span> <span class="p">(</span><span class="n">station_name</span><span class="p">,</span> <span class="n">type</span><span class="p">),</span> <span class="n">s.station_id</span><span class="p">,</span> <span class="n">t.type_id</span>
    <span class="k">FROM</span> <span class="n">train_services</span> <span class="n">ts</span>
    <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">stations</span> <span class="n">s</span> <span class="k">USING</span> <span class="p">(</span><span class="n">station_name</span><span class="p">)</span>
    <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">service_types</span> <span class="n">t</span> <span class="k">USING</span> <span class="p">(</span><span class="n">type</span><span class="p">);</span>
</code></pre></div></div>

<p>The fact table now carries both keys, and a query joins back only the dimensions it reads. A count of calls per station needs <code class="language-plaintext highlighter-rouge">stations</code>, while a breakdown by service type needs <code class="language-plaintext highlighter-rouge">service_types</code>.</p>

<p>If two columns always appear together, such as <code class="language-plaintext highlighter-rouge">station_code</code> and <code class="language-plaintext highlighter-rouge">station_name</code>, a single dimension table keyed on the combination is often simpler. The fact table then stores one key instead of two, which makes it narrower still.</p>

<h3 id="keeping-the-dimension-up-to-date">Keeping the Dimension Up to Date</h3>

<p>When new data arrives, add unseen strings with keys that continue after the current maximum:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">stations</span>
    <span class="k">SELECT</span>
        <span class="n">n.station_name</span><span class="p">,</span>
        <span class="p">((</span><span class="k">SELECT</span> <span class="nf">max</span><span class="p">(</span><span class="n">station_id</span><span class="p">)</span> <span class="k">FROM</span> <span class="n">stations</span><span class="p">)</span>
            <span class="o">+</span> <span class="nf">row_number</span><span class="p">()</span> <span class="k">OVER</span> <span class="p">(</span><span class="k">ORDER</span> <span class="k">BY</span> <span class="n">n.station_name</span><span class="p">))::</span><span class="nb">USMALLINT</span> <span class="k">AS</span> <span class="n">station_id</span>
    <span class="k">FROM</span> <span class="p">(</span><span class="k">SELECT</span> <span class="k">DISTINCT</span> <span class="n">station_name</span> <span class="k">FROM</span> <span class="n">new_train_services</span> <span class="k">WHERE</span> <span class="n">station_name</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="nb">NULL</span><span class="p">)</span> <span class="n">n</span>
    <span class="k">ANTI</span> <span class="k">JOIN</span> <span class="n">stations</span> <span class="k">USING</span> <span class="p">(</span><span class="n">station_name</span><span class="p">);</span>
</code></pre></div></div>

<p>The <a href="/docs/current/sql/query_syntax/from.html#semi-and-anti-joins"><code class="language-plaintext highlighter-rouge">ANTI JOIN</code></a> keeps only the names that are not already in <code class="language-plaintext highlighter-rouge">stations</code>, so the existing keys stay untouched and each genuinely new name gets a key that continues past the current maximum.</p>

<p>Then encode the new rows the same way as in Step 3 and append them:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">train_services_encoded</span>
    <span class="k">SELECT</span> <span class="n">n</span><span class="p">.</span><span class="o">*</span> <span class="k">EXCLUDE</span> <span class="p">(</span><span class="n">station_name</span><span class="p">),</span> <span class="n">s.station_id</span>
    <span class="k">FROM</span> <span class="n">new_train_services</span> <span class="n">n</span>
    <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">stations</span> <span class="n">s</span> <span class="k">USING</span> <span class="p">(</span><span class="n">station_name</span><span class="p">);</span>
</code></pre></div></div>

<p>Appended keys no longer follow alphabetical order. If your queries rely on <code class="language-plaintext highlighter-rouge">ORDER BY station_id</code> matching <code class="language-plaintext highlighter-rouge">ORDER BY station_name</code>, rebuild the dimension table and re-key the fact table periodically, or sort on the string after the final join.</p>

<h2 id="narrow-keys-do-not-reduce-the-number-of-groups">Narrow Keys Do Not Reduce the Number of Groups</h2>

<p>Dictionary encoding makes each group smaller. It does not reduce how many groups there are, and that matters for very high-cardinality aggregations.</p>

<p>The report that prompted this post, <a href="https://github.com/duckdb/duckdb/issues/14584">duckdb/duckdb#14584</a>, shows this. It grouped 9.2 billion rows into 320 million distinct groups and used far more memory than expected. The group key was already a <a href="/docs/current/sql/data_types/numeric.html#fixed-width-integer-types"><code class="language-plaintext highlighter-rouge">UBIGINT</code></a>, so there were no strings to encode.</p>

<p>The cause is how DuckDB parallelizes aggregation. Each thread first aggregates its share of the rows in its own thread-local hash table, and the partial results are combined at the end. This works well when each thread sees many repeats of the same groups, which is typical for real-world data.</p>

<p>In the report, with 8 threads, each thread saw about 1.1 billion rows: only about 3 times the number of distinct values, spread without any useful pattern. Nearly every group ended up in nearly every thread's table, so memory use approached the number of threads times the number of groups.</p>

<p>Narrow keys make each of those entries smaller, but they cannot prevent the duplication. When the number of groups is close to the number of rows each thread processes, reducing the number of <a href="/docs/current/configuration/overview.html#global-configuration-options"><code class="language-plaintext highlighter-rouge">threads</code></a> helps more, because each thread holds its own copy of each group.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="py">threads</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
</code></pre></div></div>

<p>This trades speed for memory, so reserve it for aggregations that would otherwise run out of memory or spill large amounts of data to disk. Data that is clustered by the group key also helps, as each thread then sees a smaller, more distinct set of groups.</p>

<h2 id="when-the-pattern-does-not-help">When the Pattern Does Not Help</h2>

<p>The pattern makes the schema and the queries more complex, so it is not always worth it.</p>

<p>Don't use it when:</p>

<ul>
  <li><strong>The strings are short.</strong> Values of up to 12 bytes are already stored inline, so the gap to an integer key is much smaller.</li>
  <li><strong>The column is nearly unique.</strong> If most values are distinct, such as IDs or free text, the dimension table has almost as many rows as the fact table and little is saved.</li>
  <li><strong>You query the data once.</strong> Building the dimension table and rewriting the fact table costs a full pass over the data. That cost is only worth paying if you query the data repeatedly.</li>
  <li><strong>You don't aggregate on the column.</strong> If you only ever filter on or display the string, the benefit is limited.</li>
</ul>

<p>In these cases, keep the string column as it is. If you are unsure, compare both versions as described in <a href="#measuring-the-effect">Measuring the Effect</a>.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Grouping on repeated strings is expensive. By moving them into a small dimension table with sorted, narrow integer keys, DuckDB can aggregate on fixed-width integers and keep its hash tables compact. The strings come back in a final join against a result that is already small.</p>

<p>This approach helps most when you repeatedly aggregate on long strings that have few distinct values. You can find a condensed version of this tip in the <a href="/docs/current/guides/performance/schema.html">Performance Guide</a>.</p>]]></content>
      

      
        <author>
          <name>The DuckDB team</name>
        </author>
      

      
        
      

      
        <category term="using-duckdb" />
      

      
      
        <summary type="html"><![CDATA[When a query groups on long, repeated strings, move the strings into a small dimension table with sorted, narrow integer keys. Aggregate on the keys, then join the strings back in at the very end. The query works as before, though on small fixed-width integers instead of variable-length text.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/dimension-table.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/dimension-table.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">Jev and DuckDB: Plain-English Conditions in SQL</title>
      <link href="https://duckdb.org/2026/09/29/jev.html" rel="alternate" type="text/html" title="Jev and DuckDB: Plain-English Conditions in SQL" />
      <published>2026-09-29T00:00:00+00:00</published>
      <updated>2026-09-29T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/09/29/jev</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/09/29/jev.html"><![CDATA[<p>Say you have a Parquet file of 50,000 support tickets and want to know which customers are angry. A <a href="/docs/lts/sql/functions/pattern_matching.html"><code class="language-plaintext highlighter-rouge">LIKE</code> pattern</a> won't find that. The usual options are to label data and train a classifier, or to send each row to a chat LLM and parse the text it returns. The first takes work up front. The second is slow and costs more per row.</p>

<p>On September 15, 2026, <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">TypeSafe AI introduced Jev</a>, a model that answers this kind of question with a typed value and a probability instead of a paragraph. Within a week, <a href="https://x.com/hamiltonulmer/status/2100370557405667768">Hamilton Ulmer posted on X</a> that he had built a DuckDB extension that classifies about 1,000 rows from any CSV, Parquet file or table in roughly ten seconds. Other DuckDB extensions appeared over the next few days.</p>

<p>This post describes what Jev is, how the extensions work and where to read more.</p>

<h2 id="what-jev-is">What Jev Is</h2>

<p>Jev is what TypeSafe calls a <em>System One model</em>: you send it a state and a set of questions, and it returns typed answers with calibrated probabilities. It never generates a sentence. In the <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">launch post</a>, founder Diogo Almeida (who worked on the research behind ChatGPT at OpenAI) describes it as a function call backed by frontier intelligence. The name refers to Kahneman's fast System 1 thinking and to the economist William Stanley Jevons.</p>

<p>There are three question types, and each corresponds to something SQL already has:</p>

<table>
  <thead>
    <tr>
      <th>Jev type</th>
      <th>What it returns</th>
      <th>SQL analogue</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Noul</td>
      <td>A yes/no judgment with a probability</td>
      <td>A <code class="language-plaintext highlighter-rouge">BOOLEAN</code> predicate in <code class="language-plaintext highlighter-rouge">WHERE</code></td>
    </tr>
    <tr>
      <td>Choice</td>
      <td>One option from a fixed list (up to 255)</td>
      <td>An <code class="language-plaintext highlighter-rouge">ENUM</code> column you can <code class="language-plaintext highlighter-rouge">GROUP BY</code></td>
    </tr>
    <tr>
      <td>Score</td>
      <td>A position on an ordered rubric</td>
      <td>An ordinal you can <code class="language-plaintext highlighter-rouge">ORDER BY</code></td>
    </tr>
  </tbody>
</table>

<p>TypeSafe quotes 70–500 ms end-to-end latency and $0.042 per million input tokens, with output tokens free. All questions in a request are evaluated in parallel, so asking five questions about a row takes little more time than asking one. The launch post lists “map-reducing over big data” as a target use case, which is the kind of work people use DuckDB for.</p>

<p>For independent context, <a href="https://thenewstack.io/typesafe-jev-system-one/">The New Stack's launch coverage</a> covers the $40 million seed round. <a href="https://www.langchain.com/blog/building-a-harness-with-jev">LangChain's post on building a harness with Jev</a> shows where it fits inside an agent loop. <a href="https://www.hyperstack.cloud/technical-resources/tutorials/jev-inside-typesafe-ais-first-system-one-decision-model">Hyperstack's deep dive</a> reproduces the scoring mechanism on an open model and checks TypeSafe's speed claims against LangChain's benchmark. Note that the headline 190× speedup figures come from TypeSafe's own workflow evals.</p>

<h2 id="pg-jev-and-the-duckdb-ports">pg-jev and the DuckDB Ports</h2>

<p>The first SQL integration was for Postgres. On September 17, Zachi <a href="https://x.com/iam_zachi/status/2100679300756435135">posted pg-jev on X</a>: a <code class="language-plaintext highlighter-rouge">jev()</code> function that turns a plain-English condition into a <code class="language-plaintext highlighter-rouge">WHERE</code> clause, with no index and no embeddings. His demo judged 129 rows in about a second for under a tenth of a cent, and you can try it on mock tables in the <a href="https://pgjev.zachi.dev/">pg-jev live demo</a>. The <a href="https://github.com/realZachi/pg-jev">source is on GitHub</a>. Four days later, <a href="https://runtimewire.com/article/actian-hires-pg-jev-creator-mahmoud-zachi-enterprise-ai">Actian hired him</a>.</p>

<p>DuckDB ports followed within days. The closest to the original is <a href="https://github.com/judoaseeta/duckdb-jev">judoaseeta/duckdb-jev</a>, which keeps pg-jev's function names.</p>

<h3 id="installation">Installation</h3>

<p>With the exception of the <a href="/community_extensions/extensions/jev"><code class="language-plaintext highlighter-rouge">jev</code> community extension</a>, most of the extensions are not yet distributed through the community extensions repository, so you build them yourself and load them as unsigned extensions.
For the rest of the post, we'll use the <code class="language-plaintext highlighter-rouge">jev</code> community extension. To install and load it, run:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSTALL</span><span class="n"> jev</span> <span class="k">FROM</span> <span class="n">community</span><span class="p">;</span>
<span class="k">LOAD</span><span class="n"> jev</span><span class="p">;</span>
</code></pre></div></div>

<blockquote>
  <p>The <code class="language-plaintext highlighter-rouge">jev</code> community extension is supported in DuckDB v1.5.5 and v1.5.6.</p>
</blockquote>

<p>Then set your TypeSafe API key, which you can get from the <a href="https://console.typesafe.ai/">TypeSafe console</a> after registering and topping up your account:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="n">jev_api_key</span> <span class="o">=</span> <span class="s1">'...'</span><span class="p">;</span>
</code></pre></div></div>

<p>The extension must be built for the exact DuckDB version and platform you run.</p>

<h3 id="an-example">An Example</h3>

<blockquote>
  <p>Warning All of the extensions in this post send row contents to a third-party API. Do not use them on data you are not allowed to share.</p>
</blockquote>

<p>Let's start with filtering using the <code class="language-plaintext highlighter-rouge">person</code> table of the <a href="https://ldbcouncil.org/benchmarks/snb/datasets/">LDBC SF0.1 dataset</a>.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Filter: a boolean predicate</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">person</span> <span class="k">AS</span>
<span class="k">FROM</span> <span class="s1">'https://blobs.duckdb.org/data/ldbc-sf0.1-person.parquet'</span><span class="p">;</span>

<span class="k">SELECT</span> <span class="n">id</span><span class="p">,</span> <span class="n">firstName</span><span class="p">,</span> <span class="n">lastName</span>
<span class="k">FROM</span> <span class="n">person</span>
<span class="k">WHERE</span> <span class="n">jev</span><span class="p">(</span><span class="n">person</span><span class="p">,</span> <span class="s1">'the name is European'</span><span class="p">)</span>
<span class="k">LIMIT</span> <span class="mi">5</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────────┬───────────┬──────────┐
│       id       │ firstName │ lastName │
│     int64      │  varchar  │ varchar  │
├────────────────┼───────────┼──────────┤
│           1129 │ Carmen    │ Lepland  │
│ 10995116278700 │ Joseph    │ Anderson │
│ 28587302322727 │ Steve     │ Moore    │
│ 30786325578904 │ Giuseppe  │ Donati   │
│  6597069766983 │ A. C.     │ Bos      │
└────────────────┴───────────┴──────────┘
</code></pre></div></div>

<p>In a ticketing system, you could rank, classify or score the tickets as follows:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Rank: a probability you can sort on</span>
<span class="k">SELECT</span> <span class="n">subject</span><span class="p">,</span> <span class="n">jev_prob</span><span class="p">(</span><span class="n">tickets</span><span class="p">,</span> <span class="s1">'the customer is angry'</span><span class="p">)</span> <span class="k">AS</span> <span class="n">p</span>
<span class="k">FROM</span> <span class="n">tickets</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">p</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="mi">20</span><span class="p">;</span>

<span class="c1">-- Classify: one of a fixed set of labels</span>
<span class="k">SELECT</span>
    <span class="n">jev_choice</span><span class="p">(</span><span class="n">tickets</span><span class="p">,</span> <span class="s1">'which team should handle this?'</span><span class="p">,</span>
               <span class="p">[</span><span class="s1">'billing'</span><span class="p">,</span> <span class="s1">'technical'</span><span class="p">,</span> <span class="s1">'security'</span><span class="p">,</span> <span class="s1">'sales'</span><span class="p">])</span> <span class="k">AS</span> <span class="n">team</span><span class="p">,</span>
    <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">tickets</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">;</span>

<span class="c1">-- Score: a position on an ordered rubric</span>
<span class="k">SELECT</span>
    <span class="n">name</span><span class="p">,</span>
    <span class="n">jev_score</span><span class="p">(</span><span class="n">products</span><span class="p">,</span> <span class="s1">'how luxurious is this product?'</span><span class="p">,</span>
              <span class="p">[</span><span class="s1">'budget'</span><span class="p">,</span> <span class="s1">'mid-range'</span><span class="p">,</span> <span class="s1">'premium'</span><span class="p">,</span> <span class="s1">'luxury'</span><span class="p">])</span> <span class="k">AS</span> <span class="n">luxury</span>
<span class="k">FROM</span> <span class="n">products</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">luxury</span> <span class="k">DESC</span><span class="p">;</span>
</code></pre></div></div>

<p>Because <code class="language-plaintext highlighter-rouge">jev()</code> returns a boolean, it works with the rest of SQL. If you add a cheaper condition such as <code class="language-plaintext highlighter-rouge">age &gt; 40</code> in the same <code class="language-plaintext highlighter-rouge">WHERE</code> clause, it runs first, and rows it rejects are never sent to the API. A <code class="language-plaintext highlighter-rouge">LIMIT</code> stops the scan early, and <code class="language-plaintext highlighter-rouge">jev_max_rows_per_statement</code> caps the spend.</p>

<p>The video <a href="https://www.youtube.com/watch?v=31VCA2Zlkc8">“I Tested Jev with DuckDB. It's 1.8x Faster Than Haiku.”</a> demonstrates plain-English SQL queries in DuckDB and compares Jev's speed with Claude Haiku. The 1.8× figure is from one test.</p>

<h2 id="the-duckdb-extensions">The DuckDB Extensions</h2>

<p>Several people built these extensions independently, and they made different choices about batching, caching and output types.</p>

<table>
  <thead>
    <tr>
      <th>Extension</th>
      <th>Angle</th>
      <th>Notable detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/judoaseeta/duckdb-jev">judoaseeta/duckdb-jev</a></td>
      <td>Port of pg-jev</td>
      <td>Same <code class="language-plaintext highlighter-rouge">jev</code>, <code class="language-plaintext highlighter-rouge">jev_prob</code>, <code class="language-plaintext highlighter-rouge">jev_choice</code>, <code class="language-plaintext highlighter-rouge">jev_score</code> API, plus a process-wide cache keyed by row content</td>
    </tr>
    <tr>
      <td><a href="https://github.com/recodelabs/duck-jev">recodelabs/duck-jev</a></td>
      <td>Natural-language <code class="language-plaintext highlighter-rouge">WHERE</code> clauses</td>
      <td>Groups rows by question, judges duplicates once, runs batches across threads and shares a database-scoped cache</td>
    </tr>
    <tr>
      <td><a href="https://github.com/prasanthj/duckdb-jev">prasanthj/duckdb-jev</a></td>
      <td>Throughput and production controls</td>
      <td>Packs up to 1,000 judgments per request, streams across DuckDB chunks, reports usage through <code class="language-plaintext highlighter-rouge">jev_usage()</code></td>
    </tr>
    <tr>
      <td><a href="https://madewithjev.com/builds/duckdb-jev">Colliber's duckdb-jev</a></td>
      <td>Typed results</td>
      <td>Adds <code class="language-plaintext highlighter-rouge">jev_choice</code>, <code class="language-plaintext highlighter-rouge">jev_score</code>, <code class="language-plaintext highlighter-rouge">jev_noul</code> and <code class="language-plaintext highlighter-rouge">jev_ask</code>. A choice's options become an <code class="language-plaintext highlighter-rouge">ENUM</code> column type</td>
    </tr>
  </tbody>
</table>

<p>DuckDB passes rows to an extension in chunks of up to 2,048, which suits batched API calls. The <a href="https://github.com/recodelabs/duck-jev">recodelabs extension</a> groups each chunk by question and options, skips identical rows and sends the rest in batches across <code class="language-plaintext highlighter-rouge">jev_concurrency</code> threads. It retries 429s and 5xx errors with exponential backoff.</p>

<p>Batching makes a large difference to speed. The <a href="https://github.com/prasanthj/duckdb-jev">prasanthj extension</a> reports a 0.211 s median for 100 nested JSON rows sent in batches of 25, compared with 16.8 s one row at a time. A 2,049-row run took under a second. The author notes these timings come from one local run on synthetic data. Larger batches may cost accuracy, though: pg-jev's docs report that accuracy drops above roughly 20–25 rows per request, so test batch sizes on your own data.</p>

<p><a href="https://madewithjev.com/builds/duckdb-jev">Colliber's version</a> uses Jev's typed output directly. Choice options are defined in advance, so the result can be a DuckDB <code class="language-plaintext highlighter-rouge">ENUM</code> rather than text that needs parsing. The prasanthj build can evaluate nested JSON, <code class="language-plaintext highlighter-rouge">STRUCT</code>, <code class="language-plaintext highlighter-rouge">LIST</code> and <code class="language-plaintext highlighter-rouge">ARRAY</code> values without exporting them to Python.</p>

<p>Some replies under <a href="https://x.com/hamiltonulmer/status/2100370557405667768">Hamilton Ulmer's post</a> point out that a fine-tuned ModernBERT is still cheaper once training is amortized, and that treating classification as embedding retrieval can reach about 20,000 rows per second on CPU. Jev makes most sense when you have no labeled data and a new question to ask. For a fixed classification task at high volume, a trained model may be cheaper and faster.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Jev returns a <code class="language-plaintext highlighter-rouge">BOOLEAN</code>, an <code class="language-plaintext highlighter-rouge">ENUM</code> or an ordinal score, and DuckDB can filter, group and sort on all three. With these extensions, you can write a condition in English and use it alongside joins, <code class="language-plaintext highlighter-rouge">GROUP BY</code> and window functions.</p>

<p>Jev is in early access behind a waitlist, the extensions are days old and unsigned, and most performance numbers come from their own authors. Start on a small, non-sensitive sample, watch your usage counters and compare against a baseline before you trust the labels. If you build something with Jev and DuckDB, the extension authors welcome issues and pull requests on their repositories.</p>

<h2 id="further-reading">Further Reading</h2>

<ul>
  <li><a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">Introducing System One Models &amp; Jev</a> (TypeSafe AI's launch post)</li>
  <li><a href="https://thenewstack.io/typesafe-jev-system-one/">TypeSafe launched Jev because sequential LLMs are “totally useless for computers”</a> (The New Stack)</li>
  <li><a href="https://www.langchain.com/blog/building-a-harness-with-jev">What Is Jev? A Guide to TypeSafe AI's System One Model</a> (LangChain blog)</li>
  <li><a href="https://www.hyperstack.cloud/technical-resources/tutorials/jev-inside-typesafe-ais-first-system-one-decision-model">Jev: TypeSafe AI's First System One Decision Model</a> (Hyperstack)</li>
  <li><a href="https://www.youtube.com/watch?v=31VCA2Zlkc8">I Tested Jev with DuckDB. It's 1.8x Faster Than Haiku.</a> (YouTube)</li>
  <li><a href="https://www.youtube.com/watch?v=rwNrCHS3BM8">Calling Jev from SQL</a> (YouTube)</li>
  <li><a href="https://x.com/iam_zachi/status/2100679300756435135">pg-jev launch post</a> and <a href="https://pgjev.zachi.dev/">live demo</a> (Zachi)</li>
  <li><a href="https://x.com/hamiltonulmer/status/2100370557405667768">Hamilton Ulmer's DuckDB extension post</a> (X)</li>
</ul>]]></content>
      

      
        <author>
          <name>Geertjan Wielenga</name>
        </author>
      
        <author>
          <name>Gábor Szárnyas</name>
        </author>
      

      
        
      

      
        <category term="extensions" />
      

      
      
        <summary type="html"><![CDATA[TypeSafe AI released Jev, a model that returns typed answers instead of text, on September 15, 2026. Within ten days, several community extensions let you filter, classify and score DuckDB rows with plain-English conditions. This post covers what Jev is, how the extensions work and the posts and videos about them.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/jev-duckdb.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/jev-duckdb.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">Announcing DuckDB 1.5.6</title>
      <link href="https://duckdb.org/2026/09/28/announcing-duckdb-156.html" rel="alternate" type="text/html" title="Announcing DuckDB 1.5.6" />
      <published>2026-09-28T00:00:00+00:00</published>
      <updated>2026-09-28T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/09/28/announcing-duckdb-156</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/09/28/announcing-duckdb-156.html"><![CDATA[<p>In this blog post, we highlight a few important fixes in DuckDB v1.5.6, the sixth patch release in <a href="/2026/03/09/announcing-duckdb-150.html">DuckDB's 1.5 (Variegata) line</a>.
The release ships bugfixes, performance improvements and security patches. You can find the full <a href="https://github.com/duckdb/duckdb/releases/tag/v1.5.6">release notes on GitHub</a>.</p>

<p>To install the new version, please visit the <a href="/install/">installation page</a>.</p>

<h2 id="fixes">Fixes</h2>

<p>Here are the most important fixes from the DuckDB v1.5.6 release, organized by category:</p>

<h3 id="correctness">Correctness</h3>

<ul>
  <li><a href="https://github.com/duckdb/duckdb/pull/24240"><code class="language-plaintext highlighter-rouge">#24240</code></a> – Fix <code class="language-plaintext highlighter-rouge">LIMIT</code> pushdown through a volatile projection with an <code class="language-plaintext highlighter-rouge">OFFSET</code></li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24239"><code class="language-plaintext highlighter-rouge">#24239</code></a> – Don't push filters on volatile groups through aggregates</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24119"><code class="language-plaintext highlighter-rouge">#24119</code></a> – Fix <code class="language-plaintext highlighter-rouge">UNNEST</code> pushdown</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24399"><code class="language-plaintext highlighter-rouge">#24399</code></a> – Preserve <code class="language-plaintext highlighter-rouge">NULL</code>s in Top-N window elimination when the <code class="language-plaintext highlighter-rouge">ORDER BY</code> expression has no column references</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24551"><code class="language-plaintext highlighter-rouge">#24551</code></a> – Fix Top-N window elimination for nullable ordering expressions</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25831"><code class="language-plaintext highlighter-rouge">#25831</code></a> – Fix wrong results from common subplan elimination with <code class="language-plaintext highlighter-rouge">UNION ALL</code> arms sharing a join subtree</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25714"><code class="language-plaintext highlighter-rouge">#25714</code></a> – Fix silent truncation of very long integer literals into <code class="language-plaintext highlighter-rouge">HUGEINT</code></li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25766"><code class="language-plaintext highlighter-rouge">#25766</code></a> – Fix <code class="language-plaintext highlighter-rouge">max</code> on Hive partition column after file pruning</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24438"><code class="language-plaintext highlighter-rouge">#24438</code></a> – Fix ICU <code class="language-plaintext highlighter-rouge">strptime</code> leaking time zone state between rows</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24845"><code class="language-plaintext highlighter-rouge">#24845</code></a> – Fix <code class="language-plaintext highlighter-rouge">GEOMETRY</code> row group pruning with <code class="language-plaintext highlighter-rouge">NULL</code>s and empty geometries</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25728"><code class="language-plaintext highlighter-rouge">#25728</code></a> – Fix reading and writing <code class="language-plaintext highlighter-rouge">TIME_NS</code> values in Parquet</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/26027"><code class="language-plaintext highlighter-rouge">#26027</code></a> – Fix Parquet v2 value count mismatch for <code class="language-plaintext highlighter-rouge">NULL</code>s in a list that fills a page</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/26162"><code class="language-plaintext highlighter-rouge">#26162</code></a> – Fix Parquet <code class="language-plaintext highlighter-rouge">VARIANT</code> shredding for <code class="language-plaintext highlighter-rouge">REQUIRED</code> fields and element groups</li>
</ul>

<h3 id="crashes-and-internal-errors">Crashes and Internal Errors</h3>

<ul>
  <li><a href="https://github.com/duckdb/duckdb/pull/25103"><code class="language-plaintext highlighter-rouge">#25103</code></a> – Fix crash in Top-N with <code class="language-plaintext highlighter-rouge">LIMIT 0</code></li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24427"><code class="language-plaintext highlighter-rouge">#24427</code></a> – Fix segfault in <code class="language-plaintext highlighter-rouge">url_decode</code> with <code class="language-plaintext highlighter-rouge">TRY()</code> on dictionary-encoded columns</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24447"><code class="language-plaintext highlighter-rouge">#24447</code></a> – Fix failed checkpoint marker recovery</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25490"><code class="language-plaintext highlighter-rouge">#25490</code></a> – Close the main WAL handle before renaming over it during WAL recovery</li>
</ul>

<h3 id="generic-bugfixes">Generic Bugfixes</h3>

<ul>
  <li><a href="https://github.com/duckdb/duckdb/pull/24065"><code class="language-plaintext highlighter-rouge">#24065</code></a> – Automatically roll back failed implicitly wrapped multi-statements on all paths</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25693"><code class="language-plaintext highlighter-rouge">#25693</code></a> – Fix dead node counting in ART indexes</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25573"><code class="language-plaintext highlighter-rouge">#25573</code></a> – Report the real storage version when opening a DuckDB v2.0+ database file</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25808"><code class="language-plaintext highlighter-rouge">#25808</code></a> – Reject invalid UTF-8 produced by <code class="language-plaintext highlighter-rouge">printf</code>'s <code class="language-plaintext highlighter-rouge">%c</code> conversion</li>
</ul>

<h3 id="miscellaneous">Miscellaneous</h3>

<ul>
  <li><a href="https://github.com/duckdb/duckdb/pull/26102"><code class="language-plaintext highlighter-rouge">#26102</code></a> – Add <code class="language-plaintext highlighter-rouge">enable_optimistic_write</code> setting</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25283"><code class="language-plaintext highlighter-rouge">#25283</code></a> – Harden temporary file reads</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/24362"><code class="language-plaintext highlighter-rouge">#24362</code></a> – Unify C API symbol versioning for clients and extensions, stabilize all v1 APIs</li>
  <li><a href="https://github.com/duckdb/duckdb/pull/25214"><code class="language-plaintext highlighter-rouge">#25214</code></a> – Always quote identifiers in error messages</li>
</ul>

<h2 id="looking-ahead-duckdb-v20-on-windows">Looking Ahead: DuckDB v2.0 on Windows</h2>

<p>If you have read this far, don't miss out on a sneak peek at <a href="/2026/09/02/try-duckdb-20-alpha.html">DuckDB v2.0.0-dev</a> for Windows.
First, extensions are now available for <a href="/install/preview.html">these clients</a>.
Second, we recently ran a benchmark to measure the performance improvement that the new clients bring, and the results blew our minds!</p>

<p>We used Windows 11 25H2 on a <a href="/2025/09/08/duckdb-on-the-framework-laptop-13.html">laptop</a> with 128 GB RAM and 12 AMD Ryzen AI 300 CPU cores with simultaneous multithreading (yielding 24 threads).
We used the <a href="/docs/current/core_extensions/tpch.html">TPC-H</a> SF300 dataset and ran each query twice on both DuckDB v1.5.6 and v2.0.0-dev (alpha43586).
For each query, we took the runtime of the second (hot) run.
The total runtime for DuckDB v1.5.6 was 822 seconds, while for v2.0.0-dev it was 129 seconds.
That's more than 6× faster!</p>

<p><img src="/images/blog/windows-tpch-sf300.svg" alt="Windows performance on v1.5.6 vs. v2.0.0-dev" /></p>

<p>The detailed results are as follows:</p>

<div class="monospace_table"></div>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Query</th>
      <th style="text-align: right">v1.5.6 (s)</th>
      <th style="text-align: right">v2.0.0-dev (s)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td style="text-align: right">17.590</td>
      <td style="text-align: right">19.954</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td style="text-align: right">1.065</td>
      <td style="text-align: right">1.972</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td style="text-align: right">4.282</td>
      <td style="text-align: right">8.787</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td style="text-align: right">5.310</td>
      <td style="text-align: right">8.386</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td style="text-align: right">4.562</td>
      <td style="text-align: right">6.097</td>
    </tr>
    <tr>
      <td style="text-align: right">6</td>
      <td style="text-align: right">1.167</td>
      <td style="text-align: right">1.057</td>
    </tr>
    <tr>
      <td style="text-align: right">7</td>
      <td style="text-align: right">5.091</td>
      <td style="text-align: right">3.405</td>
    </tr>
    <tr>
      <td style="text-align: right">8</td>
      <td style="text-align: right">4.986</td>
      <td style="text-align: right">5.359</td>
    </tr>
    <tr>
      <td style="text-align: right">9</td>
      <td style="text-align: right">16.662</td>
      <td style="text-align: right">17.505</td>
    </tr>
    <tr>
      <td style="text-align: right">10</td>
      <td style="text-align: right">60.919</td>
      <td style="text-align: right">7.619</td>
    </tr>
    <tr>
      <td style="text-align: right">11</td>
      <td style="text-align: right">6.797</td>
      <td style="text-align: right">0.674</td>
    </tr>
    <tr>
      <td style="text-align: right">12</td>
      <td style="text-align: right">3.350</td>
      <td style="text-align: right">2.579</td>
    </tr>
    <tr>
      <td style="text-align: right">13</td>
      <td style="text-align: right">174.847</td>
      <td style="text-align: right">13.256</td>
    </tr>
    <tr>
      <td style="text-align: right">14</td>
      <td style="text-align: right">20.044</td>
      <td style="text-align: right">2.001</td>
    </tr>
    <tr>
      <td style="text-align: right">15</td>
      <td style="text-align: right">22.836</td>
      <td style="text-align: right">1.807</td>
    </tr>
    <tr>
      <td style="text-align: right">16</td>
      <td style="text-align: right">43.019</td>
      <td style="text-align: right">1.953</td>
    </tr>
    <tr>
      <td style="text-align: right">17</td>
      <td style="text-align: right">4.958</td>
      <td style="text-align: right">2.805</td>
    </tr>
    <tr>
      <td style="text-align: right">18</td>
      <td style="text-align: right">230.175</td>
      <td style="text-align: right">11.453</td>
    </tr>
    <tr>
      <td style="text-align: right">19</td>
      <td style="text-align: right">23.013</td>
      <td style="text-align: right">2.416</td>
    </tr>
    <tr>
      <td style="text-align: right">20</td>
      <td style="text-align: right">24.303</td>
      <td style="text-align: right">2.391</td>
    </tr>
    <tr>
      <td style="text-align: right">21</td>
      <td style="text-align: right">67.344</td>
      <td style="text-align: right">5.766</td>
    </tr>
    <tr>
      <td style="text-align: right">22</td>
      <td style="text-align: right">79.726</td>
      <td style="text-align: right">1.533</td>
    </tr>
  </tbody>
</table>

<p>This improvement is thanks to several optimizations, including a <a href="https://github.com/duckdb/duckdb/pull/24391">switch to the <code class="language-plaintext highlighter-rouge">clang-cl</code> compiler</a> and a <a href="https://github.com/duckdb/duckdb/pull/24036">new allocator</a>.
That said, you can observe that there are some regressions for the time being (we are working on them!) and the 6× speedup may not generalize to all workloads – but rest assured that you should see significant improvements for most workloads on Windows once you upgrade to DuckDB v2.0.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This post was a short summary of the changes in v1.5.6. As usual, you can find the <a href="https://github.com/duckdb/duckdb/releases/tag/v1.5.6">full release notes on GitHub</a>.
We would like to thank our contributors for providing detailed issue reports and patches.
Stay tuned for <a href="/release_calendar.html">future DuckDB releases</a>, including v2.0.0 in October!</p>]]></content>
      

      
        <author>
          <name>The DuckDB team</name>
        </author>
      

      
        
      

      
        <category term="release" />
      

      
      
        <summary type="html"><![CDATA[Today we are releasing DuckDB 1.5.6 with bugfixes and performance improvements.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/duckdb-release-1-5-6.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/duckdb-release-1-5-6.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <entry>
      

      <title type="html">DuckDB and Hugging Face: Querying Datasets Directly</title>
      <link href="https://duckdb.org/2026/09/25/hugging-face.html" rel="alternate" type="text/html" title="DuckDB and Hugging Face: Querying Datasets Directly" />
      <published>2026-09-25T00:00:00+00:00</published>
      <updated>2026-09-25T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/09/25/hugging-face</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/09/25/hugging-face.html"><![CDATA[<p><a href="https://huggingface.co/">Hugging Face</a> is where much of the machine learning community publishes and finds its datasets, while DuckDB is the in-process analytical database that queries files like CSV and Parquet directly, with no server or warehouse to install or run.</p>

<p>Did you know that, since DuckDB <a href="https://github.com/duckdb/duckdb/releases/tag/v0.10.3">v0.10.3</a> (released on May 22, 2024), you can point a <code class="language-plaintext highlighter-rouge">SELECT</code> at a dataset on the <a href="https://huggingface.co/docs/hub">Hugging Face Hub</a>, using the DuckDB <code class="language-plaintext highlighter-rouge">hf://</code> protocol, and query it, without downloading it first? This post covers how that integration works and the use cases it fits.</p>

<h2 id="background">Background</h2>

<p>On the <a href="https://huggingface.co/docs/hub">Hugging Face Hub</a>, each dataset is a git repository holding its data as plain files, usually CSV, JSONL, or Parquet. The <a href="https://huggingface.co/datasets/cais/mmlu"><code class="language-plaintext highlighter-rouge">cais/mmlu</code></a> benchmark and the <a href="https://huggingface.co/datasets/datasets-examples/doc-formats-csv-1"><code class="language-plaintext highlighter-rouge">datasets-examples/doc-formats-csv-1</code></a> repository used later in this post are two such examples: you can browse their files and commit history in the browser, the same way you would any git repository.</p>

<p>Before the Hugging Face integration, getting at that data from DuckDB meant downloading the files first, or loading them with the Hugging Face <a href="https://huggingface.co/docs/datasets"><code class="language-plaintext highlighter-rouge">datasets</code></a> library, before they could be read and analyzed. Either way, the data had to be copied out of the <a href="https://huggingface.co/docs/hub">Hugging Face Hub</a> before you could query it.</p>

<p>DuckDB was already able to read remote files over HTTP through its <a href="/docs/current/core_extensions/httpfs/overview.html"><code class="language-plaintext highlighter-rouge">httpfs</code> extension</a>, so reading a URL directly was not new. Hugging Face datasets are also increasingly published as Parquet, the columnar format DuckDB reads natively and can scan without materializing everything in memory.</p>

<p>DuckDB and Hugging Face <a href="/2024/05/29/access-150k-plus-datasets-from-hugging-face-with-duckdb.html">worked together</a> to add the <a href="/docs/lts/core_extensions/httpfs/hugging_face.html"><code class="language-plaintext highlighter-rouge">hf://</code> path scheme</a> on top of <code class="language-plaintext highlighter-rouge">httpfs</code>, <a href="/2024/05/29/access-150k-plus-datasets-from-hugging-face-with-duckdb.html">announced in May 2024</a> with DuckDB <a href="https://github.com/duckdb/duckdb/releases/tag/v0.10.3">v0.10.3</a>. As a result, DuckDB can resolve a dataset repository to the files inside it, so that a query can read and analyze them exactly where they are located, instead of via a downloaded copy.</p>

<h2 id="reading-hugging-face-datasets-directly">Reading Hugging Face Datasets Directly</h2>

<p>The examples below cover the common cases. <a href="/docs/lts/core_extensions/httpfs/hugging_face.html">DuckDB's Hugging Face docs</a> are the full reference. The scheme maps a Hugging Face dataset repository onto a path DuckDB can read:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">hf</span><span class="p">:</span><span class="o">//</span><span class="n">datasets</span><span class="o">/</span><span class="ge">my_username</span><span class="o">/</span><span class="ge">my_dataset</span><span class="o">/</span><span class="ge">path_to_file</span>
</code></pre></div></div>

<p>Reading a file is then just a query. This reads the CSV file from the <a href="https://huggingface.co/datasets/datasets-examples/doc-formats-csv-1"><code class="language-plaintext highlighter-rouge">datasets-examples/doc-formats-csv-1</code></a> repository:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/datasets-examples/doc-formats-csv-1/data.csv'</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>kind</th>
      <th>sound</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>dog</td>
      <td>woof</td>
    </tr>
    <tr>
      <td>cat</td>
      <td>meow</td>
    </tr>
    <tr>
      <td>pokemon</td>
      <td>pika</td>
    </tr>
    <tr>
      <td>human</td>
      <td>hello</td>
    </tr>
  </tbody>
</table>

<p>Here <code class="language-plaintext highlighter-rouge">datasets-examples</code> is the user or organization, <code class="language-plaintext highlighter-rouge">doc-formats-csv-1</code> is the dataset repository, and <code class="language-plaintext highlighter-rouge">data.csv</code> is the file inside it. The same example data is published in the <a href="https://huggingface.co/datasets/datasets-examples/doc-formats-jsonl-1"><code class="language-plaintext highlighter-rouge">doc-formats-jsonl-1</code></a> and <a href="https://huggingface.co/datasets/datasets-examples/doc-formats-parquet-1"><code class="language-plaintext highlighter-rouge">doc-formats-parquet-1</code></a> repositories, so these queries return the same four rows:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/datasets-examples/doc-formats-jsonl-1/data.jsonl'</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/datasets-examples/doc-formats-parquet-1/data/train-00000-of-00001.parquet'</span><span class="p">;</span>
</code></pre></div></div>

<p>DuckDB infers the format from the file and reads only the columns that the query actually needs. And, nothing is downloaded to a local copy first.</p>

<h3 id="querying-many-files-at-once">Querying Many Files at Once</h3>

<p>Datasets are often split across many files. A <a href="/docs/current/data/multiple_files/overview.html#multi-file-reads-and-globs">glob pattern</a> lets you treat a whole directory as one table. The <a href="https://huggingface.co/datasets/cais/mmlu"><code class="language-plaintext highlighter-rouge">cais/mmlu</code></a> benchmark stores its <code class="language-plaintext highlighter-rouge">astronomy</code> task across three Parquet files (<code class="language-plaintext highlighter-rouge">dev</code>, <code class="language-plaintext highlighter-rouge">test</code>, and <code class="language-plaintext highlighter-rouge">validation</code>), and this counts the rows across all of them:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">count</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/cais/mmlu/astronomy/*.parquet'</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th style="text-align: right">count</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">173</td>
    </tr>
  </tbody>
</table>

<p>Because DuckDB reads Parquet column by column, you can filter across all those files without pulling every row into memory:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">count</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/cais/mmlu/astronomy/*.parquet'</span>
<span class="k">WHERE</span> <span class="n">question</span> <span class="k">LIKE</span> <span class="s1">'%planet%'</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th style="text-align: right">count</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">21</td>
    </tr>
  </tbody>
</table>

<h3 id="versions-and-the-parquet-branch">Versions and the <code class="language-plaintext highlighter-rouge">~parquet</code> Branch</h3>

<p>Each Hugging Face dataset is a git repository, so it has branches and revisions. You can pin a query to a specific one with an <code class="language-plaintext highlighter-rouge">@</code> suffix:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/datasets-examples/doc-formats-csv-1@~parquet/**/*.parquet'</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>kind</th>
      <th>sound</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>dog</td>
      <td>woof</td>
    </tr>
    <tr>
      <td>cat</td>
      <td>meow</td>
    </tr>
    <tr>
      <td>pokemon</td>
      <td>pika</td>
    </tr>
    <tr>
      <td>human</td>
      <td>hello</td>
    </tr>
  </tbody>
</table>

<p>Hugging Face automatically converts every dataset into Parquet on this special <code class="language-plaintext highlighter-rouge">~parquet</code> branch to make it efficient to scan. So even a dataset published as CSV or JSONL usually has a columnar version ready, which is what DuckDB reads fastest.</p>

<h3 id="saving-a-local-copy">Saving a Local Copy</h3>

<p>If you are going to query the same data repeatedly, materialize it once so you are not hitting the remote endpoint each time:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">data</span> <span class="k">AS</span>
    <span class="k">SELECT</span> <span class="o">*</span>
    <span class="k">FROM</span> <span class="s1">'hf://datasets/datasets-examples/doc-formats-csv-1/data.csv'</span><span class="p">;</span>
</code></pre></div></div>

<p>After that, the data lives in the local table and queries no longer touch the <a href="https://huggingface.co/docs/hub">Hugging Face Hub</a>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">data</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>kind</th>
      <th>sound</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>dog</td>
      <td>woof</td>
    </tr>
    <tr>
      <td>cat</td>
      <td>meow</td>
    </tr>
    <tr>
      <td>pokemon</td>
      <td>pika</td>
    </tr>
    <tr>
      <td>human</td>
      <td>hello</td>
    </tr>
  </tbody>
</table>

<h3 id="private-and-gated-datasets">Private and Gated Datasets</h3>

<p>Public datasets need no setup. For private or gated ones, store a Hugging Face token in DuckDB's <a href="/docs/current/configuration/secrets_manager.html">Secrets Manager</a>. You can pass the token directly:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">SECRET</span> <span class="n">hf_token</span> <span class="p">(</span>
    <span class="k">TYPE</span> <span class="n">huggingface</span><span class="p">,</span>
    <span class="k">TOKEN</span> <span class="s1">'your_hf_token'</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Or let DuckDB pick it up from <code class="language-plaintext highlighter-rouge">~/.cache/huggingface/token</code>, where the Hugging Face tooling stores it:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">SECRET</span> <span class="n">hf_token</span> <span class="p">(</span>
    <span class="k">TYPE</span> <span class="n">huggingface</span><span class="p">,</span>
    <span class="k">PROVIDER</span> <span class="k">credential_chain</span>
<span class="p">);</span>
</code></pre></div></div>

<h2 id="typical-use-cases">Typical Use Cases</h2>

<p>The integration is a good fit whenever you want to look at data on the <a href="https://huggingface.co/docs/hub">Hugging Face Hub</a> without committing to a download or a pipeline.</p>

<ul>
  <li>
    <p><strong>Exploring a dataset before you use it.</strong> Before training or finetuning a dataset, you usually want to know what is in it: the row count, how many rows are unique, and what the columns look like. A single query against an <code class="language-plaintext highlighter-rouge">hf://</code> path answers that, reading only the columns you ask for:</p>

    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">questions</span><span class="p">,</span>
    <span class="nf">count</span><span class="p">(</span><span class="k">DISTINCT</span> <span class="n">question</span><span class="p">)</span> <span class="k">AS</span> <span class="n">distinct_questions</span><span class="p">,</span>
    <span class="nf">avg</span><span class="p">(</span><span class="nf">len</span><span class="p">(</span><span class="n">choices</span><span class="p">))</span> <span class="k">AS</span> <span class="n">avg_choices</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/cais/mmlu/astronomy/*.parquet'</span><span class="p">;</span>
</code></pre></div>    </div>

    <table>
      <thead>
        <tr>
          <th style="text-align: right">questions</th>
          <th style="text-align: right">distinct_questions</th>
          <th style="text-align: right">avg_choices</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td style="text-align: right">173</td>
          <td style="text-align: right">166</td>
          <td style="text-align: right">4.0</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>
    <p><strong>Filtering and sampling for training.</strong> Large datasets often need to be narrowed to a subset, say a single language or the rows above some quality threshold, before they are useful. Express that as a <code class="language-plaintext highlighter-rouge">WHERE</code> clause and write the result straight to a local Parquet file with <code class="language-plaintext highlighter-rouge">COPY</code>:</p>

    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">COPY</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">question</span><span class="p">,</span> <span class="n">choices</span><span class="p">,</span> <span class="n">answer</span>
    <span class="k">FROM</span> <span class="s1">'hf://datasets/cais/mmlu/astronomy/*.parquet'</span>
    <span class="k">WHERE</span> <span class="n">question</span> <span class="k">LIKE</span> <span class="s1">'%planet%'</span>
<span class="p">)</span> <span class="k">TO</span> <span class="s1">'astronomy_planets.parquet'</span><span class="p">;</span>
</code></pre></div>    </div>

    <p>This turns a remote dataset into a focused local file, here the 21 astronomy questions that mention a planet.</p>
  </li>
  <li>
    <p><strong>Working with benchmarks and evaluation sets.</strong> Benchmarks like <a href="https://huggingface.co/datasets/cais/mmlu">MMLU</a> ship as many small files grouped by task. You can read several tasks as one table and compute per-task statistics:</p>

    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">subject</span><span class="p">,</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">questions</span>
<span class="k">FROM</span> <span class="nf">read_parquet</span><span class="p">([</span>
    <span class="s1">'hf://datasets/cais/mmlu/astronomy/test-00000-of-00001.parquet'</span><span class="p">,</span>
    <span class="s1">'hf://datasets/cais/mmlu/anatomy/test-00000-of-00001.parquet'</span>
<span class="p">])</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">subject</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">subject</span><span class="p">;</span>
</code></pre></div>    </div>

    <table>
      <thead>
        <tr>
          <th>subject</th>
          <th style="text-align: right">questions</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>anatomy</td>
          <td style="text-align: right">135</td>
        </tr>
        <tr>
          <td>astronomy</td>
          <td style="text-align: right">152</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>
    <p><strong>Joining Hugging Face Hub data with your own.</strong> Because an <code class="language-plaintext highlighter-rouge">hf://</code> path behaves like any other table source, you can join a public dataset against your own tables. Here a small lookup table maps each numeric answer to a choice letter:</p>

    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">l.letter</span> <span class="k">AS</span> <span class="n">correct_choice</span><span class="p">,</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">n</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/cais/mmlu/astronomy/*.parquet'</span> <span class="k">AS</span> <span class="n">m</span>
<span class="k">JOIN</span> <span class="p">(</span><span class="k">VALUES</span> <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="s1">'A'</span><span class="p">),</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'B'</span><span class="p">),</span> <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'C'</span><span class="p">),</span> <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'D'</span><span class="p">))</span> <span class="k">AS</span> <span class="n">l</span><span class="p">(</span><span class="n">idx</span><span class="p">,</span> <span class="n">letter</span><span class="p">)</span>
  <span class="k">ON</span> <span class="n">m.answer</span> <span class="o">=</span> <span class="n">l.idx</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">l.letter</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">l.letter</span><span class="p">;</span>
</code></pre></div>    </div>

    <table>
      <thead>
        <tr>
          <th>correct_choice</th>
          <th style="text-align: right">n</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>A</td>
          <td style="text-align: right">35</td>
        </tr>
        <tr>
          <td>B</td>
          <td style="text-align: right">32</td>
        </tr>
        <tr>
          <td>C</td>
          <td style="text-align: right">48</td>
        </tr>
        <tr>
          <td>D</td>
          <td style="text-align: right">58</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>
    <p><strong>Reproducible analysis.</strong> Pinning a query to a specific commit means it reads the same data every time it runs, which matters for anything you need to reproduce later. Add the revision with an <code class="language-plaintext highlighter-rouge">@</code> suffix:</p>

    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">count</span>
<span class="k">FROM</span> <span class="s1">'hf://datasets/cais/mmlu@c30699e8356da336a370243923dbaf21066bb9fe/astronomy/*.parquet'</span><span class="p">;</span>
</code></pre></div>    </div>

    <table>
      <thead>
        <tr>
          <th style="text-align: right">count</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td style="text-align: right">173</td>
        </tr>
      </tbody>
    </table>
  </li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>The <a href="/docs/lts/core_extensions/httpfs/hugging_face.html"><code class="language-plaintext highlighter-rouge">hf://</code> protocol</a> lets you query a dataset on the <a href="https://huggingface.co/docs/hub">Hugging Face Hub</a> by putting its path in a <code class="language-plaintext highlighter-rouge">SELECT</code>, with no download step and no server to run.</p>

<p>If you work with datasets on the <a href="https://huggingface.co/docs/hub">Hugging Face Hub</a>, that covers a lot of day-to-day tasks, from inspecting a new dataset to creating a training subset out of a larger one.</p>

<p>For further reading, see the original <a href="/2024/05/29/access-150k-plus-datasets-from-hugging-face-with-duckdb.html">announcement post</a>, <a href="/docs/lts/core_extensions/httpfs/hugging_face.html">DuckDB's Hugging Face docs</a>, and Hugging Face's own <a href="https://huggingface.co/docs/hub/datasets-duckdb">DuckDB guide</a>.</p>]]></content>
      

      
        <author>
          <name>The DuckDB team</name>
        </author>
      

      
        
      

      
        <category term="extensions" />
      

      
      
        <summary type="html"><![CDATA[Hugging Face hosts hundreds of thousands of datasets, and DuckDB can read them directly, exactly where they are and without downloading anything, over the DuckDB `hf://` protocol. This post looks at how the integration works and the scenarios where it works best.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/hugging-face.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/hugging-face.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
</feed>
