DuckDB’s C API is inherently synchronous. Running a DuckDB query on the Tokio executor directly would block the thread for the duration of the query — starving other tasks of CPU time. TheDocumentation 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.
async feature in better-duck solves this by wrapping every DuckDB call in tokio::task::spawn_blocking, which moves the work onto a dedicated blocking thread pool managed by Tokio. From the caller’s perspective, every method is a normal async fn that you await without worrying about thread blocking.
Enabling the async Feature
Add theasync feature to your Cargo.toml:
async feature pulls in tokio and parking_lot as additional dependencies.
AsyncConnection
AsyncConnection is an async facade over a synchronous Connection. Internally it wraps the connection behind an Arc<Mutex<Connection>>, so clones share one connection serialized by the mutex. The Arc means cloning is a cheap reference-count bump.
Opening a connection
execute_batch
execute
AsyncConnection::execute materializes the result set before returning — the raw DuckResult with its FFI handles stays on the blocking thread; only the thread-safe ResultSet crosses the await boundary:
execute_with (parameterized)
Unlike the synchronousexecute_with which takes &mut [&mut dyn AppendAble], the async variant accepts an owned Vec<DuckValue>. This avoids lifetime complications across the spawn_blocking boundary:
Async method summary
| Method | Signature |
|---|---|
open_in_memory | async fn open_in_memory() -> Result<AsyncConnection> |
open | async fn open<P: AsRef<Path> + Send + 'static>(path: P) -> Result<AsyncConnection> |
execute_batch | async fn execute_batch<S: Into<String> + Send>(sql: S) -> Result<()> |
execute | async fn execute<S: Into<String> + Send>(sql: S) -> Result<ResultSet> |
execute_with | async fn execute_with<S: Into<String> + Send>(sql: S, binds: Vec<DuckValue>) -> Result<ResultSet> |
with_connection | async fn with_connection<F, T>(f: F) -> Result<T> |
with_appender | async fn with_appender<F, T>(table, schema, f: F) -> Result<T> |
Running Custom Closures with with_connection
with_connection is the primitive underlying every other async method. It accepts an FnOnce(&mut Connection) -> Result<T> closure, runs it on a blocking thread holding the mutex lock, and returns the result across the await boundary. Use it for anything the typed helpers don’t cover — transactions, multi-statement batches, or operations that need direct access to the Connection.
Transactions
Err from the closure propagates out through the await; the closure itself is responsible for issuing ROLLBACK if needed:
Transactions must not span an
.await point. The with_connection closure runs entirely on a single blocking thread before the future resolves, so the connection never yields to the executor mid-transaction.Bulk Insert with with_appender
with_appender opens a bulk-insert Appender and passes it to your closure on a blocking thread. The Appender holds raw FFI pointers and cannot be moved across threads, so it never leaves the closure:
Cloning AsyncConnection Handles
AsyncConnection is Clone. Each clone increments the Arc counter — all clones share one underlying Connection serialized by the internal mutex. This makes it easy to fan out async tasks that each need database access without opening separate connections:
AsyncDatabase and AsyncPool
Theasync and pool features work together. When both are enabled, AsyncPool is available:
AsyncPool wraps an r2d2 Pool and exposes async methods that check out a connection, run work on a blocking thread, and release the checkout before the await resolves:
with() method is the pool-level equivalent of with_connection — the right place to run a transaction that touches a pooled connection:
AsyncPool API and pool configuration, see the Pooling guide.