Skip to content
Architecture

DuckDB extensions, without the DuckDB internals.

Write the extension in a language you already use and run it as an ordinary process. DuckDB calls it through the same interfaces it uses for native ones — functions, catalogs, pushdown — over Apache Arrow IPC.

The program on the far end is called a worker, and it is ordinary code — no DuckDB headers, no build against its internals. This page works top down: why that's worth doing, the five shapes a worker's functions can take, how it's reached and hosted, and finally the wire itself.

Where this came from

Extensions are how DuckDB grows — and the hardest thing to ship

Parquet, cloud storage, geospatial, half a dozen other databases: nearly everything DuckDB can reach arrives as an extension, and the interface for writing one is genuinely good. What's hard is everything around writing it. Two costs in particular land before your code ever answers a query.

The cognitive load

Most of the work isn't your logic. It's learning how DuckDB thinks: how a vector of values is laid out, how nulls are tracked beside it, which callback owns which piece of state, and what has to be freed and when. That knowledge is specific to DuckDB, and everyone who touches the code pays for it before writing anything useful.

The release latency

Not query latency — the wait between changing a line and everyone having it. A compiled extension is tied to one DuckDB version on one platform, so shipping means building for every platform you support, getting them signed, and publishing — then doing it again on the next DuckDB release, whether your code changed or not.

Neither is a complaint about DuckDB. Both are the honest price of running inside the engine's process, and if you're building one extension, it's a price worth paying. We wanted to build them at scale — dozens of them, maintained by people who shouldn't have to learn DuckDB's internals first, and increasingly written with the help of coding agents. Paid once, that price is fine. Paid per extension, it doesn't divide.

The idea

Let the extension live outside DuckDB

So we wrote one extension, generically, and gave it nothing to do but talk. It's an ordinary DuckDB extension — compiled, signed, distributed, all of it — and its only job is to carry DuckDB's requests to a program running outside the database, then bring the answers back. That program is a worker: your code, in whatever language you like, answering in Apache Arrow.

From the SQL side, nothing looks unusual. You ATTACH a worker, DuckDB sees catalogs, schemas, tables, and functions — its own objects, with its own semantics — and it plans queries against them the way it plans anything else.

Kafka Stripe MongoDB Elasticsearch Salesforce Snowflake GitHub Redis Slack Twilio OpenAI Hugging Face PyTorch SymPy OpenCV FFmpeg Kafka Stripe MongoDB Elasticsearch Salesforce Snowflake GitHub Redis Slack Twilio OpenAI Hugging Face PyTorch SymPy OpenCV FFmpeg Kafka Stripe MongoDB Elasticsearch Salesforce Snowflake GitHub Redis Slack Twilio OpenAI Hugging Face PyTorch SymPy OpenCV FFmpeg Kafka Stripe MongoDB Elasticsearch Salesforce Snowflake GitHub Redis Slack Twilio OpenAI Hugging Face PyTorch SymPy OpenCV FFmpeg VGI EXTENSION Arrow IPC over any transport DuckDB

That one move takes care of both costs, and a few other things along with them:

One extension, installed once

There's one VGI binary, and we maintain it. INSTALL vgi FROM community is the last build-and-sign cycle anyone runs — after that, your functionality ships when you ship it, with no DuckDB release to wait for.

Ordinary code, ordinary tooling

A worker is a normal program in a normal language. Your debugger, your test runner, your dependency manager, your CI — all of it works, and none of it is pinned to a DuckDB version.

Failure stays outside

A segfault, a runaway allocation, or a hung third-party client takes down a worker, not the database. The worker is also a separate process with its own credentials and its own network access, which is a security boundary you don't get in-process.

An agent can write one

We think building a DuckDB extension should be within reach of a coding agent, and out-of-process is what puts it there. A worker is typed code with a test you can run in seconds — the loop agents are good at. Manual memory ownership, an ABI to match, and a signed cross-platform release are not.

The obvious objection

“Isn't leaving the process slow?”

It's the first question everyone asks, and it deserves a real answer rather than a benchmark screenshot. Four things make that boundary far cheaper than it sounds. Then there's the one case where the worry is justified.

