<?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-11-09T12:43:21+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">How JetBrains Built Cross-Database Queries into DataGrip with DuckDB</title>
      <link href="https://duckdb.org/2026/11/05/jetbrains-datagrip.html" rel="alternate" type="text/html" title="How JetBrains Built Cross-Database Queries into DataGrip with DuckDB" />
      <published>2026-11-05T00:00:00+00:00</published>
      <updated>2026-11-05T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/11/05/jetbrains-datagrip</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/11/05/jetbrains-datagrip.html"><![CDATA[<p>When <a href="https://www.jetbrains.com/">JetBrains</a> set out to let developers <a href="https://www.jetbrains.com/datagrip/whatsnew/#connectivity">join data across different database connections in a single query</a>, it chose not to build its own cross-database query engine. Instead, <a href="https://www.jetbrains.com/datagrip/">DataGrip</a>, its database IDE, embeds DuckDB to execute those queries, and exposes DuckDB’s SQL dialect directly to the developers writing them.</p>

<blockquote class="quote-large">
  <p>Quote</p>

  <p>DuckDB turned out to be the simplest option to integrate.</p>

  <p>— Ilia Epelbaum, DataGrip, JetBrains</p>
</blockquote>

<h2 id="querying-across-data-sources">Querying across Data Sources</h2>

<p>DataGrip is JetBrains’ IDE for working with databases and writing SQL, developed since 2015. The same database functionality is also available as a plugin inside the other JetBrains IDEs, so the feature reaches well beyond DataGrip itself.</p>

<p>For a long time, users had been asking for one thing in particular: the ability to run a single query across more than one data source. The most common request was to join data from different database connections in one query. DataGrip could already execute queries against each connection, but nothing tied the results together, so the team needed an engine that could.</p>

<h2 id="choosing-duckdb-over-building-its-own">Choosing DuckDB over Building Its Own</h2>

<p>JetBrains explored several approaches. It considered <a href="https://calcite.apache.org/">Apache Calcite</a>, handling the queries itself using <a href="https://plugins.jetbrains.com/docs/intellij/psi.html">PSI</a> (Program Structure Interface, the internal code representation JetBrains uses across its IDEs), and DuckDB.</p>

<p>DuckDB won on ease of integration. <strong>“DuckDB turned out to be the simplest option to integrate,”</strong> says Ilia Epelbaum, who works on the DataGrip team. The deciding feature was the ability to register a custom table function: it gave JetBrains a straightforward way to connect DuckDB to the query-execution functionality DataGrip already had. DuckDB was also preferred over lighter alternatives such as <a href="https://h2database.com/html/main.html">H2</a> because it brought more capabilities, including query optimizations like projection pushdown.</p>

<p>Maxim Sobolevskiy, Head of Database Ecosystem at JetBrains, puts the result in concrete terms. <strong>“With DuckDB and its support for custom table functions, cross-database queries have finally become practical in DataGrip,”</strong> he says. <strong>“Users can join data from PostgreSQL, MySQL, MongoDB, or any other supported database in one query, without setting up extra infrastructure.”</strong> The embedded alternatives the team evaluated could not do this, because <strong>“extending them required either native code or a complicated implementation.”</strong></p>

<p>Epelbaum came to the project knowing the territory. He had been a database developer working on <a href="https://trino.io/">Trino</a> before moving into database tooling, and already knew how flexible DuckDB was. That background is part of why the team reached for it rather than writing and maintaining a cross-database query engine of its own.</p>

<h2 id="duckdb-in-plain-sight">DuckDB in Plain Sight</h2>

<p>Developers know DuckDB is there. Because a cross-source join runs through a DuckDB table function, the query itself is written in DuckDB’s SQL dialect, and DataGrip tells developers so. <strong>“We highlight that it’s DuckDB,”</strong> Epelbaum says, so that someone writing a join across connections knows they are writing a DuckDB query.</p>

<p>JetBrains has gone further and supports DuckDB as a first-class dialect in its own right, not only as the engine behind cross-source joins. Epelbaum contributed syntax highlighting for DuckDB, including the syntax introduced in <a href="/2026/10/21/announcing-duckdb-200.html">DuckDB 2.0</a>.</p>

<h2 id="an-initial-solution-still-evolving">An Initial Solution, Still Evolving</h2>

<p>This is still a first version. <strong>“We’ve built the initial solution, and we want to see how users actually use it in practice,”</strong> Epelbaum says. Only after that does the team plan to layer on more of DuckDB’s optimizations.</p>

<p>Two directions are on the roadmap. The first is to take fuller advantage of DuckDB’s query optimizations. The second is to use DuckDB for file-based data sources, reading files the way it already reads across connections. Epelbaum also singled out DuckDB’s <a href="/docs/2.0/sql/dialect/friendly_sql.html">friendly SQL</a> as something the team values in day-to-day use.</p>

<p>For now, DuckDB sits at the core of a feature that JetBrains users had wanted for years, letting them join across their databases without JetBrains having to become a query-engine vendor to make it happen.</p>

<h2 id="about-jetbrains-and-datagrip">About JetBrains and DataGrip</h2>

<p>JetBrains builds professional developer tools, including IntelliJ IDEA, PyCharm, and the Kotlin language, that have served millions of programmers for more than 20 years. <a href="https://www.jetbrains.com/datagrip/">DataGrip</a>, its database IDE, helps developers and data engineers query, explore, and manage multiple databases from a single environment.</p>]]></content>
      

      
        <author>
          <name>Ilia Epelbaum</name>
        </author>
      
        <author>
          <name>Geertjan Wielenga</name>
        </author>
      
        <author>
          <name>Tim Cox</name>
        </author>
      

      
        
      

      
        <category term="case-study" />
      

      
      
        <summary type="html"><![CDATA[JetBrains users had long asked to join data across different database connections in a single query. Rather than build its own cross-database query engine, JetBrains embedded DuckDB in DataGrip, using DuckDB’s custom table functions to connect it to the IDE’s existing query execution. This is why DuckDB won out over the alternatives, and where the integration goes next.]]></summary>
      

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

      <title type="html">Bringing Local Analytics to the Quant&apos;s Desktop</title>
      <link href="https://duckdb.org/2026/11/03/bringing-local-analytics-to-the-quants-desktop.html" rel="alternate" type="text/html" title="Bringing Local Analytics to the Quant&apos;s Desktop" />
      <published>2026-11-03T00:00:00+00:00</published>
      <updated>2026-11-03T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/11/03/bringing-local-analytics-to-the-quants-desktop</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/11/03/bringing-local-analytics-to-the-quants-desktop.html"><![CDATA[<blockquote class="quote-large">
  <p>Quote</p>

  <p>I like the simplicity of just a small thing running and working. And then you can get more complex.</p>

  <p>— Ryan Hamilton, founder, <a href="https://www.timestored.com/">TimeStored</a></p>
</blockquote>

<h2 id="fifteen-years-of-heavyweight-infrastructure">Fifteen Years of Heavyweight Infrastructure</h2>

<p>For about fifteen years, <a href="https://www.linkedin.com/in/ryananthonyhamilton/">Ryan Hamilton</a> has built large market-data platforms for banks and hedge funds. Most of that work was in <a href="https://kx.com/">kdb+</a>, integrated with real-time messaging systems such as <a href="https://solace.com/">Solace</a>. He built much of the platform at UBS and almost all of it at Citi, contributed to Morgan Stanley's, and most recently worked at a hedge fund.</p>

<p>His users are typically teams of 10 to 15 quants. They are deep specialists in finance who spend anywhere from a tenth to more than half their time on technology. Ryan's job has been to store their data efficiently so they can get on with their work. Sometimes he leads a team to do that. Sometimes he builds it himself. Sometimes he talks quants out of building infrastructure on their own.</p>

<h2 id="problems-that-didnt-need-a-heavyweight-answer">Problems That Didn't Need a Heavyweight Answer</h2>

<p>Alongside that work, startups, smaller tech firms, and smaller funds kept asking Ryan the same question: what should we run this on?</p>

<p>For years he had no good answer. He tried other systems but found that their license was prohibitively expensive, their setup didn't offer an embedded mode of operation, or they were much more operationally involved than he wanted at the time. For some clients, the honest answer was that he would write something in C for their narrow use case.</p>

<h2 id="a-different-starting-point">A Different Starting Point</h2>

<p>DuckDB was different. It was small, embedded, reliable, and analytically capable, and a team could start with it inside an application without standing up a server.</p>

<p><strong>“I like the simplicity of just a small thing running and working,”</strong> Ryan says. <strong>“And then you can get more complex.”</strong></p>

<p>That let him give smaller firms a straight recommendation. DuckDB might not scale to everything, but it would run well and serve them for a long time. For roughly 90% of use cases, he reasoned, it would carry a team for years. By the time they outgrew it, they would understand their workload well enough to choose what came next. In many cases, DuckDB has expanded quickly enough that they never needed to move.</p>

<h2 id="qstudio-a-local-analytical-sandbox">QStudio: A Local Analytical Sandbox</h2>

<p>The clearest illustration is <a href="https://www.timestored.com/qstudio/">QStudio</a>, the analytics IDE Ryan released about 13 years ago. It was originally built for kdb+ and now connects to around 15 databases. He leaves database administration to tools like <a href="https://dbeaver.io/">DBeaver</a> and focuses QStudio on analytics and charting.</p>

<p>DuckDB was the first deep integration, and it changed how quants work with expensive queries. The workflow runs like this. An analyst runs a heavy query against a remote database. Some of these take up to 20 minutes. QStudio then saves the result locally as Parquet through DuckDB, with a click in the UI.</p>

<p>The next day, the analyst queries that saved result directly, without rerunning the original workload:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">symbol</span><span class="p">,</span>
    <span class="nf">avg</span><span class="p">(</span><span class="n">price</span><span class="p">)</span> <span class="k">AS</span> <span class="n">avg_price</span><span class="p">,</span>
    <span class="nf">sum</span><span class="p">(</span><span class="n">volume</span><span class="p">)</span> <span class="k">AS</span> <span class="n">total_volume</span>
<span class="k">FROM</span> <span class="s1">'results/trades_2026-11-10.parquet'</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="k">ALL</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">total_volume</span> <span class="k">DESC</span><span class="p">;</span>
</code></pre></div></div>

<p>The result is a personal sandbox where quants can build on their own earlier work. It proved popular, and Ryan found its performance close to kdb+'s.</p>

<p>Because QStudio is a Java Swing desktop application deployed inside banks, the integration depends on DuckDB's <a href="/docs/2.0/clients/java/overview.html">Java client</a>. Ryan's early version relied on inefficient workarounds to get appending working. Once a dedicated Java developer joined and rebuilt the <a href="/docs/2.0/clients/java/data_import.html">Appender</a> properly, every kink was ironed out.</p>

<p><strong>“It just worked,”</strong> Ryan says, <strong>“which is the highest praise I would give any engineering thing.”</strong></p>

<p>That experience became the basis of his DuckCon talk. His argument was that an open-source engine already beat a far more exotic and expensive stack in some areas. DuckDB has moved well ahead since then.</p>

<h2 id="pulse-duckdb-by-default">Pulse: DuckDB by Default</h2>

<p>QStudio was never a significant direct revenue generator, but it got a very small company onto the approved-vendor lists of some very large firms, which is hard for small vendors to do. When Ryan's company, TimeStored, launched <a href="https://www.timestored.com/pulse/">Pulse</a>, a real-time HTML visualization tool that is now its main product, that path into large firms already existed.</p>

<p>Ryan reached for DuckDB again. Pulse uses it to snapshot and cache data. What began as an integration experiment had become a repeated TimeStored architecture pattern. DuckDB sits in the embedded storage and query layer, and increasingly supports AI-assisted data exploration. Excel exports and similar user-facing features stay in the application's UI. That is partly to keep styling intact, and partly because bank users often can't install extensions at all.</p>

<h2 id="peachq-back-to-q">PeachQ: Back to Q</h2>

<p>Ryan's newest project is <a href="https://peachq.org/">PeachQ</a>, an open-source implementation of the <a href="https://en.wikipedia.org/wiki/Q_(programming_language_from_Kx_Systems)">Q language</a>. Ryan loves Q as a language but thinks DuckDB is ahead as a database, so PeachQ separates the two.</p>

<p>PeachQ uses the Rayforce in-memory engine and DuckDB-backed historical storage to deliver a modern, open-source alternative to kdb+, bringing together real-time processing, powerful analytics, and efficient storage in a single system. Ryan's recent work on C extensions points the same way, along with experiments with newer formats such as <a href="/docs/2.0/core_extensions/vortex.html">Vortex</a> and custom table formats. He credits DuckDB's <a href="/docs/2.0/extensions/overview.html">extension</a> API with letting people add features cleanly, unlike the fragile workarounds he and others once built into closed systems. Often, he notes, DuckDB later builds those features itself.</p>

<h2 id="learn-more">Learn More</h2>

