Skip to main content
Everything fallible in better-duck-core returns one Result type, and engine failures carry DuckDB’s own typed classification. This page is the overview: the shape of Error, how the common failures surface, and the two behaviors — appender poisoning and the panic = "abort" caveat — that most often surprise people. For the exhaustive variant list and the extension API, see Errors & Extensions.

One Result type

better_duck_core::error::Error is #[non_exhaustive] — match it with a _ arm. It implements std::error::Error (with source()), Display, and a partial PartialEq, so it drops into anyhow / thiserror and Box<dyn Error>-based error handling cleanly.

How failures are grouped

The variants fall into a few families. The main runtime failure is Engine.

EngineError — DuckDB’s classification

A failed query surfaces as Error::Engine(EngineError { kind, message }). Both fields are copied out of DuckDB memory when the error is built, so the error stays valid after the result handle — and the connection — are gone.
EngineErrorKind mirrors DuckDB’s duckdb_error_type and is #[non_exhaustive]:
  • Known kinds map 1:1 (Syntax, Catalog, Constraint, …; the full set is EngineErrorKind::KNOWN).
  • Unknown(raw) preserves a classification a future DuckDB adds — nothing is lost across engine upgrades.
  • Unavailable is an explicit absence: the originating API exposed only a status code and a message, with no typed classification. The driver never invents a kind by parsing message text.
Match on kind for programmatic branching (e.g. retry on Constraint, surface Syntax to a developer), and show message to a human. EngineErrorKind::from_raw / to_raw round-trip the raw code if you need it.

Appender poisoning

The Appender has a failure mode worth calling out: DuckDB invalidates it the moment a flush, row-end, or close fails, and the buffered data is gone.
A failed append / save poisons the Appender: every later operation fails fast with an Error::Engine carrying the poison message — without re-entering DuckDB — and drop skips the flush entirely (re-flushing an invalidated appender can deadlock DuckDB on indexed / PRIMARY KEY tables). The originating connection stays usable. Use finish() (not drop) when you need the flush/close error reported.
A Rust-side value-conversion failure mid-row also poisons the appender, but returns the original conversion error unchanged rather than a poison message.

Async and pool errors

  • The async facade runs work on spawn_blocking; a panicked or cancelled task surfaces as Error::BackgroundTaskFailed(String).
  • The r2d2 pool’s own builder/checkout errors are r2d2’s type; map them into the core error with Error::Pool(e.to_string()). A checkout timeout likewise arrives as Error::Pool(..).

Panics and the abort profile

This is the single most important runtime gotcha for UDFs and other callbacks.
Callback panics are caught and reported to DuckDB as a query error only under panic = "unwind" (the default for dev and test). Under panic = "abort" — common in release profiles — a panicking UDF aborts the process: catch_unwind cannot catch once unwinding is compiled out.
The fix is to make fallible UDFs return errors instead of panicking — the error path never depends on unwinding:
A Result::Err return fails the query with E’s message; duck_bail!("…") is shorthand for return Err(format!(…).into()).

Errors & Extensions

The exhaustive Error variant table and the extension API.

Appender

Full poisoning and flush semantics.

User-defined functions

Returning errors from UDFs and the panic rules.

Async & pooling

BackgroundTaskFailed and pool errors.