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.

A replacement scan is a hook that DuckDB calls whenever it cannot resolve a table reference to an existing table, view, or CTE. The hook can inspect the unresolved name and either rewrite it into a table function call — for example, routing SELECT * FROM '5.range' to range(5), or routing SELECT * FROM 'data.parquet' to a custom Parquet reader function — or silently decline so DuckDB reports its normal “table not found” error. Replacement scans are well-suited to extension-based dispatch: examine the suffix of a string literal table name, map it to the right reader function, and thread the original name through as a parameter.
Replacement scans in better-duck are experimental. This is a from-scratch safe-Rust design with no reference implementation to model against. The v1 API deliberately restricts what a scan callback may do: it may only set a function name and add literal parameters. You cannot run SQL from inside the callback — DuckDB invokes the hook while resolving the very query being planned, on the same connection. Issuing another query from inside the callback would re-enter that in-progress resolution and likely deadlock or corrupt it. If a rewrite needs data that can only come from a query, compute it before the query that triggers the scan, not inside the callback.

Enabling Replacement Scans

Replacement scans require the udf feature flag — the same one used for scalar and table functions:
[dependencies]
better-duck-core = { version = "0.1.0-beta.4", features = ["udf"] }

The ReplacementScan Trait

Implement the ReplacementScan trait to define a scan hook. The trait is stateless by design — like VTab and VScalar, the implementing type is used as a marker and is never instantiated. All logic lives in the single replace method:
pub trait ReplacementScan {
    fn replace(
        table_name: &str,
        info: &ReplacementScanInfo,
    ) -> better_duck_core::udf::UdfResult<()>;
}
Inside replace:
  • Call info.set_function_name("fn_name")? to name the table function that should handle this reference.
  • Call info.add_parameter(&value)? one or more times to pass literal arguments to that function, in order.
  • Return Ok(()) without calling set_function_name to decline the rewrite — DuckDB will then try any other registered replacement scans, and finally report “table not found” if none claim it.
  • Return Err(...) to fail the query with a custom error message instead of the default “table not found”.
UdfResult<T> is Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>, so any error type that implements std::error::Error (or converts to String via From) works.

Registering a Replacement Scan

Call db.register_replacement_scan::<MyScanner>() on a Database handle. The scan is registered database-wide and applies to every Connection opened from that Database.
use better_duck_core::{
    database::Database,
    udf::{ReplacementScan, ReplacementScanInfo},
};

struct RangeByExtension;

impl ReplacementScan for RangeByExtension {
    fn replace(
        table_name: &str,
        info: &ReplacementScanInfo,
    ) -> better_duck_core::udf::UdfResult<()> {
        // Only handle names ending in ".range"; decline everything else.
        let Some(stem) = table_name.strip_suffix(".range") else {
            return Ok(());
        };
        // Parse the stem as an integer or fail the query with a message.
        let n: i64 = stem
            .parse()
            .map_err(|_| format!("'{table_name}' has a non-integer stem"))?;
        info.set_function_name("range")?;
        info.add_parameter(&n)?;
        Ok(())
    }
}

let db = Database::open_in_memory()?;
db.register_replacement_scan::<RangeByExtension>();

let mut conn = db.connect()?;
conn.execute("SELECT * FROM '5.range' ORDER BY range")?;
// Routed to: range(5)  →  0, 1, 2, 3, 4
Replacement scans are registered on Database, not on Connection. A scan registered on db automatically applies to every connection obtained from db.connect(), including ones opened before or after the scan was registered. Connection::open_in_memory() creates an independent database and does not share replacement scans with any other connection.

Multiple Scans and Ordering

You can register more than one replacement scan on the same database. DuckDB calls each registered scan in the order they were added, stopping at the first one that claims the name (i.e. calls set_function_name). A scan that returns Ok(()) without calling set_function_name is treated as a pass-through and the next registered scan is tried.

Scope and Limitations

Database-scoped

A replacement scan applies to all connections on the same Database handle. Opening a separate database — even in-memory via Connection::open_in_memory() — gives a completely independent scan registry.

Literal parameters only

The add_parameter method accepts any type implementing DuckDialect (all scalar primitives, strings, booleans, integers, floats). Computed values that require querying DuckDB cannot be used — see the warning above.

No SQL inside the callback

DuckDB invokes the hook during query planning on the same connection. Any attempt to issue a query from inside the callback re-enters the in-progress resolution and risks a deadlock. Prepare any dynamic data before the triggering query.

Panic containment

Like scalar and table function callbacks, a panicking replacement scan callback is caught under panic = "unwind" and surfaces as a query error. Under panic = "abort" builds it will abort the process. Prefer returning Err over panicking.

Build docs developers (and LLMs) love