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, routingDocumentation 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.
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.
Enabling Replacement Scans
Replacement scans require theudf feature flag — the same one used for scalar and table functions:
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:
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 callingset_function_nameto 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
Calldb.register_replacement_scan::<MyScanner>() on a Database handle. The scan is registered database-wide and applies to every Connection opened from that Database.
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. callsset_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.