Skip to main content

Errors and panics

Errors​

DuckOptionResult<T> is Result<Option<T>, ExtensionError>, which gives a function three outcomes at once:

#[duck_scalar_function]
fn dfn_scalar_ret_checked(i: i32) -> DuckOptionResult<i32> {
if i == 0 {
return Err(duck_error("dfn_scalar_ret_checked: division by zero"));
}
if i < 0 {
return Ok(None);
}
Ok(Some(100 / i))
}
Return valueSQL
Ok(Some(v))v
Ok(None)NULL
Err(e)The query fails with the message of e.
SELECT dfn_scalar_ret_checked(4); -- 25
SELECT dfn_scalar_ret_checked(-1); -- NULL
SELECT dfn_scalar_ret_checked(0); -- error: dfn_scalar_ret_checked: division by zero

duck_error("…") constructs the error. ExtensionError is quack_rs::error::ExtensionError, so ? also propagates errors from quack-rs APIs directly, and format! is the usual way to put values into the message.

Conventions used throughout the example extension, and worth following:

  • Start the message with the name of the function, so it is obvious where the failure came from: dfn_cast_str_to_int: not an integer: "abc", dfn_table_checked: n must be >= 0.
  • Reserve Ok(None) for "no value for this row", and use Err for real failures.

Note that Result<T, ExtensionError> is not one of the shapes the macros accept — use DuckOptionResult<T> and wrap successful values in Some.

Panics​

A panic inside a duckfn function does not unwind across the FFI boundary. It is caught and reported as a query error:

#[duck_scalar_function]
fn dfn_scalar_ret_panic(i: i32) -> i32 {
if i == 13 {
panic!("unlucky input: {i}");
}
i
}
SELECT dfn_scalar_ret_panic(1); -- 1
SELECT dfn_scalar_ret_panic(13); -- error: unlucky input: 13
The message differs under DuckDB-Wasm

The block above runs in DuckDB-Wasm, where the same input reports Maximum call stack size exceeded instead of unlucky input: 13: a caught panic surfaces without its message in the wasm build. The native build — and the sqllogictest suite — report the message quoted above, which is what duckdb -unsigned shows locally.

The same holds for every registration kind:

KindPanics caught inExample
ScalarThe function bodySELECT dfn_scalar_ret_panic(13);
AggregateThe row handlerSELECT dfn_agg_panic(x) FROM (VALUES (13)) t(x);
CastThe function bodySELECT CAST('NaN'::DOUBLE AS BIGINT); → dfn_cast_double_to_bigint: not a finite number: NaN
Table functionThe iterator, and the bind stepSELECT * FROM dfn_table_full(5); → dfn_table_full: bad row 2
Replacement scanThe callbackSELECT * FROM 'boom.panic'; → dfn_scan_points: panic while handling boom.panic
Panics are a safety net, not a control-flow tool

Being caught keeps the query, not the developer, in charge: a panic aborts the whole query and loses the error type you would otherwise choose. Prefer Ok(None) and Err(duck_error(…)).

Where each failure surfaces​

Table functions. The return shape decides when a failure is reported:

FailureShapeResult
Invalid argumentsDuckResult<impl Iterator<Item = Row>>The query fails before any row is produced.
A NULL rowOk(None) in a DuckFullIteratorResultEvery column of that row is NULL.
A row that cannot be producedErr(…) in a DuckFullIteratorResultThe query fails.

Casts. The same Err behaves differently depending on how the cast was reached:

SQLResult
CAST('abc' AS INTEGER)The query fails.
TRY_CAST('abc' AS INTEGER)NULL for that row, and the query continues.

Aggregates. An Err returned from the row handler fails the query; there is no per-row NULL channel any more, because a row only updates the state.

Source and tests​

Next​