<p>Ryan's DuckCon talk gives a short tour of this work.</p>

<div class="video-container">
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/viMXP_TG6tU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>
</div>

<p>You can explore QStudio, now open source, and try PeachQ at <a href="https://peachq.org/">peachq.org</a>.</p>

<p>We would like to thank Ryan for the conversation. You can follow his work on DuckDB, Q, and TimeStored at <a href="https://www.timestored.com/">timestored.com</a>. And if you are reading this, are using DuckDB, and would like to share your story, drop us a line at <a href="mailto:geertjan@ducklabs.com">geertjan@ducklabs.com</a>.</p>]]></content>
      

      
        <author>
          <name>Ryan Hamilton</name>
        </author>
      
        <author>
          <name>Geertjan Wielenga</name>
        </author>
      
        <author>
          <name>Tim Cox</name>
        </author>
      

      
        
      

      
        <category term="case-study" />
      

      
      
        <summary type="html"><![CDATA[After years building kdb+-based data platforms for banks and hedge funds, Ryan Hamilton wanted an analytical engine he could recommend to smaller teams without specialized infrastructure or expensive licensing. DuckDB gave him a small, embedded way to bring fast local analytics into QStudio and Pulse, and it is now shaping his next open-source project, PeachQ.]]></summary>
      

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

      <title type="html">From Spreadsheets to DuckLake: A Data Engineer&apos;s Road to DuckDB</title>
      <link href="https://duckdb.org/2026/10/29/from-spreadsheets-to-ducklake.html" rel="alternate" type="text/html" title="From Spreadsheets to DuckLake: A Data Engineer&apos;s Road to DuckDB" />
      <published>2026-10-29T00:00:00+00:00</published>
      <updated>2026-10-29T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/29/from-spreadsheets-to-ducklake</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/29/from-spreadsheets-to-ducklake.html"><![CDATA[<blockquote class="quote-large">
  <p>Quote</p>

  <p>There actually is something out there that's easy to use and doesn't require a bunch of infrastructure overhead.</p>

  <p>— Matt Martin</p>
</blockquote>

<h2 id="from-copy-paste-to-code">From Copy-Paste to Code</h2>

<p>Matt's career started in 2008 the way many data careers do: with spreadsheets. As a fresh graduate, he spent his days copying and pasting between Excel, Microsoft Access, and SQL Server. Assembling a single report could take three hours, and one mistake meant starting over.</p>

<p><strong>“There's got to be an easier way,”</strong> he remembers thinking. His dad taught him how to write VBA macros, and that was the first step. Next, he got access to the SQL Server and Oracle databases behind those spreadsheets and started querying the source directly instead of other people's workbooks. Macros turned into scheduled stored procedures, and stored procedures turned into automated pipelines.</p>

<p>When he started, the team published its 20 to 30 daily reports by 10 AM. By the time he had automated the process, the reports were out by 6 AM, before anyone had touched anything.</p>

<h2 id="teradata-bigquery-and-the-spark-years">Teradata, BigQuery, and the Spark Years</h2>

<p>From there, Matt moved into big data. Teradata gave him his first taste of massively parallel processing, along with lessons about data skew and distributing work evenly across nodes. Around 2016, at The Home Depot, he moved to Google BigQuery. BigQuery did not support stored procedures at the time, so Matt picked up Python, then pandas and eventually Apache Spark.</p>

<p>Spark was powerful, but it was also heavy. Even local testing meant aligning JVM and Spark versions, deciphering stack traces that ran for hundreds of lines, and waiting three to five minutes for the cluster to warm up before processing a few hundred files. <strong>“A lot of battle scars,”</strong> as Matt puts it.</p>

<p>He is also skeptical that most teams need that machinery. Outside a handful of domains such as aerospace, fraud detection, and high-frequency trading, he rarely sees workloads that truly need to churn through petabytes at speed. More often, it is the classic meme: someone insists on real-time data, and it turns out to be for a monthly report.</p>

<h2 id="meeting-duckdb">Meeting DuckDB</h2>

<p>Matt first came across DuckDB around 2022. At first, he was not sure what to make of it: it was not pandas, not Spark, and not SQLite, yet it promised SQL for transforming data. Then he installed it and read a CSV file with two lines of code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">duckdb</span>
<span class="n">duckdb</span><span class="p">.</span><span class="n">sql</span><span class="p">(</span><span class="s">"FROM 'sales.csv'"</span><span class="p">).</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>

<p><strong>“There actually is something out there that's easy to use and doesn't require a bunch of infrastructure overhead,”</strong> he says. No JVM, no knobs to turn. From local files, he moved on to files on Amazon S3, over the <a href="/docs/2.0/core_extensions/httpfs/s3api.html"><code class="language-plaintext highlighter-rouge">s3://</code></a> protocol:</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="p">(</span><span class="k">TYPE</span> <span class="k">s3</span><span class="p">,</span> <span class="k">PROVIDER</span> <span class="k">credential_chain</span><span class="p">);</span>

<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">num_rows</span>
<span class="k">FROM</span> <span class="s1">'s3://</span><span class="ge">bucket</span><span class="s1">/exports/*.parquet'</span><span class="p">;</span>
</code></pre></div></div>

<p>And from there, to mixing CSV, Parquet, and Iceberg tables in a single query:</p>

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

<span class="k">SELECT</span>
    <span class="n">o.order_id</span><span class="p">,</span>
    <span class="n">c.customer_name</span><span class="p">,</span>
    <span class="n">p.product_name</span>
<span class="k">FROM</span> <span class="nf">iceberg_scan</span><span class="p">(</span><span class="s1">'s3://</span><span class="ge">bucket</span><span class="s1">/warehouse/orders'</span><span class="p">)</span> <span class="k">AS</span> <span class="n">o</span>
<span class="k">JOIN</span> <span class="s1">'s3://</span><span class="ge">bucket</span><span class="s1">/customers.parquet'</span> <span class="k">AS</span> <span class="n">c</span>
    <span class="k">ON</span> <span class="n">o.customer_id</span> <span class="o">=</span> <span class="n">c.customer_id</span>
<span class="k">JOIN</span> <span class="s1">'products.csv'</span> <span class="k">AS</span> <span class="n">p</span>
    <span class="k">ON</span> <span class="n">o.product_id</span> <span class="o">=</span> <span class="n">p.product_id</span><span class="p">;</span>
</code></pre></div></div>

<p>Two things made DuckDB a natural fit. First, simplicity: after nearly two decades in data engineering, Matt values a frictionless experience above almost anything else, as long as the SLAs are met. Second, SQL: after 15 years of writing advanced SQL, he found that DuckDB supported practically all of the syntax he already knew.</p>

<p>He also uses DuckDB for his personal finances, analyzing CSV exports of his bank transactions locally, the kind of query that takes a minute to write:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="nf">date_trunc</span><span class="p">(</span><span class="s1">'month'</span><span class="p">,</span> <span class="n">transaction_date</span><span class="p">)</span> <span class="k">AS</span> <span class="n">month</span><span class="p">,</span>
    <span class="n">category</span><span class="p">,</span>
    <span class="nf">sum</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span> <span class="k">AS</span> <span class="n">total</span>
<span class="k">FROM</span> <span class="s1">'transactions_*.csv'</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="k">ALL</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">month</span><span class="p">,</span> <span class="n">total</span><span class="p">;</span>
</code></pre></div></div>

<p>He is experimenting with LLM-based assistants for this, but likes knowing that he does not depend on them: <strong>“If LLMs ever become too expensive, I could easily pick right back up with DuckDB and write it myself. It's like riding a bike at this point.”</strong></p>

<h2 id="why-ducklake">Why DuckLake</h2>

<p>As open table formats took off, Matt worked extensively with Apache Iceberg and Delta Lake. Both are good at managing large datasets and their version history. The trouble starts when the metadata grows. Iceberg writes several metadata files per transaction, including metadata JSON, manifest lists, and manifests. Over time, a query that used to plan in seconds can slow down significantly because thousands of metadata files need to be resolved first.</p>

<p><a href="https://ducklake.select/">DuckLake</a> takes a different approach: it stores all metadata in a SQL database. <strong>“Organizations have been doing SQL for over four decades and have gotten very, very good at optimizing queries,”</strong> Matt says. In his experience, catalog operations that take seconds with Iceberg resolve in under a millisecond with DuckLake, because they are queries against indexed tables.</p>

<p>What Matt likes most is how little setup it takes: standing up an Iceberg lakehouse involves a lot of tooling, while a DuckLake on object storage takes two statements, one to create a secret for the bucket and one to attach the lake:</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="p">(</span><span class="k">TYPE</span> <span class="k">s3</span><span class="p">,</span> <span class="k">PROVIDER</span> <span class="k">credential_chain</span><span class="p">);</span>

<span class="k">ATTACH</span> <span class="s1">'ducklake:</span><span class="ge">metadata.ducklake</span><span class="s1">'</span> <span class="k">AS</span> <span class="n">my_lake</span> <span class="p">(</span>
    <span class="k">DATA_PATH</span> <span class="s1">'s3://</span><span class="ge">bucket</span><span class="s1">/</span><span class="ge">prefix</span><span class="s1">/'</span>
<span class="p">);</span>
</code></pre></div></div>

<p>If the DuckLake does not exist yet, DuckDB creates it and you can start creating tables right away. Matt has also written about <a href="https://performancede.substack.com/p/streaming-events-to-ducklake">streaming events straight into a DuckLake</a>.</p>

<p>The same simplicity applies to existing Iceberg catalogs. Matt has <a href="https://performancede.substack.com/">written on his Substack</a> about the Spark configuration of 20-plus lines he once needed to query an Iceberg table in AWS Glue, just to get the right JARs and the right AWS settings. With DuckDB's <a href="/docs/2.0/core_extensions/iceberg/overview.html">Iceberg extension</a>, it takes three statements:</p>

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

<span class="k">ATTACH</span> <span class="s1">'</span><span class="ge">aws_account_id</span><span class="s1">'</span> <span class="k">AS</span> <span class="n">glue_catalog</span> <span class="p">(</span>
    <span class="k">TYPE</span> <span class="k">iceberg</span><span class="p">,</span>
    <span class="k">ENDPOINT_TYPE</span> <span class="k">glue</span>
<span class="p">);</span>
</code></pre></div></div>

<h2 id="looking-forward-to-duckdb-20">Looking Forward to DuckDB 2.0</h2>

<p>Matt has been testing the DuckDB 2.0 alpha and has not run into any regressions so far. Several features stand out for him.</p>

<p><strong>Dynamic <code class="language-plaintext highlighter-rouge">ATTACH</code> statements.</strong> While writing the book, Matt needed to attach AWS Glue catalogs in examples. Up to v1.5, the <code class="language-plaintext highlighter-rouge">ATTACH</code> path had to be a literal, so the account number had to be hard-coded in the script, as calling <code class="language-plaintext highlighter-rouge">getenv</code> raised an error.</p>

<p>Matt filed a feature request, and a pull request followed within two days. In v2.0, <code class="language-plaintext highlighter-rouge">ATTACH</code> accepts expressions, so account IDs, usernames, passwords, and connection strings can come from environment variables at runtime instead of living in the script:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ATTACH</span> <span class="nf">getenv</span><span class="p">(</span><span class="s1">'AWS_ACCOUNT_ID'</span><span class="p">)</span> <span class="k">AS</span> <span class="n">glue_catalog</span> <span class="p">(</span>
    <span class="k">TYPE</span> <span class="k">iceberg</span><span class="p">,</span>
    <span class="k">ENDPOINT_TYPE</span> <span class="k">glue</span>
<span class="p">);</span>
</code></pre></div></div>

<p><strong>Asynchronous I/O.</strong> This is the feature Matt calls a game changer. In his <a href="https://performancede.substack.com/p/duckdb-20-and-s3-file-read-performance">benchmarks, published on his Substack</a>, reading 1,000 and 10,000 files from Amazon S3 consistently took 67% to 70% less time with the v2.0 alpha.</p>

<p>Much of the time used to go into sequential requests to list and fetch files. In v2.0, DuckDB issues these in the background. No code changes are needed: a query like the following simply runs faster on v2.0.</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">num_events</span>
<span class="k">FROM</span> <span class="nf">read_json</span><span class="p">(</span><span class="s1">'s3://</span><span class="ge">bucket</span><span class="s1">/logs/*.json'</span><span class="p">);</span>
</code></pre></div></div>

<p>For workloads such as log analytics over large numbers of raw files in S3, Google Cloud Storage, or Azure, where teams would traditionally spin up a wide Spark cluster, a single DuckDB process can now keep up, with less overhead and lower cost.</p>

<p><strong>Quack.</strong> The <a href="/docs/2.0/quack/overview.html">Quack remote protocol</a> turns a DuckDB instance into a server that other DuckDB instances can connect to over HTTP. Matt likes the cleaner connect and disconnect syntax in v2.0 and the official DuckLake support.</p>

