Overloading with Function Sets

A DuckDB aggregate function can have several signatures under one name, registered together as a function set. This page shows how to overload an aggregate function in Rust with AggregateFunctionSetBuilder, including variadic aggregates such as retention(c1, c2, ..., c32) and overloads with different return types.

Known DuckDB limitation. C-API aggregates — including every overload in a set — read out of bounds under agg(x) OVER () (whole-partition window frames) and agg(x ORDER BY y). This is a DuckDB C API defect; see Aggregate Functions for the details and DuckDB source lines. Do not use C-API aggregates in those two query shapes.

Reported upstream as duckdb/duckdb#26109.

Note: For scalar function overloads, see ScalarFunctionSetBuilder.


When to use function sets

Use AggregateFunctionSetBuilder when you need:

  • Multiple type signatures for the same function name (e.g., my_agg(INT) and my_agg(BIGINT))
  • Variadic arity under one name (e.g., retention(2 columns), retention(3 columns), ...)
  • Overloads that return different types (see Per-overload return types)

For a single signature, use AggregateFunctionBuilder directly.


Registration

#![allow(unused)]
fn main() {
use libduckdb_sys::{duckdb_aggregate_state, duckdb_bind_info, duckdb_connection,
    duckdb_data_chunk, duckdb_function_info, duckdb_init_info, duckdb_vector, idx_t};
use quack_rs::prelude::*;
#[derive(Default)] struct RetentionState { hits: u32 }
impl AggregateState for RetentionState {}
unsafe extern "C" fn update(_: duckdb_function_info, _: duckdb_data_chunk, _: *mut duckdb_aggregate_state) {}
unsafe extern "C" fn combine(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: *mut duckdb_aggregate_state, _: idx_t) {}
unsafe extern "C" fn finalize(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: duckdb_vector, _: idx_t, _: idx_t) {}
use quack_rs::aggregate::AggregateFunctionSetBuilder;
use quack_rs::types::TypeId;

unsafe fn register(con: duckdb_connection) -> Result<(), ExtensionError> {
    unsafe {
        AggregateFunctionSetBuilder::new("retention")
            .returns(TypeId::Varchar)
            .overloads(2..=3, |n, builder| {
                // Each overload gets `n` BOOLEAN parameters
                let b = (0..n).fold(builder, |b, _| b.param(TypeId::Boolean));
                b.ffi_state::<RetentionState>() // state_size + init + destructor
                    .update(update)
                    .combine(combine)
                    .finalize(finalize)
            })
            .register(con)?;
    }
    Ok(())
}
}

overloads takes a RangeInclusive<usize> and a closure, called once per arity n with a fresh AggregateOverloadBuilder, that returns the configured overload. The set builder gives every member the set's name when it registers them.


Per-overload return types

DuckDB resolves an aggregate overload from its parameter types and arity alone — the return type plays no part in resolution. Members of one set are therefore free to return different types. This is how DuckDB's own arg_max works:

arg_max(ANY, ANY)      -> ANY
arg_max(ANY, ANY, ANY) -> ANY[]

Set the return type on the overload with AggregateOverloadBuilder::returns (or returns_logical), and add each one with overload:

#![allow(unused)]
fn main() {
use libduckdb_sys::{duckdb_aggregate_state, duckdb_bind_info, duckdb_connection,
    duckdb_data_chunk, duckdb_function_info, duckdb_init_info, duckdb_vector, idx_t};
use quack_rs::prelude::*;
#[derive(Default)] struct IntState { sum: i64 }
impl AggregateState for IntState {}
unsafe extern "C" fn int_update(_: duckdb_function_info, _: duckdb_data_chunk, _: *mut duckdb_aggregate_state) {}
unsafe extern "C" fn int_combine(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: *mut duckdb_aggregate_state, _: idx_t) {}
unsafe extern "C" fn int_finalize(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: duckdb_vector, _: idx_t, _: idx_t) {}
#[derive(Default)] struct StrState { longest: String }
impl AggregateState for StrState {}
unsafe extern "C" fn str_update(_: duckdb_function_info, _: duckdb_data_chunk, _: *mut duckdb_aggregate_state) {}
unsafe extern "C" fn str_combine(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: *mut duckdb_aggregate_state, _: idx_t) {}
unsafe extern "C" fn str_finalize(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: duckdb_vector, _: idx_t, _: idx_t) {}
unsafe fn demo(con: duckdb_connection) -> Result<(), ExtensionError> {
use quack_rs::aggregate::{AggregateFunctionSetBuilder, AggregateOverloadBuilder};
use quack_rs::types::TypeId;

unsafe {
    AggregateFunctionSetBuilder::new("my_agg")
        .overload(
            AggregateOverloadBuilder::new()
                .param(TypeId::Integer)
                .returns(TypeId::Integer)      // my_agg(INTEGER) -> INTEGER
                .ffi_state::<IntState>()
                .update(int_update)
                .combine(int_combine)
                .finalize(int_finalize),
        )
        .overload(
            AggregateOverloadBuilder::new()
                .param(TypeId::Varchar)
                .returns(TypeId::Varchar)      // my_agg(VARCHAR) -> VARCHAR
                .ffi_state::<StrState>()
                .update(str_update)
                .combine(str_combine)
                .finalize(str_finalize),
        )
        .register(con)?;
}
Ok(())
}
}

