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.

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. The 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 the async feature to your Cargo.toml:
[dependencies]
better-duck-core = { version = "0.1.0-beta.4", features = ["async"] }
tokio = { version = "1", features = ["full"] }
The 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

use better_duck_core::AsyncConnection;

#[tokio::main]
async fn main() -> better_duck_core::error::Result<()> {
    // In-memory
    let conn = AsyncConnection::open_in_memory().await?;

    // File-based
    let conn = AsyncConnection::open("my_database.duckdb").await?;

    Ok(())
}

execute_batch

conn.execute_batch("CREATE TABLE t (id INTEGER)").await?;
conn.execute_batch("INSERT INTO t VALUES (1), (2), (3)").await?;

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:
use better_duck_core::result_set::ResultSet;

let result: ResultSet = conn.execute("SELECT * FROM t").await?;

println!("rows: {}", result.len());
for row in result.rows() {
    println!("{:?}", row.get("id"));
}

execute_with (parameterized)

Unlike the synchronous execute_with which takes &mut [&mut dyn AppendAble], the async variant accepts an owned Vec<DuckValue>. This avoids lifetime complications across the spawn_blocking boundary:
use better_duck_core::types::value::DuckValue;

let result = conn.execute_with(
    "SELECT id FROM t WHERE id = $1",
    vec![DuckValue::Int(2)],
).await?;

assert_eq!(result.len(), 1);

Async method summary

MethodSignature
open_in_memoryasync fn open_in_memory() -> Result<AsyncConnection>
openasync fn open<P: AsRef<Path> + Send + 'static>(path: P) -> Result<AsyncConnection>
execute_batchasync fn execute_batch<S: Into<String> + Send>(sql: S) -> Result<()>
executeasync fn execute<S: Into<String> + Send>(sql: S) -> Result<ResultSet>
execute_withasync fn execute_with<S: Into<String> + Send>(sql: S, binds: Vec<DuckValue>) -> Result<ResultSet>
with_connectionasync fn with_connection<F, T>(f: F) -> Result<T>
with_appenderasync fn with_appender<F, T>(table, schema, f: F) -> Result<T>
AsyncConnection methods panic if called outside a Tokio runtime context — this matches the behaviour of tokio::task::spawn_blocking itself. Always call them from within a #[tokio::main] function, a tokio::spawn task, or a #[tokio::test] test.

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

conn.with_connection(|c| {
    c.execute_batch("BEGIN")?;
    c.execute_batch("INSERT INTO t VALUES (10)")?;
    c.execute_batch("INSERT INTO t VALUES (11)")?;
    c.execute_batch("COMMIT")?;
    Ok(())
}).await?;
Returning Err from the closure propagates out through the await; the closure itself is responsible for issuing ROLLBACK if needed:
conn.with_connection(|c| {
    c.execute_batch("BEGIN")?;
    let result = c.execute_batch("INSERT INTO t VALUES (42)");
    if result.is_err() {
        c.execute_batch("ROLLBACK")?;
        return result;
    }
    c.execute_batch("COMMIT")?;
    Ok(())
}).await?;
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:
conn.with_appender("t", "main", |appender| {
    for i in 0..100i32 {
        appender.append(&mut better_duck_core::types::value::DuckValue::Int(i))?;
    }
    appender.save()?;
    Ok(())
}).await?;
// Verify the rows landed
let result = conn.execute("SELECT count(*) AS c FROM t").await?;
match result.rows()[0].get("c") {
    Some(DuckValue::BigInt(n)) => println!("inserted: {n}"),
    _ => {}
}

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:
use better_duck_core::AsyncConnection;

#[tokio::main]
async fn main() -> better_duck_core::error::Result<()> {
    let conn = AsyncConnection::open_in_memory().await?;
    conn.execute_batch("CREATE TABLE t (id INTEGER)").await?;

    let mut handles = Vec::new();
    for i in 0..8i32 {
        let c = conn.clone(); // cheap Arc bump
        handles.push(tokio::spawn(async move {
            c.execute_batch(format!("INSERT INTO t VALUES ({i})")).await.unwrap();
        }));
    }
    for h in handles {
        h.await.unwrap();
    }

    let result = conn.execute("SELECT count(*) AS c FROM t").await?;
    println!("{:?}", result.rows()[0].get("c")); // BigInt(8)
    Ok(())
}
Because clones share one connection and one mutex, concurrent calls will queue behind each other. If you need true concurrent query execution, use AsyncPool instead — it checks out separate connections from the pool for each call.

AsyncDatabase and AsyncPool

The async and pool features work together. When both are enabled, AsyncPool is available:
[dependencies]
better-duck-core = { version = "0.1.0-beta.4", features = ["async", "pool"] }
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:
use better_duck_core::{AsyncPool, pool::{DuckDbConnectionManager, Pool}};

let manager = DuckDbConnectionManager::memory()?;
let pool = Pool::builder().max_size(8).build(manager)?;
let async_pool = AsyncPool::new(pool);

async_pool.execute_batch("CREATE TABLE t (id INTEGER)").await?;

let result = async_pool.execute("SELECT * FROM t").await?;
The with() method is the pool-level equivalent of with_connection — the right place to run a transaction that touches a pooled connection:
async_pool.with(|conn| {
    conn.execute_batch("BEGIN")?;
    conn.execute_batch("INSERT INTO t VALUES (1)")?;
    conn.execute_batch("COMMIT")?;
    Ok(())
}).await?;
For the full AsyncPool API and pool configuration, see the Pooling guide.

Build docs developers (and LLMs) love