<p>He has written about <a href="https://performancede.substack.com/p/serving-a-ducklake-on-ec2-via-quack">serving a DuckLake on an Amazon EC2 instance via Quack</a>. End users only need the Quack address and a token, with no AWS credentials and no knowledge of the DuckLake behind it. He has also built a DuckDB-Wasm dashboard on top of this setup, which he covers in the DuckLake book he is writing. The Wasm build is still on v1.5 and will be updated for the v2.0 release.</p>

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

<p>Matt's journey does not suggest that distributed systems are unnecessary. It illustrates a different engineering principle: start with the requirements of the workload, and introduce additional infrastructure only when those requirements justify it. For Matt, DuckDB and DuckLake demonstrate how much analytical capability can be delivered without making the underlying system unnecessarily complicated.</p>

<p>We would like to thank Matt for the conversation. You can follow his writing on DuckDB and DuckLake on <a href="https://performancede.substack.com/">his Substack</a>. And if you are reading this, are using DuckDB, and would like to share your story, drop us a line at <a href="mailto:quack@duckdb.org">quack@duckdb.org</a>.</p>]]></content>
      

      
        <author>
          <name>Matt Martin</name>
        </author>
      
        <author>
          <name>Geertjan Wielenga</name>
        </author>
      
        <author>
          <name>Tim Cox</name>
        </author>
      

      
        
      

      
        <category term="case-study" />
      

      
      
        <summary type="html"><![CDATA[After nearly two decades working with increasingly sophisticated data infrastructure, data engineer [Matt Martin](https://www.linkedin.com/in/mattmartin14/) discovered DuckDB could handle many of the analytical tasks he cared about without the operational complexity he had come to associate with big data. The same preference for familiar SQL and simpler infrastructure drew him to DuckLake, and he has since become an active tester and advocate of DuckDB's evolving capabilities.]]></summary>
      

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

      <title type="html">Gems of DuckDB 2.0.0</title>
      <link href="https://duckdb.org/2026/10/26/gems-of-duckdb-200.html" rel="alternate" type="text/html" title="Gems of DuckDB 2.0.0" />
      <published>2026-10-26T00:00:00+00:00</published>
      <updated>2026-10-26T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/26/gems-of-duckdb-200</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/26/gems-of-duckdb-200.html"><![CDATA[<p>Last week we released <a href="/2026/10/21/announcing-duckdb-200.html">DuckDB 2.0.0 LTS</a>, our biggest feature release so far.
The announcement covered the headline items: the Quack protocol, asynchronous I/O, the first-class <code class="language-plaintext highlighter-rouge">VARIANT</code> type, agent mode, the PEG parser, and triggers.
DuckDB 2.0.0 is now available in <a href="/docs/2.0/clients/overview.html">all primary clients</a> – check the <a href="/docs/2.0/installation.html">installation page</a> for instructions on installing or upgrading.</p>

<p>A release of this size ships many smaller improvements that never make it into the original announcement.
In this post, we highlight a few of these gems.
To find the documentation for everything that is new in 2.0, see the <a href="/docs/2.0/whats_new_in_the_docs.html">“What's New in the Docs for 2.0”</a> page.</p>

<h2 id="python-appender">Python Appender</h2>

<p>Inserting rows one at a time is inefficient, and building up a giant <code class="language-plaintext highlighter-rouge">INSERT</code> statement is awkward.
Therefore, the preferred solution for inserting large amounts of data has been the <a href="/docs/2.0/data/appender.html">appender</a>. However, it was only available in some clients (C/C++, Go, Java, etc.) and notably absent from the Python client.
DuckDB 2.0.0's Python client ships an <a href="/docs/2.0/clients/python/appender.html">appender</a> that collects rows and writes them together in a single operation:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">duckdb</span>

<span class="n">con</span> <span class="o">=</span> <span class="n">duckdb</span><span class="p">.</span><span class="n">connect</span><span class="p">()</span>
<span class="n">con</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="s">"CREATE TABLE duckdb_releases (version VARCHAR, codename VARCHAR);"</span><span class="p">)</span>

<span class="k">with</span> <span class="n">con</span><span class="p">.</span><span class="n">appender</span><span class="p">(</span><span class="s">"duckdb_releases"</span><span class="p">)</span> <span class="k">as</span> <span class="n">appender</span><span class="p">:</span>
    <span class="n">appender</span><span class="p">.</span><span class="n">append_row</span><span class="p">(</span><span class="s">"1.4"</span><span class="p">,</span> <span class="s">"Andium"</span><span class="p">)</span>
    <span class="n">appender</span><span class="p">.</span><span class="n">append_rows</span><span class="p">([(</span><span class="s">"1.5"</span><span class="p">,</span> <span class="s">"Variegata"</span><span class="p">),</span> <span class="p">(</span><span class="s">"2.0"</span><span class="p">,</span> <span class="s">"Cyanoptera"</span><span class="p">)])</span>

<span class="k">print</span><span class="p">(</span><span class="n">con</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="s">"FROM duckdb_releases"</span><span class="p">).</span><span class="n">fetchall</span><span class="p">())</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[('1.4', 'Andium'), ('1.5', 'Variegata'), ('2.0', 'Cyanoptera')]
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">with</code> block flushes the buffered rows when it exits.
For data that already lives in a DataFrame or Arrow table, <code class="language-plaintext highlighter-rouge">INSERT INTO ... SELECT</code> is still faster, but the appender is ideal for rows that your Python code produces on the fly.</p>

<h2 id="usability-improvements-in-the-cli">Usability Improvements in the CLI</h2>

<h3 id="pretty-printed-schema">Pretty-Printed <code class="language-plaintext highlighter-rouge">.schema</code></h3>

<p>As of DuckDB 2.0, the <a href="/docs/2.0/clients/cli/dot_commands.html#querying-the-database-schema"><code class="language-plaintext highlighter-rouge">.schema</code> dot command</a> pretty-prints and highlights its output by default, which makes reading the schema of a database much easier. If you prefer the earlier single-line form, for example, to copy a statement verbatim, use <code class="language-plaintext highlighter-rouge">--no-indent</code> (or its alias <code class="language-plaintext highlighter-rouge">--no-format</code>):</p>

<div class="language-batch highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">duckdb </span>mydata.duckdb <span class="s2">".schema --no-indent"</span>
</code></pre></div></div>

<h3 id="manual-dot-command"><code class="language-plaintext highlighter-rouge">.manual</code> Dot Command</h3>

<p>DuckDB 2.0 introduces the <a href="/docs/2.0/clients/cli/dot_commands.html#displaying-function-documentation"><code class="language-plaintext highlighter-rouge">.manual</code> dot command</a>, which shows the signatures, description, and examples of a SQL function.
For example, to get the details of the <code class="language-plaintext highlighter-rouge">bar</code> function, run:</p>

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

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  ────────────────────────────────────────────────────────────────────────────────────────────────
  system.main                                   bar                                scalar function

  SIGNATURE

        bar(x DOUBLE, min DOUBLE, max DOUBLE) -&gt; VARCHAR

        bar(x DOUBLE, min DOUBLE, max DOUBLE, width DOUBLE) -&gt; VARCHAR

  DESCRIPTION

        Draws a band whose width is proportional to (`x - min`) and equal to `width` characters
        when `x` = `max`. `width` defaults to 80.

  EXAMPLES

        bar(5, 0, 20, 10)
</code></pre></div></div>

<p>The same page is available from the command line via <code class="language-plaintext highlighter-rouge">duckdb -manual bar</code>, and the name may contain <code class="language-plaintext highlighter-rouge">LIKE</code> wildcards, e.g., <code class="language-plaintext highlighter-rouge">.manual list_%</code>.
In the coming releases, we will extend the manuals to cover more features such as SQL statements and clauses.</p>

<h3 id="calculator">Calculator</h3>

<p>You can use the CLI as a calculator. Simply type the expression followed by a semicolon – no more typing into your browser's address bar!</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">6</span><span class="o">*</span><span class="mi">7</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────┐
│ (6 * 7) │
│  int32  │
├─────────┤
│      42 │
└─────────┘
</code></pre></div></div>

<h3 id="command-history">Command History</h3>

<p>The CLI now exposes its command history in two ways. The <a href="/docs/2.0/clients/cli/dot_commands.html#command-history"><code class="language-plaintext highlighter-rouge">.history</code> dot command</a> shows previously executed statements with syntax highlighting, optionally limited to the last <code class="language-plaintext highlighter-rouge">N</code> entries:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">.history</span> <span class="mi">10</span>
</code></pre></div></div>

<p>The same information is available as a table through the <code class="language-plaintext highlighter-rouge">shell_history()</code> table function, so you can query your own history with SQL:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">sql</span>
<span class="k">FROM</span> <span class="nf">shell_history</span><span class="p">()</span>
<span class="k">WHERE</span> <span class="n">sql</span> <span class="k">ILIKE</span> <span class="s1">'%copy%'</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="offline-documentation">Offline Documentation</h2>

<p>As a reminder, both the DuckDB documentation and the DuckDB blog are available <a href="/docs/2.0/guides/offline-copy.html">for offline browsing</a>:</p>

<ul>
  <li><a href="https://blobs.duckdb.org/docs/duckdb-docs.md">Documentation as a Markdown file</a> (approx. 5 MB)</li>
  <li><a href="https://blobs.duckdb.org/docs/duckdb-docs.pdf">Documentation as a PDF file</a> (approx. 10 MB)</li>
  <li><a href="https://blobs.duckdb.org/docs/duckdb-blog.md">Blog as a Markdown file</a> (approx. 3 MB)</li>
  <li><a href="https://blobs.duckdb.org/docs/duckdb-blog.pdf">Blog as a PDF file</a> (approx. 35 MB)</li>
</ul>

<p>You can download them to your e-reader (we are not affiliated with any particular brand!) or read them on your laptop on the go.
You can also use them to supply context to LLMs and coding agents.
At approximately 5 MB, the documentation exceeds the context window of most LLMs, so instead of ingesting it in full, point them to specific sections or let them find the parts they need.</p>

<h2 id="unsung-sql-features">Unsung SQL Features</h2>

<h3 id="show-for-configuration-settings"><code class="language-plaintext highlighter-rouge">SHOW</code> for Configuration Settings</h3>

<p>The <a href="/docs/2.0/sql/statements/show.html#show-statement"><code class="language-plaintext highlighter-rouge">SHOW</code> statement</a> can now print a <a href="/docs/2.0/configuration/overview.html">configuration setting</a> by name, PostgreSQL-style, when the name does not refer to a table:</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">42</span><span class="p">;</span>
<span class="k">SHOW</span> <span class="py">threads</span><span class="p">;</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>threads</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>42</td>
    </tr>
  </tbody>
</table>

<p>This is much more succinct than the previous formulation that used the <a href="/docs/2.0/sql/functions/utility.html#current_settingsetting_name"><code class="language-plaintext highlighter-rouge">current_setting</code> function</a> (e.g., <code class="language-plaintext highlighter-rouge">SELECT current_setting('threads') AS threads;</code>).</p>

<p>When a table and a setting share a name, DuckDB prioritizes the table by default. You can control this with the <code class="language-plaintext highlighter-rouge">show_behavior</code> setting, which takes <code class="language-plaintext highlighter-rouge">'auto'</code> (the default), <code class="language-plaintext highlighter-rouge">'table'</code>, or <code class="language-plaintext highlighter-rouge">'setting'</code>.</p>

<blockquote>
  <p>Deprecated Using <code class="language-plaintext highlighter-rouge">SHOW</code> to describe a table, view, or query (e.g., <code class="language-plaintext highlighter-rouge">SHOW my_table</code>) is deprecated as of DuckDB 2.0 and prints a warning. Use <a href="/docs/2.0/sql/statements/describe.html"><code class="language-plaintext highlighter-rouge">DESCRIBE</code></a> instead.</p>
</blockquote>

<h3 id="create-secure-view-statement"><code class="language-plaintext highlighter-rouge">CREATE SECURE VIEW</code> Statement</h3>

<p>Regular views can leak information about their underlying tables through statistics and query plans, for example via the <code class="language-plaintext highlighter-rouge">stats()</code> function. The new <a href="/docs/2.0/sql/statements/create_view.html#examples"><code class="language-plaintext highlighter-rouge">SECURE VIEW</code></a> does not:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">SECURE</span> <span class="k">VIEW</span> <span class="n">users</span> <span class="k">AS</span>
    <span class="k">SELECT</span> <span class="py">username</span>
    <span class="k">FROM</span> <span class="n">all_users</span>
    <span class="k">WHERE</span> <span class="n">access_level</span> <span class="o">=</span> <span class="s1">'standard'</span><span class="p">;</span>
</code></pre></div></div>

<p>On a secure view, <code class="language-plaintext highlighter-rouge">stats()</code> returns only uninformative statistics and <code class="language-plaintext highlighter-rouge">EXPLAIN ANALYZE</code> shows a single <code class="language-plaintext highlighter-rouge">Secure View</code> node instead of the view's query plan, so neither can be used to reconstruct the rows that the view filters out.</p>

<h3 id="options-in-regexp_extract">Options in <code class="language-plaintext highlighter-rouge">regexp_extract</code></h3>

<p>The <a href="/docs/2.0/sql/functions/regular_expressions.html#using-regexp_extract"><code class="language-plaintext highlighter-rouge">regexp_extract</code> function</a> gained an overload that takes regular expression options as the third argument, with no capture group required. For example, you can now match case-insensitively in a single call:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">regexp_extract</span><span class="p">(</span><span class="s1">'ABC'</span><span class="p">,</span> <span class="s1">'abc'</span><span class="p">,</span> <span class="s1">'i'</span><span class="p">);</span> <span class="c1">-- ABC</span>
</code></pre></div></div>

<h3 id="setting-a-sequence-value">Setting a Sequence Value</h3>

<p><a href="/docs/2.0/sql/statements/create_sequence.html">Sequences</a> gained a PostgreSQL-style <a href="/docs/2.0/sql/statements/create_sequence.html#setting-the-sequence-value"><code class="language-plaintext highlighter-rouge">setval</code> function</a> to reset a sequence to a given value, which is useful after a bulk load:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">SEQUENCE</span> <span class="n">serial</span> <span class="k">START</span> <span class="mi">1</span><span class="p">;</span>
<span class="k">SELECT</span> <span class="nf">setval</span><span class="p">(</span><span class="s1">'serial'</span><span class="p">,</span> <span class="mi">42</span><span class="p">);</span>
<span class="k">SELECT</span> <span class="nf">nextval</span><span class="p">(</span><span class="s1">'serial'</span><span class="p">);</span>
</code></pre></div></div>

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

<p>The next value after <code class="language-plaintext highlighter-rouge">setval</code> is <code class="language-plaintext highlighter-rouge">value + increment</code> by default. Pass <code class="language-plaintext highlighter-rouge">false</code> as the optional third argument (<code class="language-plaintext highlighter-rouge">is_called</code>) to make the next <code class="language-plaintext highlighter-rouge">nextval</code> return <code class="language-plaintext highlighter-rouge">value</code> itself.</p>

<h3 id="session-variables-as-default-parameter-values">Session Variables as Default Parameter Values</h3>

<p><a href="/docs/2.0/sql/query_syntax/prepared_statements.html#session-variables-as-default-values">Prepared statements</a> can now fall back to a <a href="/docs/2.0/sql/statements/set_variable.html">session variable</a> of the same name when a named parameter is not supplied at execution time:</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">multiplier</span> <span class="o">=</span> <span class="mi">10</span><span class="p">;</span>
<span class="k">SET</span> <span class="k">VARIABLE</span> <span class="n">x</span> <span class="o">=</span> <span class="mi">42</span><span class="p">;</span>
<span class="k">PREPARE</span> <span class="n">compute</span> <span class="k">AS</span> <span class="k">SELECT</span> <span class="nv">$multiplier</span> <span class="o">*</span> <span class="nv">$x</span> <span class="k">AS</span> <span class="n">result</span><span class="p">;</span>
<span class="k">EXECUTE</span> <span class="n">compute</span><span class="p">;</span>
</code></pre></div></div>

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

<p>An explicit argument always takes precedence over the variable, and because the fallback is resolved at execution time, changing a variable and re-executing the statement picks up the new value.</p>

<h3 id="custom-partition-paths">Custom Partition Paths</h3>

<p>When writing <a href="/docs/2.0/data/partitioning/partitioned_writes.html">partitioned data</a>, the <code class="language-plaintext highlighter-rouge">PARTITION_BY</code> clause writes each partition to a <code class="language-plaintext highlighter-rouge">col=value</code> subdirectory following the Hive convention. The new <a href="/docs/2.0/data/partitioning/partitioned_writes.html#custom-partition-paths"><code class="language-plaintext highlighter-rouge">PARTITION_PATH</code> option</a> gives you full control over the subdirectory that each partition is written to, using an expression over the partitioning columns.
Let's suppose you have a table named <code class="language-plaintext highlighter-rouge">orders</code> that stores customer orders.</p>

<details>
  <summary>
Click here to see the code to create the <code class="language-plaintext highlighter-rouge">orders</code> table.
</summary>
  <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">orders</span> <span class="k">AS</span>
<span class="k">FROM</span> <span class="p">(</span><span class="k">VALUES</span>
    <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2018</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mf">120.00</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">2018</span><span class="p">,</span> <span class="mi">11</span><span class="p">,</span> <span class="mf">75.50</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">2019</span><span class="p">,</span> <span class="mi">6</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="mi">2019</span><span class="p">,</span> <span class="mi">6</span><span class="p">,</span> <span class="mf">310.25</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="mi">2020</span><span class="p">,</span> <span class="mi">9</span><span class="p">,</span> <span class="mf">18.75</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">6</span><span class="p">,</span> <span class="mi">2021</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mf">42.00</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">7</span><span class="p">,</span> <span class="mi">2021</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mf">99.90</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">8</span><span class="p">,</span> <span class="mi">2023</span><span class="p">,</span> <span class="mi">12</span><span class="p">,</span> <span class="mf">260.00</span><span class="p">)</span>
<span class="p">)</span> <span class="n">t</span><span class="p">(</span><span class="n">order_id</span><span class="p">,</span> <span class="n">year</span><span class="p">,</span> <span class="n">month</span><span class="p">,</span> <span class="n">amount</span><span class="p">);</span>
</code></pre></div>  </div>
</details>

