Quick start
The whole page in four steps:
Prerequisites
-
Rust 1.86 or newer (
rust-versioninCargo.toml). -
just — the tool the recipes call:
cargo install just -
make (inside Git Bash on Windows) and Python 3 — the official DuckDB
extension-ci-toolsbuild/test flow, whichjust buildruns under the hood (make configure+make debug).just testuses the same flow. -
A DuckDB binary 1.3 or newer (
duckdbonPATH, or point at it withjust DUCKDB=/path/to/duckdb …).
1. Rename the extension
just rename csv_stats
scripts/rename.sh rewrites the five places the extension name has to match — Cargo.toml
([package] name), EXTENSION_NAME in the Makefile, the entry-point symbol in
src/extension/mod.rs, the Justfile and the CI workflow — plus every occurrence in the docs, and
regenerates the Cargo.lock entry. It finishes by printing what still needs a human pass; the functions
and their tests are the main item.
After renaming
What is left once the name is consistent everywhere:
AGENTS.md— fill in the{{PROJECT_GOAL}}placeholder, and set the function-name prefix in the naming convention to your own (the prefix here isop_).- The template's sample functions and
test/sql/*.test— replace them with your API; see writing functions and testing. This repository has already done that: seven function files undersrc/extension/functions/covering nine SQL names, the platform layer insrc/extension/launcher.rs, the persistence layer insrc/extension/writer.rs, the options STRUCT insrc/extension/types/op_write_file_options.rs, and one.testper function file undertest/sql/plus a smoke test. community-extension/description.yml—extension.name/description/maintainers/repoare yours to fill in; the field-by-field reasoning is in community extensions.LICENSE— the copyright holder, and the duckfn version inCargo.toml.- Before the first release, make sure the repository has a
mainbranch and anoriginremote:just release_tagpushes both.
2. Build
just build # = make configure && make debug
The artifact is build/debug/duckfn_open.duckdb_extension. There is no C++ step and no local DuckDB
build: the extension is compiled against DuckDB's headers and dispatches through its API table when it
is loaded.
3. Load and call it
just repl # a DuckDB REPL with the extension already loaded
-- or by hand; -unsigned is required for a locally built extension
duckdb -unsigned -c "LOAD './build/debug/duckfn_open.duckdb_extension';"
What the extension registers, read straight out of the catalog. Every sql fence on this page is a static
code block — the site runs nothing in the browser — so paste them into the REPL above (or load the
artifact into your own duckdb -unsigned first):
SELECT function_name, len(parameters) AS args, return_type
FROM duckdb_functions()
WHERE starts_with(function_name, 'op_')
ORDER BY function_name, args;
Nine SQL names, twenty-one signatures, and every one of them is a scalar function — no aggregate, no table
function. Seven names carry more than one signature under a single SQL name, merged with overloads_name,
which is why the signature count outruns the name count:
op_open_path(path) op_open_path(path, app)
op_open_path_detached(path) op_open_path_detached(path, app)
op_open_commands(path)
op_write_file(content|blob, ext) op_write_file(content|blob, ext, opts)
op_open_content(content|blob, ext) op_open_content(content|blob, ext, opts)
op_open_html(content)
op_print(text) op_print(text, tail)
op_sanitize_filename(value) op_sanitize_filename(value, replacement)
op_sanitize_filename(value, replacement, truncate, windows)
op_sanitize_filename_check(value) op_sanitize_filename_check(value, truncate, windows)
The one type it registers is the options argument of the write family:
SELECT type_name, logical_type
FROM duckdb_types()
WHERE starts_with(type_name, 'op_');
That is op_write_file_options(dir VARCHAR, "name" VARCHAR, part VARCHAR) — name carries quotes because NAME
is a keyword in DuckDB's grammar. Both the signatures and the type are declared in Rust; see writing
functions. The filename pair registers no type alongside them: its arguments are
plain VARCHAR and BOOLEAN, which is worth asserting once in a test, because a Rust argument name is not a
SQL type.
A call that starts no process gives the same answer everywhere, which is why NULL is the portable demo:
SELECT op_open_path(NULL) AS opened; -- NULL input short-circuits, so no launcher is called at all
The failure path is worth running too — an empty argument is an error, not a false:
SELECT op_open_path(''); -- error: the path or URL must not be empty
The write family answers the same way on every machine, because it needs no desktop at all: op_write_file
returns the path it wrote and starts nothing, op_print puts the text on the console and hands the same
text back.
SELECT op_write_file('report body', 'md', {'dir':'./out', 'name':'report', 'part':NULL});
-- ./out/report.md — a missing directory is created, nothing is started
SELECT op_print('row ' || x) FROM range(3) t(x); -- three lines on the console, three rows of text in SQL
The filename pair asks for even less — one string in, a string or a BOOLEAN out, no desktop and no file
system — so it is the one family whose every answer can be asserted verbatim. Its rules and defaults are
sanitize-filename's own (illegal characters, control
characters, all-dot names, the Windows reserved device names, trailing dots and spaces, a cut at 255 bytes),
with one pinned exception: windows is true whatever the host is, because the crate takes the compiling
platform and one query must answer the same way on every machine. It is not a helper of the write family,
which uses the same crate under a different policy.
SELECT op_sanitize_filename('quarterly/report:v2.csv'); -- 'quarterlyreportv2.csv': the defaults delete, they do not substitute
SELECT op_sanitize_filename('a/b:c?.txt', '_'); -- 'a_b_c_.txt': pass a replacement to see where it edited
SELECT op_sanitize_filename_check('CON.txt'); -- false: a Windows reserved device name
SELECT count(*) FROM (VALUES ('a.txt'), ('b/c.txt')) t(name)
WHERE NOT op_sanitize_filename_check(name); -- 1: how many names would be touched, changed by nothing
op_sanitize_filename has no three-argument form: truncate and windows only come as a pair, so
op_sanitize_filename(x, '_', true) is a binder error. That gap is the one compression in this API and it is
deliberate — the reason is in src/extension/functions/scalar_op_sanitize_filename.rs.
What an actual launch answers depends on this machine's desktop: which launchers are installed, and what
.html is associated with. There is no second place to run it — this extension builds for desktop targets
only, see build and release.
SELECT op_open_path('report.html'); -- default program, waits for the launcher
SELECT op_open_path('https://duckdb.org', 'firefox'); -- a program of your choosing
SELECT op_open_path_detached('report.html'); -- returns as soon as the launcher started
SELECT op_open_commands('report.html'); -- the candidate commands, running none
SELECT op_open_content('<svg xmlns="http://www.w3.org/2000/svg"/>', 'svg',
{'dir':'./out', 'name':NULL, 'part':'chart'});
-- write it out, then open it with the default program
SELECT op_open_html('<h1>hello</h1>'); -- the same with nothing to configure: .html, browser
A single query from the command line, without a REPL:
just sql "SELECT op_open_path(NULL)"
4. Run the tests
just test # make configure + make debug + make test, then a cleanup of what the tests wrote
Eight files under test/sql/, and three of them really execute: op_write_file.test writes files and reads them
back, op_print.test writes to stdout, op_sanitize_filename.test asserts whole rewrites on the strings it gets
back — no process, no file system, so nothing there depends on the machine. The files land under test/sql/
(named/, blob/, unique/ …), git-ignored and swept away by that cleanup step.
Testing covers that, the faster loop, and what a new function should cover.
Traps
-unsignedis mandatory when you load a locally built extension. Without it DuckDB refuses the file.- The artifact file name must stay
<extension name>.duckdb_extension. DuckDB finds the entry-point symbol through the file name, so a copy calledwin.duckdb_extensionfails withdid not contain function "duckfn_open_init_c_api". make testdoes not rebuild. After changing Rust code runjust ci-build(ormake debug) first, otherwise the tests run against the previous artifact.
One more, on Windows: if make debug reports the artifact is in use, a DuckDB process is
holding build/debug/duckfn_open.duckdb_extension (usually a just repl left open) — close that
process and rebuild. A .duckdb_extension is not a renamed DLL: DuckDB's metadata lives at the end of
the file, so copying a DLL over it produces The metadata at the end of the file is invalid.