The unit is a batch, not a row

DuckDB is vectorized: it hands over thousands of rows per call, not one. A round trip is amortized across the whole vector, so the boundary shows up as a fixed cost per batch rather than a tax on every row.

Arrow on both sides means no conversion

DuckDB's execution format and the worker's are the same columnar layout, so there's no serialize / deserialize step in the middle — the buffers go on the wire as they already are. On one host, shared memory moves them with no copy at all.

Fewer calls, not just cheaper ones

Projection, filter, ORDER BY + LIMIT, and distinct join-key pushdown mean the worker sends back less. Catalogs load lazily instead of up front, and subprocess workers are pooled and reused rather than spawned per query.

What it measures

Roughly 450 million rows/second across the boundary. Fast enough that for the overwhelming majority of workloads, the hop stops being the thing you tune.

And the case where it's justified: a scalar function on a genuinely hot path — called a billion-plus times a second, doing almost nothing per call — runs faster in-process, and no amount of batching closes that gap. A chatty pattern that can't be expressed set-at-a-time pays the boundary over and over for the same reason. For those, a native extension is still the right tool, and we'd rather say so than sell you around it.

Lineage

Airport, retooled for the web

VGI is not the first attempt at this. We built Airport first — a DuckDB extension that reaches remote services over Arrow Flight and exposes them as catalogs, tables, and functions. Airport proved the idea works, and it is still the right answer if you already speak Flight.

What it inherited from Flight was the constraint. Flight is gRPC and Protocol Buffers: excellent inside a data center, awkward everywhere else. Requiring end-to-end HTTP/2 with trailer support rules out a lot of ordinary web infrastructure — CDNs, proxies, edge and function-as-a-service runtimes — and Flight's verbs describe a narrower slice of DuckDB than an extension can actually register.

VGI is that idea retooled. Same premise, different wire: Apache Arrow IPC over a transport you choose, with plain HTTP as the web-friendly default. That bought back the entire hosting story below, along with a wider surface — five function shapes, per-column statistics, pushdown, and transactional catalog semantics. It's faster, too.

The interface

The same API, one hop out of the process

Here's the part that matters if you've written against duckdb.h: VGI deliberately mirrors DuckDB's own extension interfaces rather than inventing a friendlier abstraction on top of them. If you know what bind does, or why an aggregate needs combine, you already know how a worker is structured — the concepts are DuckDB's, and only the plumbing underneath them changed.

Take upper_case(VARCHAR) → VARCHAR, about as small as a function gets. The first tab is what it takes to write natively, in C; the rest are the same function as a VGI worker, one per SDK. Each one is a whole program, not an excerpt, and each registers upper_case in a catalog DuckDB can ATTACH.

extension.c — a native DuckDB extension
/* DuckDB C extension API — upper_case(VARCHAR) -> VARCHAR: the
   compute callback and the registration that publishes it. */
static void upper_case(duckdb_function_info info,
                       duckdb_data_chunk input,
                       duckdb_vector output) {
  idx_t count             = duckdb_data_chunk_get_size(input);
  duckdb_vector in        = duckdb_data_chunk_get_vector(input, 0);
  duckdb_string_t *in_data = (duckdb_string_t *) duckdb_vector_get_data(in);
  uint64_t *in_valid      = duckdb_vector_get_validity(in);

  duckdb_vector_ensure_validity_writable(output);
  uint64_t *out_valid = duckdb_vector_get_validity(output);

  for (idx_t row = 0; row < count; row++) {
    if (!duckdb_validity_row_is_valid(in_valid, row)) {
      duckdb_validity_set_row_invalid(out_valid, row);
      continue;
    }

    /* Strings up to 12 bytes live inside the struct; longer ones are behind a
       pointer — you have to know which before you can read one. */
    duckdb_string_t str = in_data[row];
    const char *bytes   = duckdb_string_is_inlined(str) ? str.value.inlined.inlined
                                                        : str.value.pointer.ptr;
    uint32_t len        = duckdb_string_is_inlined(str) ? str.value.inlined.length
                                                        : str.value.pointer.length;

    char *upper = (char *) malloc(len);   /* ASCII fast path only */
    for (uint32_t i = 0; i < len; i++) {
      upper[i] = (char) toupper((unsigned char) bytes[i]);
    }
    duckdb_vector_assign_string_element_len(output, row, upper, len);
    free(upper);
  }
}