<p>Let's partition this table based on year:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">COPY</span> <span class="n">orders</span> <span class="k">TO</span> <span class="s1">'orders'</span>
    <span class="p">(</span><span class="k">FORMAT</span> <span class="k">parquet</span><span class="p">,</span>
     <span class="k">PARTITION_BY</span> <span class="p">(</span><span class="n">year</span><span class="p">),</span>
     <span class="k">PARTITION_PATH</span> <span class="k">CASE</span>
         <span class="k">WHEN</span> <span class="n">year</span> <span class="o">&lt;</span> <span class="mi">2020</span> <span class="k">THEN</span> <span class="s1">'archive/'</span> <span class="o">||</span> <span class="n">year</span>
         <span class="k">ELSE</span> <span class="s1">'recent/'</span> <span class="o">||</span> <span class="n">year</span>
     <span class="k">END</span><span class="p">);</span>
</code></pre></div></div>

<p>This will result in the following directory structure:</p>

<div class="language-batch highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">tree </span>orders
</code></pre></div></div>
<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>orders
├── archive
│   ├── 2018
│   │   └── data_0.parquet
│   └── 2019
│       └── data_0.parquet
└── recent
    ├── 2020
    │   └── data_0.parquet
    ├── 2021
    │   └── data_0.parquet
    └── 2023
        └── data_0.parquet
</code></pre></div></div>

<p>The expression is evaluated once per partition, and its result must be a relative path that does not contain <code class="language-plaintext highlighter-rouge">..</code> segments. Each partition must map to a distinct path. A companion <a href="/docs/2.0/data/partitioning/partitioned_writes.html#sorting-rows-within-partitions"><code class="language-plaintext highlighter-rouge">ORDER_BY</code> copy option</a> sorts the rows before they are written. When combined with <code class="language-plaintext highlighter-rouge">PARTITION_BY</code>, the rows are sorted within each partition:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">COPY</span> <span class="n">orders</span> <span class="k">TO</span> <span class="s1">'orders_sorted'</span>
    <span class="p">(</span><span class="k">FORMAT</span> <span class="k">parquet</span><span class="p">,</span>
     <span class="k">PARTITION_BY</span> <span class="p">(</span><span class="n">year</span><span class="p">,</span> <span class="n">month</span><span class="p">),</span>
     <span class="k">ORDER_BY</span> <span class="p">(</span><span class="n">amount</span> <span class="k">DESC</span> <span class="k">NULLS</span> <span class="k">LAST</span><span class="p">));</span>
</code></pre></div></div>

<h3 id="writing-geojson">Writing GeoJSON</h3>

<p>DuckDB can now write <a href="/docs/2.0/data/json/writing_json.html#geojson">GeoJSON</a> directly with <code class="language-plaintext highlighter-rouge">COPY ... (FORMAT GEOJSON)</code>, which produces a <code class="language-plaintext highlighter-rouge">FeatureCollection</code>, or <code class="language-plaintext highlighter-rouge">FORMAT GEOJSONL</code> for newline-delimited features:</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="s1">'POINT(0 1)'</span><span class="p">::</span><span class="nb">GEOMETRY</span> <span class="k">AS</span> <span class="n">geom</span><span class="p">,</span> <span class="mi">10</span> <span class="k">AS</span> <span class="n">my_field</span><span class="p">)</span>
    <span class="k">TO</span> <span class="s1">'test.json'</span> <span class="p">(</span><span class="k">FORMAT</span> <span class="k">GEOJSON</span><span class="p">);</span>
</code></pre></div></div>

<p>The optional <code class="language-plaintext highlighter-rouge">ID_COLUMN</code> and <code class="language-plaintext highlighter-rouge">GEOMETRY_COLUMN</code> options pick which columns become the feature <code class="language-plaintext highlighter-rouge">id</code> and geometry. Reading GeoJSON is covered on the <a href="/docs/2.0/data/json/loading_json.html#geojson">Loading JSON</a> page.</p>

<h2 id="extensions">Extensions</h2>

<h3 id="compatibility-matrix-for-community-extensions">Compatibility Matrix for Community Extensions</h3>

<p>In the days following the release, once our CI pipelines got some breathing room, we built and deployed the <a href="/community_extensions/">community extensions</a> for DuckDB 2.0.0.</p>

<p>Not every extension is available for every DuckDB version and platform, so each extension's page now includes a compatibility matrix that shows which DuckDB version and platform combinations the extension was deployed for, covering recent DuckDB releases. For example, see the <a href="/community_extensions/extensions/duckpgq.html#compatibility-matrix">compatibility matrix of the DuckPGQ extension</a>:</p>

<div class="extension_compatibility_table"></div>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Platform</th>
      <th style="text-align: center">1.4.4</th>
      <th style="text-align: center">1.4.5</th>
      <th style="text-align: center">1.5.5</th>
      <th style="text-align: center">1.5.6</th>
      <th style="text-align: center">2.0.0</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Linux AMD64</td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
    </tr>
    <tr>
      <td style="text-align: left">Linux ARM64</td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
    </tr>
    <tr>
      <td style="text-align: left">macOS AMD64</td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
    </tr>
    <tr>
      <td style="text-align: left">macOS ARM64</td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
    </tr>
    <tr>
      <td style="text-align: left">Windows AMD64</td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
    </tr>
    <tr>
      <td style="text-align: left">Windows ARM64</td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
    </tr>
    <tr>
      <td style="text-align: left">WebAssembly</td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon minus"><use xlink:href="#minus"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
      <td style="text-align: center"><svg class="icon check"><use xlink:href="#check"></use></svg></td>
    </tr>
  </tbody>
</table>

<h3 id="window-function-extensions">Window Function Extensions</h3>

<p>Every aggregate function can be used as a window function, but not every windowed operation is an aggregate. In DuckDB 2.0, window functions became catalog entries, so <a href="/docs/2.0/extensions/window_function_extensions.html">extensions can now define their own window functions</a> using the same C++ API as the built-in ones.</p>

<p>An extension derives from the <code class="language-plaintext highlighter-rouge">WindowFunction</code> class, registers it with the <code class="language-plaintext highlighter-rouge">ExtensionLoader</code>, and implements a blocking interface, which builds its data structures and evaluates the function in parallel. Optionally, it can also implement a streaming interface for windows without <code class="language-plaintext highlighter-rouge">PARTITION BY</code> and <code class="language-plaintext highlighter-rouge">ORDER BY</code> clauses. As a side effect, window functions are now listed in <code class="language-plaintext highlighter-rouge">duckdb_functions()</code> and can be looked up with the <code class="language-plaintext highlighter-rouge">.manual</code> dot command.</p>

<p>For more details, see <a href="https://blobs.duckdb.org/events/duckdb-boston/meetup/richard-wesley-windowing.pdf">Richard Wesley's slides on window extensions</a> (presented in September at our <a href="/events/2026/09/03/duckdb-boston-meetup/">Boston meetup</a>).</p>

<h2 id="summary-and-looking-ahead">Summary and Looking Ahead</h2>

<p>In this blog post, we listed a few of the smaller features in DuckDB 2.0.0. For everything else, see the <a href="/2026/10/21/announcing-duckdb-200.html">announcement blog post</a> and the <a href="https://github.com/duckdb/duckdb/releases/tag/v2.0.0">release notes</a>. There are even more things in the release as part our <a href="/docs/2.0/core_extensions/overview.html">core extensions</a> – we'll release more posts on these in the coming weeks.</p>