Each overload carries its own callbacks, so overloads with different parameter types can use different state types: call ffi_state::<T>() on each overload with that overload's state type (AggregateFunctionSetBuilder itself has no ffi_state), and read it in that overload's update, combine and finalize with FfiState::<T>::with_state / with_state_mut for the same T.

Which return type wins

For each overload, in order:

  1. AggregateOverloadBuilder::returns_logical
  2. AggregateOverloadBuilder::returns
  3. AggregateFunctionSetBuilder::returns_logical (the set-level default)
  4. AggregateFunctionSetBuilder::returns (the set-level default)

Registration fails, naming the overload index, if an overload reaches the end of that list with nothing set or is missing a required callback. It also fails if two overloads take the same parameter types. All of this is checked for every overload before any DuckDB handle is created.

Other per-overload settings

Each overload can also carry its own extra_info (AggregateOverloadBuilder::extra_info), read in that overload's callbacks with AggregateFunctionInfo::get_extra_info. Ownership works as on AggregateFunctionBuilder: DuckDB frees it once registration has handed it over, even if registration then fails; the builder frees it if it never gets that far.

AggregateOverloadBuilder::null_handling sets an overload's NULL handling.

overload and overloads may be mixed on one builder; overloads register in the order they were added.

Note: AggregateOverloadBuilder was called OverloadBuilder before v0.18.0. The old name is still exported as a deprecated alias at quack_rs::aggregate::builder::OverloadBuilder.


The silent name bug — solved

Pitfall L6: When using a function set, the name must be set on each individual duckdb_aggregate_function via duckdb_aggregate_function_set_name, not just on the set. If any member lacks a name, it is silently not registered — no error is returned.

DuckDB's C API documentation does not mention this. It was found by reading DuckDB's C++ test code at test/api/capi/test_capi_aggregate_functions.cpp. In duckdb-behavioral, 6 of 7 functions failed to register silently due to this bug.

AggregateFunctionSetBuilder::register calls duckdb_aggregate_function_set_name on every member, whether it was added with overload or overloads.

See Pitfall L6.


Complex return types

If all overloads share one complex return type, set it once on the set builder as a default, rather than repeating it on every overload:

#![allow(unused)]
fn main() {
use libduckdb_sys::{duckdb_aggregate_state, duckdb_bind_info, duckdb_connection,
    duckdb_data_chunk, duckdb_function_info, duckdb_init_info, duckdb_vector, idx_t};
use quack_rs::prelude::*;
#[derive(Default)] struct RetentionState { hits: u32 }
impl AggregateState for RetentionState {}
unsafe extern "C" fn update(_: duckdb_function_info, _: duckdb_data_chunk, _: *mut duckdb_aggregate_state) {}
unsafe extern "C" fn combine(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: *mut duckdb_aggregate_state, _: idx_t) {}
unsafe extern "C" fn finalize(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: duckdb_vector, _: idx_t, _: idx_t) {}
unsafe fn demo(con: duckdb_connection) -> Result<(), ExtensionError> {
use quack_rs::aggregate::AggregateFunctionSetBuilder;
use quack_rs::types::{LogicalType, TypeId};

unsafe {
    AggregateFunctionSetBuilder::new("retention")
        .returns_logical(LogicalType::list(TypeId::Boolean))  // default for every overload
        .overloads(2..=32, |n, builder| {
            (0..n).fold(builder, |b, _| b.param(TypeId::Boolean))
                .ffi_state::<RetentionState>()
                .update(update)
                .combine(combine)
                .finalize(finalize)
        })
        .register(con)?;
}
Ok(())
}
}

Individual overloads can also use param_logical for complex parameter types:

#![allow(unused)]
fn main() {
use libduckdb_sys::{duckdb_aggregate_state, duckdb_bind_info, duckdb_connection,
    duckdb_data_chunk, duckdb_function_info, duckdb_init_info, duckdb_vector, idx_t};
use quack_rs::prelude::*;
fn demo() {
let _ = AggregateFunctionSetBuilder::new("retention")
.overloads(2..=8, |n, builder| {
    builder
        .param(TypeId::Interval)
        .param_logical(LogicalType::list(TypeId::Timestamp)) // LIST(TIMESTAMP) parameter
        // ...
})
;
}
}

Why not varargs?

DuckDB's C API has no duckdb_aggregate_function_set_varargs. A variadic aggregate must therefore be registered as one overload per supported arity, which overloads does in one call.

Note: Scalar functions do support varargs, through ScalarFunctionBuilder::varargs() (stable C API, no feature flag needed).