DUCKDB_EXTENSION_ENTRYPOINT(duckdb_connection conn,
                            duckdb_extension_info info,
                            struct duckdb_extension_access *access) {
  duckdb_logical_type varchar = duckdb_create_logical_type(DUCKDB_TYPE_VARCHAR);
  duckdb_scalar_function fn   = duckdb_create_scalar_function();

  duckdb_scalar_function_set_name(fn, "upper_case");
  duckdb_scalar_function_add_parameter(fn, varchar);
  duckdb_scalar_function_set_return_type(fn, varchar);
  duckdb_scalar_function_set_function(fn, upper_case);
  duckdb_register_scalar_function(conn, fn);

  duckdb_destroy_scalar_function(&fn);
  duckdb_destroy_logical_type(&varchar);
  return true;
}

In the C version, everything that isn't uppercasing is the work of knowing DuckDB: which vector holds the argument, where the nulls are tracked, and the fact that short strings sit inside the string struct while longer ones live behind a pointer. In the workers, all of that belongs to the SDK, and what's left is the part only you can write. The point isn't that C is bad — it's that this interface doesn't have to live in DuckDB's process to be the interface. All five SDKs →

Function shapes

Five shapes, matching DuckDB's own

Every function a worker exposes takes one of five shapes, and the extension registers each one in the matching DuckDB position. Those positions are DuckDB's own: scalars and aggregates appear in expressions, table functions appear in FROM, and the in-out shapes consume a relation. All that changes is where the implementation runs.

Scalar function shape

Scalar

1 row → 1 value

Transform each row independently. You receive the whole column as an Arrow array and return one — the vectorized shape, not a per-row callback.

DuckDB's scalar function (duckdb_create_scalar_function) · SELECT f(col) FROM t

Scalar worker, end to end →
Table function shape

Table

args → N rows

Generate rows from scalar arguments, with no input relation. Bind declares the schema; process is called repeatedly, so large results stream a bounded batch at a time.

DuckDB's table function (duckdb_create_table_function) · SELECT * FROM f(args)

Table worker, end to end →
Table-in-out function shape

Table-in-out

N rows → M rows

Consume a relation and stream a transformed relation back, batch by batch. Filter, enrich, reshape — nothing is held, so input size is unbounded.

DuckDB's in-out table function · SELECT * FROM f((SELECT …))

Table-in-out worker, end to end →
Aggregate function shape

Aggregate

N rows → 1 value

Fold rows into per-group state, then emit a row per group. combine merges partial states, which is what lets DuckDB run your aggregate in parallel.

DuckDB's aggregate function (duckdb_create_aggregate_function) · SELECT f(col) FROM t GROUP BY k

Aggregate worker, end to end →
Buffering function shape

Buffering

stream → [state] → stream

For functions that must see every row before emitting any — a global sort, top-k, a full reduction. Sink, combine, then source the result back out.

DuckDB's in-out function with a final flush · SELECT * FROM f((SELECT …))

Buffering worker, end to end →

Each shape's hooks — what bind receives, when process is called again, how state is carried between phases — are documented per language, because the contracts are expressed in that language's type system. Python function patterns →

The transport layer

Same protocol, your choice of pipe

The SDKs own the wire, so the transport is a deployment decision rather than a code one: move a worker from a pipe to HTTP and the worker itself doesn't change — only the LOCATION you attach to does.

Pipes

OS pipes between processes on the same machine. Lowest setup cost. The default for the spawned-subprocess case — including a worker that lives inside a container.

Unix sockets

Local domain sockets when the worker is already running. Same performance envelope as pipes; survives independently of the DuckDB process.

Shared memory

Zero-copy Arrow IPC over a memory region. Highest throughput for very large batches on the same host. Opt-in.

HTTP

Workers anywhere on the network — and the transport that makes ordinary web infrastructure work: proxies, load balancers, serverless runtimes.

