Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nimdeveloper/better-duck/llms.txt

Use this file to discover all available pages before exploring further.

Scalar user-defined functions (UDFs) compute one value per input row and can appear anywhere in a SELECT list or WHERE clause — SELECT my_func(x) FROM t. With better-duck’s udf feature, the #[duckdb_scalar] attribute macro turns an ordinary Rust function into a DuckDB scalar function with zero unsafe code and no manual vector handling. Parameter and return types are inferred directly from the Rust signature via the DuckLogicalType trait, so any type that already round-trips through AppendAble (all integer widths, floats, String/&str, bool, and more) works without any extra type table.

Enabling UDF Support

Scalar functions are gated behind the udf feature flag. Add it to your Cargo.toml:
[dependencies]
better-duck-core = { version = "0.1.0-beta.4", features = ["udf"] }

Basic Scalar Function

Annotate any fn with #[duckdb_scalar], then call <fn_name>::register(&mut conn) before querying. The macro generates a private module named after your function that exposes the register function.
use better_duck_core::{connection::Connection, duckdb_scalar};

#[duckdb_scalar]
fn repeat_str(s: &str, n: i32) -> String {
    s.repeat(n.max(0) as usize)
}

let mut conn = Connection::open_in_memory()?;
repeat_str::register(&mut conn)?;

let mut result = conn.execute("SELECT repeat_str('ab', 3) AS r")?;
// r = "ababab"
The SQL function name defaults to the Rust function name. Override it with the name option:
#[duckdb_scalar(name = "to_int")]
fn parse_int(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse()
}

parse_int::register(&mut conn)?;
conn.execute("SELECT to_int('42')")?; // calls the SQL function "to_int"
The #[duckdb_scalar] macro does not consume the original function — it remains a perfectly ordinary, directly callable Rust function. The generated <fn_name> module containing register is placed alongside it in the same scope.

NULL Handling

Use Option<T> for parameters that may receive NULL and for return types that may produce NULL. When any non-Option parameter receives a NULL value, DuckDB’s default NULL-propagation kicks in automatically — the function body is not called and the output row is set to NULL.
use better_duck_core::duckdb_scalar;

/// Returns NULL when the input is NULL; doubles it otherwise.
#[duckdb_scalar]
fn double_or_null(x: Option<i32>) -> Option<i32> {
    x.map(|v| v * 2)
}
SELECT double_or_null(x) AS r FROM t ORDER BY x;
-- x = 21  →  r = 42
-- x = NULL →  r = NULL
The special-handling flag (which tells DuckDB to call the function even for NULL inputs, rather than short-circuiting) is inferred automatically: it is set whenever any parameter is Option<T>, and omitted otherwise.

Error Handling

Return Result<T, E> to propagate errors back to DuckDB as a query failure. On Err, the query fails and the error message comes from E’s Display implementation. The connection remains fully usable afterwards.
use better_duck_core::duckdb_scalar;

#[duckdb_scalar(name = "to_int")]
fn parse_int(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse()
}
You can also use the duck_bail! macro for early returns with a formatted message from inside any fallible UDF body:
use better_duck_core::{duck_bail, duckdb_scalar};

#[duckdb_scalar]
fn safe_divide(a: i64, b: i64) -> Result<i64, String> {
    if b == 0 {
        duck_bail!("division by zero: a={a}");
    }
    Ok(a / b)
}
duck_bail! expands to return Err(format!(...).into()). The surrounding function’s error type must implement From<String>, which is true for String itself and for Box<dyn std::error::Error + Send + Sync>.

Shared State with duck_state!

Use #[duckdb_scalar(state(Type, init_expr))] to attach a value to the function at registration time. The value is initialized once with init_expr, stored as VScalar::State, and is accessible from inside the function body via the duck_state!(Type) macro. The state is Cloned each time duck_state! is called.
use better_duck_core::{connection::Connection, duck_state, duckdb_scalar};

#[duckdb_scalar(state(i32, 10))]
fn add_offset(x: i32) -> i32 {
    x + duck_state!(i32)
}

let mut conn = Connection::open_in_memory()?;
add_offset::register(&mut conn)?;
conn.execute_batch("CREATE TABLE t (x INTEGER)")?;
conn.execute_batch("INSERT INTO t VALUES (1), (2), (3)")?;

let result = conn.execute("SELECT add_offset(x) AS r FROM t ORDER BY x")?;
// r = 11, 12, 13
The state option accepts any Type and any Rust expression for init_expr — it is evaluated at the call to register. The state value is shared read-only across all invocations on all rows; it is not mutable during query execution.
Use state to inject configuration like format strings, scaling factors, or feature flags that are fixed at registration time but vary between deployments. For truly dynamic per-query context, register separate function instances with different state values.

volatile Flag

By default, DuckDB constant-folds zero-argument scalar functions — a call like SELECT my_const() is evaluated once at plan time and the result is inlined. Use volatile to opt out:
use better_duck_core::duckdb_scalar;

#[duckdb_scalar(volatile)]
fn answer() -> i32 {
    42
}
volatile functions are re-evaluated for every row, even with zero parameters. Use this when the function has side effects or depends on external state that may change between calls.

Panic Handling

If the function body panics and the crate is built with panic = "unwind" (the default for dev and test profiles), the panic is caught and reported to DuckDB as a query error. The connection remains usable.
Under a panic = "abort" build — common in release profiles — catch_unwind is a no-op and a panicking UDF aborts the entire process. Always prefer returning Err from a fallible function body over panicking; the error path never depends on unwinding.

Complete Attribute Reference

OptionDescription
name = "sql_name"Override the SQL function name (defaults to the Rust function name)
crate = ::pathRe-export escape hatch for the generated code’s ::better_duck_core path
volatileDisable DuckDB’s zero-argument constant-folding
state(Type, init_expr)Attach a VScalar::State value, readable inside the body via duck_state!(Type)

Build docs developers (and LLMs) love