<p>The ecosystem around DuckDB keeps growing: many projects are built both with DuckDB and within it. The community-driven <a href="https://github.com/davidgasquez/awesome-duckdb"><code class="language-plaintext highlighter-rouge">awesome-duckdb</code> repository</a>, maintained by <a href="https://github.com/davidgasquez">David Gasquez</a>, collects extensions, integrations, and articles in one place and is a great starting point for exploring what people are building.</p>

<p>We are also in the process of updating our <a href="/roadmap.html">roadmap</a> with upcoming features. We already picked the codename for DuckDB 2.1, coming in 2027: we will name it after <a href="https://en.wikipedia.org/wiki/Barrow%27s_goldeneye">Barrow's goldeneye</a> <em>(Bucephala islandica),</em> so:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">with</span> <span class="n">con</span><span class="p">.</span><span class="n">appender</span><span class="p">(</span><span class="s">"duckdb_releases"</span><span class="p">)</span> <span class="k">as</span> <span class="n">appender</span><span class="p">:</span>
    <span class="n">appender</span><span class="p">.</span><span class="n">append_row</span><span class="p">(</span><span class="s">"2.1"</span><span class="p">,</span> <span class="s">"Islandica"</span><span class="p">)</span>
</code></pre></div></div>

<p>As always, we are grateful to the contributors who made this release possible.
Thank you!</p>]]></content>
      

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

      
        
      

      
        <category term="release" />
      

      
      
        <summary type="html"><![CDATA[We released DuckDB 2.0.0 last week with an improved Quack protocol and `VARIANT` support, asynchronous I/O, and many other features. In this blog post, we highlight a few smaller features and improvements that did not make it into the release announcement.]]></summary>
      

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

      <title type="html">Announcing DuckDB 2.0.0 LTS “Cinnamon Teal”</title>
      <link href="https://duckdb.org/2026/10/21/announcing-duckdb-200.html" rel="alternate" type="text/html" title="Announcing DuckDB 2.0.0 LTS “Cinnamon Teal”" />
      <published>2026-10-21T00:00:00+00:00</published>
      <updated>2026-10-21T00:00:00+00:00</updated>
      <id>https://duckdb.org/2026/10/21/announcing-duckdb-200</id>
      
      
        <content type="html" xml:base="https://duckdb.org/2026/10/21/announcing-duckdb-200.html"><![CDATA[<blockquote>
  <p>Update 2.0.0 packed so many features that we published a followup blog post titled <a href="/2026/10/26/gems-of-duckdb-200.html">“Gems of DuckDB 2.0.0”</a>.</p>
</blockquote>

<p>We are proud to release DuckDB 2.0.0, codenamed <a href="https://shell.duckdb.org/#queries=v0,ATTACH-'https%3A%2F%2Fblobs.duckdb.org%2Fduck.db'-AS-duckdb~,CALL-duckdb.cinnamon_teal()~">“Cinnamon Teal”</a> after the <a href="https://en.wikipedia.org/wiki/Cinnamon_teal"><em>Spatula cyanoptera</em></a>, a strikingly reddish-brown duck found in the western Americas.</p>

<p>While <a href="/2024/06/03/announcing-duckdb-100.html">DuckDB 1.0</a> was all about stability and backward compatibility, DuckDB 2.0 is above all a feature release.
In this blog post, we summarize the highlights and then walk through the breaking changes and how to migrate.
We showcased many of the new features in <a href="/2026/08/17/duckdb-20-highlights.html">“A Preview of DuckDB v2.0”</a>, which covers each one in more depth.
For the complete release notes, see the <a href="https://github.com/duckdb/duckdb/releases/tag/v2.0.0">release page on GitHub</a>.</p>

<blockquote>
  <p>Tip To install the new version, please visit the <a href="/docs/2.0/installation.html">installation page</a>.</p>
</blockquote>

<p>With this release, there are currently three DuckDB lines available.
DuckDB 2.0 is a long-term support (LTS) release with support until at least October 2027.
The previous LTS release, version 1.4, is still supported until November 17, 2026.
To ensure a smooth transition and allow sufficient time for testing, we extended the support period of DuckDB 1.5 until March 2027, effectively also turning it into an LTS release.
For details, see the <a href="/release_calendar.html#lts-releases">release calendar</a>.</p>

<p>To find your way around the documentation for all of the new features, we also published a <a href="/docs/2.0/whats_new_in_the_docs.html">“What's New in the Docs for 2.0”</a> page that links each feature below to its documentation.</p>

<h2 id="duckdb-as-a-server">DuckDB as a Server</h2>

<p>DuckDB has been an in-process database since day one.
In <a href="/2026/05/20/announcing-duckdb-153.html">DuckDB 1.5.3</a>, released in May, we introduced the Quack protocol that lets you run DuckDB in a traditional client-server setup, along with the <a href="/docs/2.0/core_extensions/quack.html"><code class="language-plaintext highlighter-rouge">quack</code> extension</a>, which implements DuckDB's native protocol, so any DuckDB process can <a href="/docs/2.0/quack/overview.html#server-side-usage">serve its databases</a>.</p>

<h3 id="quack-and-connect">Quack and <code class="language-plaintext highlighter-rouge">CONNECT</code></h3>

<p>DuckDB 2.0.0 ships a polished version of the Quack protocol with substantial performance and stability improvements. It also introduces the new <a href="/docs/2.0/sql/statements/connect.html"><code class="language-plaintext highlighter-rouge">CONNECT</code> statement</a> that allows attaching and switching to a remote database in a single command for better ergonomics:</p>

<!-- markdownlint-disable MD001 -->

<div class="duck-diagram">

  <div class="duck-diagram-box">

    <h4 id="duckdb-server"><svg class="icon"><use href="#database-01"></use></svg> DuckDB Server</h4>

    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CALL</span> <span class="nf">quack_serve</span><span class="p">(</span>
    <span class="k">token</span> <span class="o">=</span> <span class="s1">'my_token'</span>
<span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">events</span>
    <span class="p">(</span><span class="n">date</span> <span class="nb">DATE</span><span class="p">,</span> <span class="n">name</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="s1">'2027-04-07'</span><span class="p">,</span>
     <span class="s1">'DuckCon #8'</span><span class="p">);</span>
</code></pre></div>    </div>

  </div>

  <div class="duck-diagram-arrow reversed">quack:</div>

  <div class="duck-diagram-box">

    <h4 id="duckdb-client"><svg class="icon"><use href="#database-01"></use></svg> DuckDB Client</h4>

    <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ATTACH</span> <span class="s1">'quack:localhost'</span>
    <span class="k">AS</span> <span class="n">qk</span> <span class="p">(</span><span class="k">TOKEN</span> <span class="s1">'my_token'</span><span class="p">);</span>

<span class="k">CONNECT</span> <span class="n">qk</span><span class="p">;</span>
<span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">events</span><span class="p">;</span>

<span class="err">┌────────────┬────────────┐</span>
<span class="err">│</span>    <span class="n">date</span>    <span class="err">│</span>    <span class="n">name</span>    <span class="err">│</span>
<span class="err">│</span>    <span class="n">date</span>    <span class="err">│</span>  <span class="n">varchar</span>   <span class="err">│</span>
<span class="err">├────────────┼────────────┤</span>
<span class="err">│</span> <span class="mi">2027-04-07</span> <span class="err">│</span> <span class="n">DuckCon</span> <span class="o">#</span><span class="mi">8</span> <span class="err">│</span>
<span class="err">└────────────┴────────────┘</span>
</code></pre></div>    </div>

  </div>

</div>

<!-- markdownlint-enable MD001 -->

<p>For simplicity, this example runs the server and the client on the same machine. By default, the server only listens on <code class="language-plaintext highlighter-rouge">localhost</code>. To accept connections from other machines, keep the server on <code class="language-plaintext highlighter-rouge">localhost</code> and put it behind a <a href="/docs/2.0/quack/setup/reverse_proxy.html">TLS-terminating reverse proxy</a>. The <a href="/docs/2.0/quack/overview.html#starting-a-server"><code class="language-plaintext highlighter-rouge">allow_other_hostname</code> option</a>, which exposes the server directly, is only meant for testing.</p>

<p>The <code class="language-plaintext highlighter-rouge">CONNECT</code> statement is not limited to Quack: it points your session at <a href="/docs/2.0/sql/statements/connect.html#supported-databases">any remote database that supports it</a>, including PostgreSQL and MySQL. For these, the new remote pushdown optimizer ships queries directly to the remote server instead of pulling tables over the wire.</p>

<h3 id="quack-in-the-cli">Quack in the CLI</h3>

<p>The CLI also has shortcuts for setting up a Quack client-server pair: the new <a href="/docs/2.0/clients/cli/arguments.html#serving-and-connecting-to-remote-databases"><code class="language-plaintext highlighter-rouge">-serve</code> and <code class="language-plaintext highlighter-rouge">-connect</code> arguments</a> start a Quack server or connect to one directly from the command line:</p>

<!-- markdownlint-disable MD001 -->

<div class="duck-diagram">

  <div class="duck-diagram-box">

    <h4 id="cli-server"><svg class="icon"><use href="#database-01"></use></svg> CLI Server</h4>

    <div class="language-batch highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">duckdb</span> <span class="nt">-serve</span>
</code></pre></div>    </div>

    <!-- «memory» D -->
    <div class="language-plsql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">t</span> <span class="k">AS</span>
      <span class="k">SELECT</span> <span class="mi">42</span> <span class="k">AS</span> <span class="n">x</span><span class="p">;</span>
</code></pre></div>    </div>

  </div>

  <div class="duck-diagram-arrow reversed">quack:</div>

  <div class="duck-diagram-box">

    <h4 id="cli-client"><svg class="icon"><use href="#database-01"></use></svg> CLI Client</h4>

    <div class="language-batch highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">duckdb</span> <span class="nt">-connect</span>
</code></pre></div>    </div>

    <!-- «quack:localhost memory» -->
    <div class="language-plsql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span> <span class="n">t</span><span class="p">;</span>

<span class="err">┌───────┐</span>
<span class="err">│</span>   <span class="n">x</span>   <span class="err">│</span>
<span class="err">│</span> <span class="n">int32</span> <span class="err">│</span>
<span class="err">├───────┤</span>
<span class="err">│</span>    <span class="mi">42</span> <span class="err">│</span>
<span class="err">└───────┘</span>
</code></pre></div>    </div>

  </div>

</div>

<!-- markdownlint-enable MD001 -->

<h2 id="performance">Performance</h2>

<h3 id="asynchronous-io">Asynchronous I/O</h3>

<p>DuckDB 2.0 introduces <a href="/docs/2.0/data/io.html">asynchronous I/O</a> for reading Parquet and CSV files, as well as <a href="/docs/2.0/internals/storage.html">DuckDB's native storage format</a>. The I/O layer now scales independently from the query processing layer, issuing reads in the background and keeping multiple requests in flight. This means far more parallelism for remote reads and dramatically faster queries on object storage such as <a href="/docs/2.0/core_extensions/httpfs/s3api.html"><code class="language-plaintext highlighter-rouge">s3://</code></a>.</p>

<p>We described the design in detail in a <a href="/2026/07/31/asynchronous-io.html">dedicated blog post</a>.
In that post, we showed the network throughput over the course of a query with and without asynchronous I/O:</p>

<p><img src="/images/blog/v20-release/sync-vs-async-network-throughput-light.svg" alt="Network throughput" class="lightmode-img" />
<img src="/images/blog/v20-release/sync-vs-async-network-throughput-dark.svg" alt="Network throughput" class="darkmode-img" />
<em class="caption">Network throughput. Higher utilization and shorter runtime are better.</em></p>

<h3 id="the-variant-type-becomes-a-first-class-citizen">The <code class="language-plaintext highlighter-rouge">VARIANT</code> Type Becomes a First-Class Citizen</h3>

<p>The <a href="/docs/2.0/sql/data_types/variant.html"><code class="language-plaintext highlighter-rouge">VARIANT</code> type</a> stores semi-structured, schema-less data such as JSON while keeping the performance of columnar execution.
So you can think of it as JSON on steroids: a <code class="language-plaintext highlighter-rouge">VARIANT</code> column can store differently shaped data in every row, but unlike JSON it is not a text format.</p>

<p><a href="/2026/03/09/announcing-duckdb-150.html#variant-type">DuckDB 1.5 already introduced <code class="language-plaintext highlighter-rouge">VARIANT</code></a>, but DuckDB 2.0 fully integrates it. DuckDB 2.0 detects the common structure in your data and “shreds” it, so it compresses well and executes fast. The pipeline works end to end, from shredded execution straight out of storage to shredded <code class="language-plaintext highlighter-rouge">VARIANT</code> reading and writing for Parquet, backed by a family of <a href="/docs/2.0/sql/functions/variant.html"><code class="language-plaintext highlighter-rouge">variant_*</code> functions</a>. For example:</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">payload</span> <span class="nb">VARIANT</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="s1">'{"user": {"id": 42, "tags": ["a", "b"]}}'</span><span class="p">::</span><span class="nb">JSON</span><span class="p">::</span><span class="nb">VARIANT</span><span class="p">),</span>
    <span class="p">(</span><span class="s1">'{"event": "click", "x": 10, "y": 20}'</span><span class="p">::</span><span class="nb">JSON</span><span class="p">::</span><span class="nb">VARIANT</span><span class="p">);</span>

<span class="k">SELECT</span> <span class="nf">variant_type</span><span class="p">(</span><span class="n">payload</span><span class="p">),</span> <span class="nf">variant_keys</span><span class="p">(</span><span class="n">payload</span><span class="p">)</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>┌───────────────────────┬───────────────────────┐
│ variant_type(payload) │ variant_keys(payload) │
│        varchar        │       varchar[]       │
├───────────────────────┼───────────────────────┤
│ OBJECT                │ [user]                │
│ OBJECT                │ [event, x, y]         │
└───────────────────────┴───────────────────────┘
</code></pre></div></div>

<h3 id="agent-mode-in-the-cli">Agent Mode in the CLI</h3>

<p>Coding agents such as Claude Code, Codex, and Cursor increasingly drive the DuckDB CLI on their users' behalf. The 2.0 CLI detects when it is being run by an agent and switches to <a href="/2026/10/09/agent-mode.html">agent mode</a>: it prints compact Markdown tables instead of padded boxes, says clearly when a result was truncated, stops runaway queries early, and reports errors as JSON.</p>

<p>Long-running SQL queries report their expected cost prior to running, so the agent can decide whether to wait. In our experiments on TPC-H, agent mode cut the amount of DuckDB output the model had to read by 59%. You can turn it <em>on</em> for manual testing using the <a href="/docs/2.0/clients/cli/agent_mode.html#activating-agent-mode"><code class="language-plaintext highlighter-rouge">-agent</code> flag</a> or instruct your agent to turn it <em>off</em> using the <code class="language-plaintext highlighter-rouge">-no-agent</code> flag.</p>

<h2 id="extensibility">Extensibility</h2>

<h3 id="a-brand-new-sql-parser">A Brand New SQL Parser</h3>

<p>DuckDB has always used a parser derived from PostgreSQL's. In 2.0, our own modern, extensible <a href="/2026/08/20/duckdb-20-peg-parser.html">PEG-based parser</a>, which debuted as an opt-in feature in <a href="/2026/03/09/announcing-duckdb-150.html#peg-parser">DuckDB 1.5</a>, becomes the default. Extensions can now hook into the grammar itself (an idea we first explored in our <a href="/2024/11/22/runtime-extensible-parsers.html">research on runtime-extensible parsers</a>), so expect extensions that expose entirely new SQL syntax. Error messages now come with precise source locations. For details, see the <a href="/docs/2.0/sql/peg_parser.html">PEG parser documentation</a>. The new parser is designed to be compatible with the old one, apart from a few deliberate changes listed in the <a href="/docs/2.0/operations_manual/migration.html#sql-parser">migration guide</a>. If you notice any other differences, please file an issue. In the meantime, you can switch back to the old parser using the <a href="/docs/2.0/core_extensions/legacy_parser.html"><code class="language-plaintext highlighter-rouge">legacy_parser</code> extension</a> by running <code class="language-sql highlight highlighter-rouge"><span class="k">SET</span> <span class="n">enable_legacy_parser</span> <span class="o">=</span> <span class="k">true</span><span class="p">;</span></code>.</p>

<p>The old parser had served DuckDB since its first commit in 2018. Its grammar was generated with YACC/Bison, which produces an LALR(1) parser, so as DuckSQL grew, even small additions could collide with existing rules and raise <code class="language-plaintext highlighter-rouge">shift/reduce</code> or <code class="language-plaintext highlighter-rouge">reduce/reduce</code> conflicts. A PEG grammar avoids this by trying alternatives in the order they are written, so adding syntax no longer risks breaking unrelated rules. Python itself moved to a PEG-based parser in <a href="https://peps.python.org/pep-0617/">version 3.9</a> for the same flexibility.</p>

<h3 id="write-extensions-once-and-host-them-yourself">Write Extensions Once and Host Them Yourself</h3>

<p>DuckDB 2.0 ships a revamped <a href="/docs/2.0/clients/c/overview.html#c-api-v2">C API v2</a>, which is considerably more powerful than its predecessor and exposes components such as file systems and table functions to extensions. Before marking it as stable, we would like to test it in the field, so please give it a try and <a href="https://github.com/duckdb/duckdb/discussions/27525">send us your feedback</a>!</p>

<p>You can also register and host your own trusted extension repositories, so an organization can host and sign its own extensions and have them install and load just like the built-in ones:</p>

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

<span class="k">CREATE</span> <span class="k">EXTENSION</span> <span class="k">REPOSITORY</span> <span class="n">my_repo</span>
    <span class="k">WITH</span> <span class="k">PREFIX</span> <span class="s1">'https://extensions.example.org'</span><span class="p">;</span>

<span class="k">INSTALL</span> <span class="k">AND</span> <span class="k">LOAD</span> <span class="n">my_ext</span> <span class="k">FROM</span> <span class="n">my_repo</span><span class="p">;</span>
</code></pre></div></div>

<p>A repository is a name, a URL prefix, and one or more RSA public keys that are trusted to sign the extensions served from it. The prefix can point at anything DuckDB can read: a local path, <code class="language-plaintext highlighter-rouge">https</code>, <code class="language-plaintext highlighter-rouge">s3</code>, you name it. At <code class="language-plaintext highlighter-rouge">CREATE</code> time, DuckDB fetches the repository's public keys and pins them into the repository definition, printing each key's SHA-256 fingerprint so you can compare it against one published out of band.</p>

<p>This feature is still a work in progress and its syntax may change in future releases. For more details, see the <a href="/docs/2.0/extensions/extension_repositories.html">Extension Repositories page</a>.</p>

<h3 id="core-extension-updates">Core Extension Updates</h3>

<p>DuckDB's <a href="/docs/2.0/core_extensions/overview.html">core extensions</a> are also shipping several new features, fixes, and performance optimizations. These are too numerous to all list here, so we only highlight a few of them and will cover them in dedicated blog posts in the coming weeks:</p>

<ul>
  <li>We released <a href="https://ducklake.select/2026/10/21/ducklake-11/">DuckLake 1.1</a>, including a new specification and <a href="/docs/2.0/core_extensions/ducklake.html">reference implementation</a>.</li>
  <li>The <a href="/docs/2.0/core_extensions/iceberg/overview.html">Iceberg extension</a> strives to become a feature-complete implementation. To this end, it adds REST catalog <a href="/docs/2.0/core_extensions/iceberg/iceberg_options.html#attach-options"><code class="language-plaintext highlighter-rouge">ATTACH</code> options</a> that derive a table's location from its namespace or load table metadata from the <code class="language-plaintext highlighter-rouge">metadata-location</code> file returned by the catalog. Further options control the URL prefix and the namespace separator.</li>
  <li>The <a href="/docs/2.0/core_extensions/delta.html">Delta extension</a> can create Delta tables with <code class="language-plaintext highlighter-rouge">CREATE TABLE</code> (including partitioning and Delta table properties), supports time travel, and improves <code class="language-plaintext highlighter-rouge">VARIANT</code> support.</li>
</ul>

<h2 id="sql-features">SQL Features</h2>

<h3 id="triggers">Triggers</h3>

<p>DuckDB 2.0 delivers triggers: the <a href="/docs/2.0/sql/statements/create_trigger.html"><code class="language-plaintext highlighter-rouge">CREATE TRIGGER</code> statement</a> with <code class="language-plaintext highlighter-rouge">BEFORE</code> and <code class="language-plaintext highlighter-rouge">AFTER</code> triggers, <code class="language-plaintext highlighter-rouge">FOR EACH ROW</code> and <code class="language-plaintext highlighter-rouge">FOR EACH STATEMENT</code>, transition tables via <code class="language-plaintext highlighter-rouge">REFERENCING OLD/NEW TABLE</code>, multiple triggers per event, <code class="language-plaintext highlighter-rouge">RETURNING</code> on triggered tables, and <code class="language-plaintext highlighter-rouge">DROP TRIGGER</code>. The classic use case is audit tables, where a trigger records what changed in response to a table modification. Some combinations, such as <code class="language-plaintext highlighter-rouge">BEFORE ... FOR EACH ROW</code> and <code class="language-plaintext highlighter-rouge">UPDATE ... FOR EACH ROW</code> triggers, are not yet supported; see the <a href="/docs/2.0/sql/statements/create_trigger.html#limitations">limitations</a>.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TRIGGER</span> <span class="n">trg_audit</span>
    <span class="k">AFTER</span> <span class="k">UPDATE</span> <span class="k">ON</span> <span class="n">target</span>
    <span class="k">REFERENCING</span>
        <span class="k">OLD</span> <span class="k">TABLE</span> <span class="k">AS</span> <span class="n">o</span>
        <span class="k">NEW</span> <span class="k">TABLE</span> <span class="k">AS</span> <span class="n">n</span>
    <span class="k">FOR</span> <span class="k">EACH</span> <span class="k">STATEMENT</span>
        <span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">audit</span>
        <span class="k">SELECT</span> <span class="n">n.id</span><span class="p">,</span> <span class="n">o.val</span><span class="p">,</span> <span class="n">n.val</span>
        <span class="k">FROM</span> <span class="n">o</span> <span class="k">JOIN</span> <span class="n">n</span> <span class="k">ON</span> <span class="n">o.id</span> <span class="o">=</span> <span class="n">n.id</span><span class="p">;</span>
</code></pre></div></div>

<p>Triggers can also maintain materialized views incrementally! For example, to keep track of the average age of people per nationality in a table <code class="language-plaintext highlighter-rouge">age_statistics</code>, you can use two triggers – one for insert and another for delete operations – and store the sum and the count alongside the average.</p>

<details>
  <summary>
To see more details, click here.
</summary>

  <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">people</span> <span class="p">(</span><span class="n">name</span> <span class="nb">VARCHAR</span><span class="p">,</span> <span class="n">nationality</span> <span class="nb">VARCHAR</span><span class="p">,</span> <span class="n">age</span> <span class="nb">INTEGER</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">age_statistics</span> <span class="p">(</span>
    <span class="n">nationality</span> <span class="nb">VARCHAR</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">sum_age</span> <span class="nb">INTEGER</span><span class="p">,</span>
    <span class="n">num_people</span> <span class="nb">INTEGER</span><span class="p">,</span>
    <span class="n">average_age</span> <span class="nb">DOUBLE</span>
<span class="p">);</span>
</code></pre></div>  </div>

  <p>On insertion, we aggregate the new rows per nationality and merge them into <code class="language-plaintext highlighter-rouge">age_statistics</code>, adding a row for each new nationality:</p>

  <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TRIGGER</span> <span class="n">people_insert</span>
    <span class="k">AFTER</span> <span class="k">INSERT</span> <span class="k">ON</span> <span class="n">people</span>
    <span class="k">REFERENCING</span> <span class="k">NEW</span> <span class="k">TABLE</span> <span class="k">AS</span> <span class="n">n</span>
    <span class="k">FOR</span> <span class="k">EACH</span> <span class="k">STATEMENT</span>
        <span class="k">MERGE</span> <span class="k">INTO</span> <span class="n">age_statistics</span> <span class="n">s</span>
        <span class="k">USING</span> <span class="p">(</span>
            <span class="k">SELECT</span> <span class="n">nationality</span><span class="p">,</span> <span class="nf">sum</span><span class="p">(</span><span class="n">age</span><span class="p">)</span> <span class="k">AS</span> <span class="n">sum_age</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">num_people</span>
            <span class="k">FROM</span> <span class="n">n</span>
            <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">nationality</span>
        <span class="p">)</span> <span class="n">d</span>
        <span class="k">ON</span> <span class="n">s.nationality</span> <span class="o">=</span> <span class="n">d.nationality</span>
        <span class="k">WHEN</span> <span class="k">MATCHED</span> <span class="k">THEN</span> <span class="k">UPDATE</span> <span class="k">SET</span>
            <span class="n">sum_age</span> <span class="o">=</span> <span class="n">s.sum_age</span> <span class="o">+</span> <span class="n">d.sum_age</span><span class="p">,</span>
            <span class="n">num_people</span> <span class="o">=</span> <span class="n">s.num_people</span> <span class="o">+</span> <span class="n">d.num_people</span><span class="p">,</span>
            <span class="n">average_age</span> <span class="o">=</span> <span class="p">(</span><span class="n">s.sum_age</span> <span class="o">+</span> <span class="n">d.sum_age</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">s.num_people</span> <span class="o">+</span> <span class="n">d.num_people</span><span class="p">)</span>
        <span class="k">WHEN</span> <span class="k">NOT</span> <span class="k">MATCHED</span> <span class="k">THEN</span> <span class="k">INSERT</span>
            <span class="k">VALUES</span> <span class="p">(</span><span class="n">d.nationality</span><span class="p">,</span> <span class="n">d.sum_age</span><span class="p">,</span> <span class="n">d.num_people</span><span class="p">,</span> <span class="n">d.sum_age</span> <span class="o">/</span> <span class="n">d.num_people</span><span class="p">);</span>
</code></pre></div>  </div>

  <p>On deletion, we subtract the deleted rows and drop a nationality once its last person has left:</p>

  <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TRIGGER</span> <span class="n">people_delete</span>
    <span class="k">AFTER</span> <span class="k">DELETE</span> <span class="k">ON</span> <span class="n">people</span>
    <span class="k">REFERENCING</span> <span class="k">OLD</span> <span class="k">TABLE</span> <span class="k">AS</span> <span class="n">o</span>
    <span class="k">FOR</span> <span class="k">EACH</span> <span class="k">STATEMENT</span>
        <span class="k">MERGE</span> <span class="k">INTO</span> <span class="n">age_statistics</span> <span class="n">s</span>
        <span class="k">USING</span> <span class="p">(</span>
            <span class="k">SELECT</span> <span class="n">nationality</span><span class="p">,</span> <span class="nf">sum</span><span class="p">(</span><span class="n">age</span><span class="p">)</span> <span class="k">AS</span> <span class="n">sum_age</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">num_people</span>
            <span class="k">FROM</span> <span class="n">o</span>
            <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">nationality</span>
        <span class="p">)</span> <span class="n">d</span>
        <span class="k">ON</span> <span class="n">s.nationality</span> <span class="o">=</span> <span class="n">d.nationality</span>
        <span class="k">WHEN</span> <span class="k">MATCHED</span> <span class="k">AND</span> <span class="n">s.num_people</span> <span class="o">=</span> <span class="n">d.num_people</span> <span class="k">THEN</span> <span class="k">DELETE</span>
        <span class="k">WHEN</span> <span class="k">MATCHED</span> <span class="k">THEN</span> <span class="k">UPDATE</span> <span class="k">SET</span>
            <span class="n">sum_age</span> <span class="o">=</span> <span class="n">s.sum_age</span> <span class="o">-</span> <span class="n">d.sum_age</span><span class="p">,</span>
            <span class="n">num_people</span> <span class="o">=</span> <span class="n">s.num_people</span> <span class="o">-</span> <span class="n">d.num_people</span><span class="p">,</span>
            <span class="n">average_age</span> <span class="o">=</span> <span class="p">(</span><span class="n">s.sum_age</span> <span class="o">-</span> <span class="n">d.sum_age</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">s.num_people</span> <span class="o">-</span> <span class="n">d.num_people</span><span class="p">);</span>
</code></pre></div>  </div>

  <p>Let's see people come and go:</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">people</span> <span class="k">VALUES</span>
    <span class="p">(</span><span class="s1">'Alice'</span><span class="p">,</span> <span class="s1">'US'</span><span class="p">,</span> <span class="mi">34</span><span class="p">),</span> <span class="p">(</span><span class="s1">'Bram'</span><span class="p">,</span> <span class="s1">'NL'</span><span class="p">,</span> <span class="mi">27</span><span class="p">),</span> <span class="p">(</span><span class="s1">'Csilla'</span><span class="p">,</span> <span class="s1">'HU'</span><span class="p">,</span> <span class="mi">45</span><span class="p">),</span>
    <span class="p">(</span><span class="s1">'Dave'</span><span class="p">,</span> <span class="s1">'US'</span><span class="p">,</span> <span class="mi">58</span><span class="p">),</span> <span class="p">(</span><span class="s1">'Eva'</span><span class="p">,</span> <span class="s1">'NL'</span><span class="p">,</span> <span class="mi">19</span><span class="p">),</span> <span class="p">(</span><span class="s1">'Femke'</span><span class="p">,</span> <span class="s1">'NL'</span><span class="p">,</span> <span class="mi">41</span><span class="p">);</span>
<span class="k">FROM</span> <span class="n">age_statistics</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">nationality</span><span class="p">;</span>
</code></pre></div>  </div>

  <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────┬─────────┬────────────┬─────────────┐
│ nationality │ sum_age │ num_people │ average_age │
│   varchar   │  int32  │   int32    │   double    │
├─────────────┼─────────┼────────────┼─────────────┤
│ HU          │      45 │          1 │        45.0 │
│ NL          │      87 │          3 │        29.0 │
│ US          │      92 │          2 │        46.0 │
└─────────────┴─────────┴────────────┴─────────────┘
</code></pre></div>  </div>

  <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">people</span> <span class="k">WHERE</span> <span class="k">name</span> <span class="o">=</span> <span class="s1">'Alice'</span><span class="p">;</span>
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">people</span> <span class="k">WHERE</span> <span class="n">nationality</span> <span class="o">=</span> <span class="s1">'HU'</span><span class="p">;</span>
<span class="k">FROM</span> <span class="n">age_statistics</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">nationality</span><span class="p">;</span>
</code></pre></div>  </div>

  <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────┬─────────┬────────────┬─────────────┐
│ nationality │ sum_age │ num_people │ average_age │
│   varchar   │  int32  │   int32    │   double    │
├─────────────┼─────────┼────────────┼─────────────┤
│ NL          │      87 │          3 │        29.0 │
│ US          │      58 │          1 │        58.0 │
└─────────────┴─────────┴────────────┴─────────────┘
</code></pre></div>  </div>
</details>

<p>Triggers bring us one step closer to materialized views, a long-standing item in the <a href="/roadmap.html#future-work">“future work” section of our roadmap</a>.</p>

<h3 id="sql-dialect-additions">SQL Dialect Additions</h3>

<p>As always, DuckDB's <a href="/docs/2.0/sql/dialect/friendly_sql.html">friendly SQL dialect</a> keeps growing. A few favorites from this release cycle:</p>

<ul>
  <li>The <a href="/docs/2.0/sql/statements/set_variable.html#the-name-shorthand"><code class="language-sql highlight highlighter-rouge"><span class="err">$</span><span class="ge">variable</span></code> shorthand</a> lets you write <code class="language-plaintext highlighter-rouge">$x</code> in most places where an expression is allowed, instead of <code class="language-sql highlight highlighter-rouge"><span class="nf">getvariable</span><span class="p">(</span><span class="s1">'x'</span><span class="p">)</span></code>.</li>
  <li><a href="/docs/2.0/sql/query_syntax/with.html#data-modifying-ctes">Data-modifying CTEs</a> let you use <code class="language-plaintext highlighter-rouge">INSERT</code>, <code class="language-plaintext highlighter-rouge">UPDATE</code>, <code class="language-plaintext highlighter-rouge">DELETE</code>, and <code class="language-plaintext highlighter-rouge">COPY</code> as pipeline steps.</li>
  <li><a href="/docs/2.0/sql/query_syntax/from.html#nearest-joins"><code class="language-plaintext highlighter-rouge">NEAREST</code> joins</a> turn top-k similarity search into a join clause, handy for vector and embedding workloads.</li>
  <li><a href="/docs/2.0/sql/statements/create_schema.html#nested-schemas">Nested schemas</a> allow schemas within schemas, e.g., <code class="language-plaintext highlighter-rouge">CREATE SCHEMA finance.reports</code>.</li>
  <li><a href="/docs/2.0/data/json/json_functions.html#json-mutation-functions">JSON mutation functions</a> <code class="language-plaintext highlighter-rouge">json_set</code>, <code class="language-plaintext highlighter-rouge">json_insert</code>, <code class="language-plaintext highlighter-rouge">json_replace</code>, and <code class="language-plaintext highlighter-rouge">json_remove</code> return modified copies of JSON documents (see also our <a href="/2026/08/18/reconciling-json.html">blog post on JSON patches</a>).</li>
</ul>

<p>There is more: DuckDB 2.0.0 supports the SQL-standard <a href="/docs/2.0/sql/query_syntax/limit.html#fetch-first"><code class="language-plaintext highlighter-rouge">FETCH FIRST</code></a> and allows <a href="/docs/2.0/sql/query_syntax/groupby.html#grouping-by-unnest"><code class="language-plaintext highlighter-rouge">UNNEST</code> in the <code class="language-plaintext highlighter-rouge">GROUP BY</code> clause</a>. It also ensures well-defined <a href="/docs/2.0/sql/statements/merge_into.html"><code class="language-plaintext highlighter-rouge">MERGE</code></a> and <a href="/docs/2.0/sql/statements/update.html"><code class="language-plaintext highlighter-rouge">UPDATE ... FROM</code></a> semantics for cases when multiple rows are matched.</p>

<h2 id="migrating-to-20">Migrating to 2.0</h2>

<p>DuckDB 2.0 comes with a small set of breaking changes. For most users, upgrading is straightforward, but complex deployments running many DuckDB processes need more care. The storage format is the most likely source of problems: DuckDB versions before 2.0 cannot read database files created by DuckDB 2.0. DuckDB 2.0 can still read and write your existing database files, and these remain readable by older versions. We recommend testing 2.0.0 on a shadow system before switching production over. And, if your workload is mission-critical, you may want to wait for the first patch release, <a href="/release_calendar.html#upcoming-releases">coming in November</a>.</p>

<p>For the full details, see the <a href="/docs/2.0/operations_manual/migration.html">migration guide</a>.</p>

<h2 id="internals-and-deprecations">Internals and Deprecations</h2>

<h3 id="compiler-requirements">Compiler Requirements</h3>

<p>Following DuckDB's long stint on C++11, DuckDB 2.0 now <a href="/docs/2.0/dev/building/overview.html#prerequisites">requires a C++17 compiler</a>.</p>

<p>We also switched to Clang as the default compiler for our release builds. On Linux and macOS, both DuckDB and its core extensions are built with Clang. On Windows, the CLI is built with Clang, while most core extensions are still built with MSVC. We may move more Windows builds to Clang in the future, but this requires further changes.</p>

<h3 id="timezones-calendars-and-collations-without-icu">Timezones, Calendars, and Collations without ICU</h3>

<p>Timezone-aware timestamps, calendars, and collations have always been powered by the ICU library. In 2.0, the ICU library is gone entirely: the <a href="/docs/2.0/core_extensions/icu.html"><code class="language-plaintext highlighter-rouge">icu</code> extension</a> now implements timezones, calendars, and collations itself, with the timezone data built directly from the IANA database and compressed down to around 45 kB.</p>

<p>Everything keeps working exactly as before, and the native implementation is also faster. If you find any issues, please <a href="https://github.com/duckdb/duckdb/issues">let us know</a>!</p>

<h3 id="deprecations">Deprecations</h3>

<h4 id="lambda-syntax">Lambda Syntax</h4>

<p>The old single-arrow lambda syntax (<code class="language-plaintext highlighter-rouge">x -&gt; x + 1</code>) is now disabled by default. Use the Python-style syntax instead:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="nf">list_transform</span><span class="p">([</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">],</span> <span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span> <span class="o">+</span> <span class="mi">1</span><span class="p">);</span>
</code></pre></div></div>

<p>You can temporarily re-enable the old syntax with <code class="language-sql highlight highlighter-rouge"><span class="k">SET</span> <span class="py">lambda_syntax</span> <span class="o">=</span> <span class="s1">'enable_single_arrow'</span><span class="p">;</span></code>. Both the flag and the single-arrow syntax will be removed entirely in v2.1.</p>

<h4 id="amalgamation-builds">Amalgamation Builds</h4>

<p>The <a href="/docs/1.4/dev/building/overview">amalgamation build</a>, which bundles DuckDB's entire source code into a single header and a single source file, is now deprecated. The amalgamation has grown too large to be realistically useful in most cases.</p>

<h4 id="gradual-phase-out-of-macos-x86_64">Gradual Phase-Out of macOS x86_64</h4>

<p>We are gradually phasing out <code class="language-plaintext highlighter-rouge">osx_amd64</code> from the list of fully supported <a href="/docs/2.0/operations_manual/installing_duckdb/requirements.html#supported-platforms">platforms</a>.
For the time being, DuckDB and <a href="/docs/2.0/core_extensions/overview.html">core extensions</a> are still built and supported, but <a href="/docs/2.0/extensions/community_extensions.html">community extensions</a> are only built for this platform on a best-effort basis. DuckDB 2.1 will stop supporting <code class="language-plaintext highlighter-rouge">osx_amd64</code>.</p>

<h2 id="summary">Summary</h2>

<p>These were a few highlights, but there are many more features and improvements in this release. There have been almost 20,000 commits by many contributors since DuckDB 1.5, shipping smaller features as well as performance and usability improvements. The full <a href="https://github.com/duckdb/duckdb/releases/tag/v2.0.0">release notes can be found on GitHub</a>.</p>

<p>We would like to thank our community for the detailed issue reports and feedback that have driven this release, and our special thanks go to our contributors!</p>]]></content>
      

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

      
        
      

      
        <category term="release" />
      

      
      
        <summary type="html"><![CDATA[The DuckDB team is happy to announce that today we're releasing DuckDB version 2.0.0 LTS, codenamed “Cinnamon Teal” (Spatula cyanoptera). This is our biggest feature release so far, and it ships a polished version of the Quack protocol, asynchronous I/O, a first-class `VARIANT` type, agent mode, triggers, an extensible SQL parser, and much more, built from close to 20,000 commits since DuckDB 1.5.]]></summary>
      

      
      
        
        <media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://duckdb.org/images/blog/thumbs/duckdb-release-2-0-0.png" />
        <media:content medium="image" url="https://duckdb.org/images/blog/thumbs/duckdb-release-2-0-0.png" xmlns:media="http://search.yahoo.com/mrss/" />
      
    </entry>
  
    <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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_merge_patch</code></a>. Store the result in a <a href="/docs/2.0/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/2.0/data/json/json_functions.html"><code class="language-plaintext highlighter-rouge">json_transform</code></a> (also available as <a href="/docs/2.0/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/2.0/sql/data_types/struct.html"><code class="language-plaintext highlighter-rouge">STRUCT</code></a> following the structure you give it. <a href="/docs/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/data/json/overview.html">JSON Overview</a> and <a href="/docs/2.0/data/json/json_functions.html">JSON Functions</a></li>
  <li><a href="/docs/2.0/data/json/json_type.html">The <code class="language-plaintext highlighter-rouge">JSON</code> Type</a></li>
  <li><a href="/docs/2.0/sql/query_syntax/unnest.html"><code class="language-plaintext highlighter-rouge">unnest</code></a> and the <a href="/docs/2.0/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 2.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 2.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 a benchmark derived from TPC-H that included both the power test and the throughput test on scale factor 1,000.
As an x86 baseline, we also ran the benchmark on an Intel Xeon 6 machine.</p>

<blockquote>
  <p>Note This workload is derived from the TPC-H Benchmark and is not comparable to published TPC-H Benchmark results, as this implementation does not comply with all requirements of the TPC-H Benchmark.</p>
</blockquote>

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

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

<p>We used two bare-metal machines running Ubuntu 26.04:</p>

<ul>
  <li>an NVIDIA Vera server with 1536 GB of memory, and</li>
  <li>an x86 baseline, an <a href="https://aws.amazon.com/ec2/instance-types/m8i/"><code class="language-plaintext highlighter-rouge">m8id.metal-48xl</code> EC2 instance</a> with an Intel Xeon 6975P-C CPU and 768 GiB of memory.</li>
</ul>

<p>On both machines, we stored the database on a single NVMe SSD with 3+ TB of storage, formatted as ext4.
At the time of writing, DuckDB 2.0 was <a href="/2026/09/02/try-duckdb-20-alpha.html">not released yet</a>, so we used a <a href="/docs/2.0/installation.html?version=preview">preview build</a>, <code class="language-plaintext highlighter-rouge">1.6.0.dev379</code> (a codename for 2.0-dev).
As a software baseline, we also ran the benchmark with DuckDB 1.5.5.</p>

<p>To run the experiments, we used our <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"># for the DuckDB 1.5.5 baseline, install this version instead:</span>
<span class="c"># python3 -m pip install duckdb==1.5.5</span>

<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 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 <code class="language-plaintext highlighter-rouge">dbgen</code> 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="nvidia-vera">NVIDIA Vera</h3>

<p>The server had a dual-socket <a href="https://www.nvidia.com/en-us/data-center/vera-cpu/">NVIDIA Vera CPU</a>, packing 88 cores per socket 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 ran DuckDB on a single socket using the <code class="language-plaintext highlighter-rouge">numactl</code> command:</p>

<div class="language-batch 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>For this test, we also pinned the number of threads to just 88:</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>

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

<p>The x86 baseline ran on an <a href="https://aws.amazon.com/ec2/instance-types/m8i/"><code class="language-plaintext highlighter-rouge">m8id.metal-48xl</code> EC2 instance</a>, which has a single-socket Intel Xeon 6 CPU, packing 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="hardware-specifications">Hardware Specifications</h3>

<p>The table below summarizes the specifications of the two CPUs:</p>

<table>
  <thead>
    <tr>
      <th>Specification</th>
      <th>NVIDIA Vera (per socket)</th>
      <th>Intel Xeon 6975P-C</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cores / threads</td>
      <td>88 / 176</td>
      <td>96 / 192</td>
    </tr>
    <tr>
      <td>Threads used by DuckDB</td>
      <td>88</td>
      <td>96</td>
    </tr>
    <tr>
      <td>L3 cache</td>
      <td>164 MB</td>
      <td>480 MB</td>
    </tr>
    <tr>
      <td>Memory bandwidth</td>
      <td>up to 1.2 TB/s</td>
      <td>not published</td>
    </tr>
    <tr>
      <td>TDP</td>
      <td>250–450 W (configurable)</td>
      <td>not published</td>
    </tr>
  </tbody>
</table>

<p>For Vera, the cache, memory bandwidth, and TDP figures are from NVIDIA's <a href="https://dam-cdn.nvd.orangelogic.com/AssetLink/lptf1ea6nbw67y10wro347r4a3biqt0w.pdf">Vera CPU architecture whitepaper</a>.
The Xeon 6975P-C is a custom variant that is not listed on <a href="https://www.intel.com/content/www/us/en/ark/products/series/240357/intel-xeon-6-processors.html">Intel ARK</a>.
At the time of writing, reliable information on the TDP of the Xeon 6975P-C and the frequency of these chips is not available.</p>

<blockquote>
  <p>Update DuckDB 2.1 will ship improvements for <a href="https://github.com/duckdb/duckdb/pull/27782">NUMA-awarerness and many-core setups</a>.</p>
</blockquote>

<h3 id="deviations-from-the-specification">Deviations from the Specification</h3>

<p>Our implementation deviates from the specification in the following ways:</p>

<ul>
  <li>The results were not audited by a TPC-certified auditor, and we did not publish a Full Disclosure Report or an Executive Summary.</li>
  <li>We did not price the system, so there is no price/performance figure.</li>
  <li>We do not list frequency data for the CPUs or power data for the Xeon 6975P-C, and we only list the configurable TDP range for Vera, as more precise information was not reliably available (see above).</li>
  <li>DuckDB 2.0.0-alpha was a pre-release version, not generally available at the time of the benchmark.</li>
  <li>We restricted DuckDB to a single socket of the dual-socket Vera server.</li>
  <li>We generated the base tables with <code class="language-plaintext highlighter-rouge">tpchgen-cli</code> instead of <code class="language-plaintext highlighter-rouge">dbgen</code>.</li>
  <li>We converted the refresh data from <code class="language-plaintext highlighter-rouge">dbgen</code>'s flat files to Parquet before the benchmark.</li>
  <li>The initial database load is not part of the reported results.</li>
  <li>We did not run the ACID tests.</li>
  <li>We report the runtimes of the power test and the throughput test instead of the composite metrics defined by the specification.</li>
  <li>We summarize three runs (after a discarded warmup run) using their mean instead of reporting the lower of two runs.</li>
</ul>

<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.
For each run, we report two metrics, where lower values are better:</p>

<ul>
  <li><strong>Power test:</strong> the geometric mean of the runtimes of the 22 queries and the 2 refresh functions.</li>
  <li><strong>Throughput test:</strong> the total runtime of the throughput test, which runs 7 query streams concurrently.</li>
</ul>

<p>To summarize the three runs, we take the geometric mean of the power test results and the arithmetic mean of the throughput test results.</p>

<h3 id="power-test">Power Test</h3>

<p>Geometric mean of the query and refresh runtimes in seconds:</p>

<table>
  <thead>
    <tr>
      <th>CPU</th>
      <th style="text-align: right">NVIDIA Vera</th>
      <th style="text-align: right">NVIDIA Vera</th>
      <th style="text-align: right">Intel Xeon 6</th>
      <th style="text-align: right">Intel Xeon 6</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DuckDB</td>
      <td style="text-align: right">1.5.5</td>
      <td style="text-align: right">2.0.0-alpha</td>
      <td style="text-align: right">1.5.5</td>
      <td style="text-align: right">2.0.0-alpha</td>
    </tr>
    <tr>
      <td>Run 1</td>
      <td style="text-align: right">1.457 s</td>
      <td style="text-align: right">1.209 s</td>
      <td style="text-align: right">2.319 s</td>
      <td style="text-align: right">1.862 s</td>
    </tr>
    <tr>
      <td>Run 2</td>
      <td style="text-align: right">1.571 s</td>
      <td style="text-align: right">1.155 s</td>
      <td style="text-align: right">2.302 s</td>
      <td style="text-align: right">1.953 s</td>
    </tr>
    <tr>
      <td>Run 3</td>
      <td style="text-align: right">1.466 s</td>
      <td style="text-align: right">1.202 s</td>
      <td style="text-align: right">2.243 s</td>
      <td style="text-align: right">1.912 s</td>
    </tr>
    <tr>
      <td><strong>Geometric mean</strong></td>
      <td style="text-align: right"><strong>1.497 s</strong></td>
      <td style="text-align: right"><strong>1.188 s</strong></td>
      <td style="text-align: right"><strong>2.288 s</strong></td>
      <td style="text-align: right"><strong>1.909 s</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="throughput-test">Throughput Test</h3>

<p>Total runtime of the throughput test in seconds:</p>

<table>
  <thead>
    <tr>
      <th>CPU</th>
      <th style="text-align: right">NVIDIA Vera</th>
      <th style="text-align: right">NVIDIA Vera</th>
      <th style="text-align: right">Intel Xeon 6</th>
      <th style="text-align: right">Intel Xeon 6</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DuckDB</td>
      <td style="text-align: right">1.5.5</td>
      <td style="text-align: right">2.0.0-alpha</td>
      <td style="text-align: right">1.5.5</td>
      <td style="text-align: right">2.0.0-alpha</td>
    </tr>
    <tr>
      <td>Run 1</td>
      <td style="text-align: right">221.26 s</td>
      <td style="text-align: right">174.79 s</td>
      <td style="text-align: right">318.54 s</td>
      <td style="text-align: right">238.30 s</td>
    </tr>
    <tr>
      <td>Run 2</td>
      <td style="text-align: right">222.51 s</td>
      <td style="text-align: right">176.26 s</td>
      <td style="text-align: right">299.77 s</td>
      <td style="text-align: right">249.76 s</td>
    </tr>
    <tr>
      <td>Run 3</td>
      <td style="text-align: right">220.94 s</td>
      <td style="text-align: right">178.22 s</td>
      <td style="text-align: right">317.68 s</td>
      <td style="text-align: right">234.49 s</td>
    </tr>
    <tr>
      <td><strong>Arithmetic mean</strong></td>
      <td style="text-align: right"><strong>221.57 s</strong></td>
      <td style="text-align: right"><strong>176.42 s</strong></td>
      <td style="text-align: right"><strong>312.00 s</strong></td>
      <td style="text-align: right"><strong>240.85 s</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="findings">Findings</h3>

<p>The run-to-run variation is small: within each configuration, the three runs differ by at most 8%.
The results show two key findings.
First, the Vera platform outperforms the x86 baseline (Intel Xeon 6) with both DuckDB versions: it is 1.53–1.61× faster in the power test and 1.37–1.41× faster in the throughput test.
Second, DuckDB 2.0.0-alpha is 1.20–1.30× faster than DuckDB 1.5.5 on both CPUs – and this is something all users will benefit from once <a href="/release_calendar.html">DuckDB 2.0.0 is out</a>.</p>

<blockquote>
  <p>Update DuckDB 2.0.0 <a href="/2026/10/21/announcing-duckdb-200.html">is out</a>!</p>
</blockquote>

<p>We attribute Vera's performance advantage compared to the x86 baseline 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 2.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 and compared it to an x86 baseline using a workload derived from TPC-H 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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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="nf">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/2.0/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/2.0/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="nf">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/2.0/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/2.0/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/2.0/core_extensions/iceberg/iceberg_functions.html#iceberg_column_stats"><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="nf">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/2.0/core_extensions/iceberg/iceberg_functions.html#iceberg_partition_stats"><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="nf">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/2.0/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>New Agent mode shipped with DuckDB 2.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-batch 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-sql highlight highlighter-rouge"><span class="k">SET</span> <span class="n">max_execution_time</span> <span class="o">=</span> <span class="ge">ms</span></code> to put an upper bound on a query’s runtime</li>
  <li><code class="language-sql highlight highlighter-rouge"><span class="k">DESCRIBE</span> <span class="ge">query</span></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-batch 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 2.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/2.0/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/2.0/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/2.0/guides/network_cloud_storage/s3_import.html"><code class="language-plaintext highlighter-rouge">s3://</code></a> bucket or an <a href="/docs/2.0/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/2.0/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/2.0/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/2.0/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/2.0/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/2.0/guides/performance/file_formats.html">network and the layout of the referenced files</a>. The tips for <a href="/docs/2.0/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/2.0/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>
  
</feed>
