Cast Functions

A DuckDB cast function defines how values of one type are converted to another. This page shows how to register one from a Rust extension with quack-rs' CastFunctionBuilder. Once registered, CAST(x AS T) and TRY_CAST(x AS T) use your callback, and so do implicit conversions if you give the cast an implicit cost.

When to use cast functions

  • Your extension introduces a new logical type and needs CAST to/from standard types.
  • You want to override DuckDB's built-in cast behaviour for a specific type pair.
  • You need to control implicit cast priority relative to other registered casts.

Registering a cast

#![allow(unused)]
fn main() {
use quack_rs::cast::{CastFunctionBuilder, CastFunctionInfo, CastMode};
use quack_rs::types::TypeId;
use quack_rs::vector::{VectorReader, VectorWriter};
use libduckdb_sys::{duckdb_function_info, duckdb_vector, idx_t};

unsafe extern "C" fn varchar_to_int(
    info: duckdb_function_info,
    count: idx_t,
    input: duckdb_vector,
    output: duckdb_vector,
) -> bool {
    let cast_info = unsafe { CastFunctionInfo::new(info) };
    let reader = unsafe { VectorReader::from_vector(input, count as usize) };
    let mut writer = unsafe { VectorWriter::new(output) };

    for row in 0..count as usize {
        if !unsafe { reader.is_valid(row) } {
            unsafe { writer.set_null(row) };
            continue;
        }
        let s = unsafe { reader.read_str(row) };
        match s.parse::<i32>() {
            Ok(v) => unsafe { writer.write_i32(row, v) },
            Err(e) => {
                let msg = format!("cannot cast {:?} to INTEGER: {e}", s);
                if cast_info.cast_mode() == CastMode::Try {
                    // TRY_CAST: record a per-row error, which also sets the row to NULL
                    unsafe { cast_info.set_row_error(&msg, row as idx_t, output) };
                } else {
                    // Regular CAST: fail the whole query
                    cast_info.set_error(&msg);
                    return false;
                }
            }
        }
    }
    true
}

fn register(con: libduckdb_sys::duckdb_connection)
    -> Result<(), quack_rs::error::ExtensionError>
{
    unsafe {
        CastFunctionBuilder::new(TypeId::Varchar, TypeId::Integer)
            .function(varchar_to_int)
            .register(con)
    }
}
}

Implicit casts

Provide an implicit_cost to allow DuckDB to use the cast automatically in expressions where the types do not match:

#![allow(unused)]
fn main() {
use quack_rs::cast::CastFunctionBuilder;
use quack_rs::types::TypeId;
use libduckdb_sys::{duckdb_function_info, duckdb_vector, idx_t};
unsafe extern "C" fn my_cast(_: duckdb_function_info, _: idx_t, _: duckdb_vector, _: duckdb_vector) -> bool { true }
fn register(con: libduckdb_sys::duckdb_connection) -> Result<(), quack_rs::error::ExtensionError> {
unsafe {
    CastFunctionBuilder::new(TypeId::Varchar, TypeId::Integer)
        .function(my_cast)
        .implicit_cost(100) // lower = higher priority
        .register(con)
}
}
}

Extra info

Attach arbitrary data to a cast function using extra_info. This is useful for parameterising the cast behaviour (e.g., a rounding mode):

#![allow(unused)]
fn main() {
use quack_rs::cast::CastFunctionBuilder;
use quack_rs::types::TypeId;
use libduckdb_sys::{duckdb_function_info, duckdb_vector, idx_t};
use std::os::raw::c_void;
unsafe extern "C" fn my_cast(_: duckdb_function_info, _: idx_t, _: duckdb_vector, _: duckdb_vector) -> bool { true }
unsafe extern "C" fn my_destroy(_: *mut c_void) {}
fn register(con: libduckdb_sys::duckdb_connection) -> Result<(), quack_rs::error::ExtensionError> {
let mode = Box::into_raw(Box::new("round".to_string())).cast::<c_void>();
unsafe {
    CastFunctionBuilder::new(TypeId::Double, TypeId::BigInt)
        .function(my_cast)
        .implicit_cost(100)
        .extra_info(mode, Some(my_destroy))
        .register(con)
}
}
}

Inside the cast callback, retrieve the extra info with CastFunctionInfo::get_extra_info(). The pointee must be Send + Sync: DuckDB passes the same pointer to the callback on every thread that runs the cast, and the destructor runs on whichever thread releases it. CastFunctionBuilder is Send, so an unregistered builder may also run the destructor on the thread that drops it.

If register returns an error — a missing callback or type, a bare composite TypeId (use new_logical), a null connection, or a source or target type that is or contains ANY/INVALID, which DuckDB refuses — the builder still owns the extra info and runs its destructor exactly once. These cases are checked in Rust before DuckDB is called, because duckdb_register_cast_function rejects them before taking ownership of the pointer.

TRY_CAST vs CAST

Inside your callback, check CastFunctionInfo::cast_mode() to distinguish between the two modes:

ModeUser wroteExpected behaviour on error
CastMode::NormalCAST(x AS T)Call set_error and return false
CastMode::TryTRY_CAST(x AS T)Call set_row_error for each failed row, continue

set_row_error records the message and sets that row of the output to NULL (FlatVector::SetNull inside the C API); row must be less than the callback's count — DuckDB does not check it in release builds.

The return value does nothing in TRY_CAST mode. DuckDB discards it (execute_cast.cpp calls the cast and ignores the result), so returning false does not turn the chunk into NULLs: every row you did not null keeps whatever the output vector held — possibly a previous chunk's value. Null each failed row yourself. The one exception is a panic inside a cast_callback! body: the macro then sets every row of the chunk to NULL, since nothing the body wrote can be trusted.

In Normal mode, set a message before returning false. A cast_callback! body that sets none fails the query with "cast function failed without reporting an error message"; a hand-written extern "C" callback that sets none makes DuckDB report Conversion Error: followed by nothing. An empty message passed to set_error / set_row_error is replaced by a placeholder.

Working example

The examples/hello-ext extension registers two cast functions:

  • CAST(VARCHAR AS INTEGER) / TRY_CAST(VARCHAR AS INTEGER) — basic cast
  • CAST(DOUBLE AS BIGINT) — with implicit_cost(100) and extra_info for rounding mode

See examples/hello-ext/src/lib.rs for complete, copy-paste-ready references.

Complex source and target types

For casts involving complex types like DECIMAL(18, 3) or LIST(VARCHAR), use the new_logical constructor instead of new:

#![allow(unused)]
fn main() {
use quack_rs::cast::CastFunctionBuilder;
use quack_rs::types::{LogicalType, TypeId};
use libduckdb_sys::{duckdb_function_info, duckdb_vector, idx_t};
unsafe extern "C" fn my_cast(_: duckdb_function_info, _: idx_t, _: duckdb_vector, _: duckdb_vector) -> bool { true }
fn register(con: libduckdb_sys::duckdb_connection) -> Result<(), quack_rs::error::ExtensionError> {
unsafe {
    CastFunctionBuilder::new_logical(
        LogicalType::list(TypeId::Varchar),   // LIST(VARCHAR) source
        LogicalType::list(TypeId::Integer),   // LIST(INTEGER) target
    )
    .function(my_cast)
    .register(con)
}
}
}

The source() and target() accessor methods return Option<TypeId> — they return None when the type was set via new_logical (since a LogicalType cannot always be expressed as a simple TypeId).

API reference