Benchmarks and a comparison matrix live on the dedicated RPC site. vgi-rpc.query.farm →

Distribution & hosting

Where a worker actually runs

Transports are how the bytes move. The other half of the question is how the worker gets there in the first place, and LOCATION is where you answer it: either a command DuckDB runs for you, or a URL it calls.

A local process

A container

A remote host

A URL

Once it's a URL, it's just a web service

We kept the protocol deliberately light: one request per call, Arrow IPC in the body, no HTTP/2 requirement, no trailers, and no long-lived connection to keep alive. When a call needs to be resumed, the worker hands back a continuation token rather than holding a session open, so the next call can land on a different instance and still be correct. That one property is what makes everything below possible.

Behind a load balancer

A pool of identical workers behind nginx, HAProxy, or an ALB, scaled like any other tier. The usual shape for a shared model or inference service.

Cloudflare Workers

Plain HTTP with a binary body is exactly what an edge runtime serves. No gRPC stack to bring along, no HTTP/2 trailers to negotiate.

Google Cloud Run

Your container, scaled to zero between queries. The worker you tested locally over pipes is the same image, listening on a port.

AWS Lambda

Behind a function URL or API Gateway. Each call is a self-contained request, which is the property a function-as-a-service runtime actually requires.

The honest caveat: scale-to-zero platforms add a cold start to the first call. A catalog scan absorbs that easily; a tight scalar loop doesn't. When per-call latency matters more than idle cost, keep a warm pool behind a load balancer instead.

Catalogs & pushdown

Workers are first-class catalogs

A worker doesn't have to stop at functions — it can expose a whole database. DuckDB then plans against it with the same kind of information it would have about a local table.

Lazy catalogs

Workers expose schemas, tables, and views; DuckDB loads metadata and per-column statistics on demand and supports multi-branch (UNION ALL) tables. An eager-load threshold controls bulk vs. per-object loading.

Pushdown

Workers can opt into projection, filter, ORDER BY + LIMIT, and distinct join-key pushdown. The planner tells the worker what the query actually needs, so the work happens where the data is and the answer comes back smaller than the table.

Wire format

Apache Arrow IPC, all the way down

Every request and response is an Arrow IPC stream — a self-describing schema followed by record batches. That's it. There's no bespoke serialization, no JSON envelope, no language-specific wire types, and nothing to generate from a schema file. If you can read Arrow, you can implement a worker.

The full byte-level specification — opcodes, framing, error handling — is published on the RPC site.

How it compares

VGI vs. native extensions, Airport & ADBC

VGI overlaps with a few other ways of reaching data from DuckDB. Use it when you want your own code — in any language — to show up as SQL functions and tables.

vs. a native C++ extension

A native extension runs in-process, so it wins the truly hot, fine-grained paths — scalar functions that must be called a billion-plus rows a second. VGI runs in a separate process yet still benchmarks at roughly 450 million rows/second, which covers the vast majority of real workloads. The price of that native speed is the bill above: C or C++, a DuckDB version pin, and a build / sign / distribute cycle per release.

vs. Airport

Both move data across a boundary as Arrow, and VGI came out of Airport. Airport speaks Arrow Flight, an open standard, and remains the right call when Flight is already your wire. VGI speaks VGI-RPC over Arrow IPC — which reaches further (all five function shapes, richer catalog semantics, local and launcher transports) and deploys onto ordinary web infrastructure.

vs. ADBC

ADBC Scanner is a database client — you connect to Postgres, Snowflake, or another engine and issue SQL statements to it. VGI is broader and closer to the metal: remote code shows up as DuckDB's own objects — tables, functions, macros, and views — because it carries DuckDB's extension interfaces over RPC rather than acting as a database driver. Use ADBC to query a database; use VGI to make remote code part of DuckDB.

When not to use it

Reach for a native in-process extension only when you genuinely need its throughput — the billion-plus-rows-a-second scalar hot paths where VGI's ~450M rows/second isn't enough. Otherwise, use ADBC to connect to a standard database, and skip VGI when you can't run or host a worker at all.

Keep going

Where to next