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.

When you need to load thousands or millions of rows, using individual INSERT statements is slow: each one goes through the SQL parser, the planner, and the executor. The Appender bypasses all of that and streams rows directly into DuckDB’s bulk-ingest path — no SQL parsing, no per-row query overhead. In benchmarks against the community duckdb crate, better-duck’s appender ingests 10,000 rows in roughly 32 ms (≈ 313k rows/s), outpacing statement-per-row approaches by an order of magnitude.

Creating an Appender

Call conn.appender(table, schema) to open an appender for a specific table. You must pass both the table name and the schema name — use "main" for the default schema.
use better_duck_core::connection::Connection;

let mut conn = Connection::open_in_memory()?;
conn.execute_batch("CREATE TABLE events (id INTEGER, label TEXT)")?;

let mut app = conn.appender("events", "main")?;
appender() is annotated #[must_use = "appender should be used to insert rows"]. Bind the returned Appender to a variable; dropping it immediately flushes any buffered rows but discards the handle.
MethodSignature
conn.appenderfn appender(&mut self, table: &str, schema: &str) -> Result<Appender>
appender() returns an error if the table does not exist or the DuckDB appender cannot be created. Always create the table with execute_batch (or equivalent DDL) before opening an appender on it.

The AppendAble Trait

Values appended through the Appender must implement the AppendAble trait. This trait has two methods:
pub trait AppendAble {
    /// Called inside a begin_row / end_row pair to write column values.
    fn appender_append(&mut self, appender: duckdb_appender) -> Result<()>;

    /// Called to bind this value as a positional parameter in a prepared statement.
    fn stmt_append(&mut self, idx: u64, stmt: duckdb_prepared_statement) -> Result<()>;
}
DuckValue already implements AppendAble for all its variants, so you can append DuckValue instances directly without writing any custom code. For high-throughput scenarios — or when you want to avoid constructing DuckValue enums — you can implement AppendAble on your own struct and call the raw DuckDB FFI functions inside unsafe blocks.

Appending Rows

Call appender.append(&mut value) once per row. The appender wraps each call in duckdb_appender_begin_row / duckdb_appender_end_row automatically. After all rows have been appended, call appender.save() to flush the internal buffer to DuckDB.
let mut app = conn.appender("events", "main")?;

app.append(&mut DuckValue::Int(1))?;
app.append(&mut DuckValue::Int(2))?;
app.append(&mut DuckValue::Int(3))?;

app.save()?; // flush to DuckDB
MethodSignature
app.appendfn append<T: AppendAble>(&mut self, row: &mut T) -> Result<()>
app.savefn save(&mut self) -> Result<()>

Auto-flush on Drop

When an Appender goes out of scope, its Drop implementation automatically flushes any unflushed rows to DuckDB. If the flush fails during drop, the error is printed to stderr — it does not panic and does not propagate.
{
    let mut app = conn.appender("events", "main")?;
    for i in 0..1000i32 {
        app.append(&mut DuckValue::Int(i))?;
    }
} // <-- appender dropped here; rows flushed silently on drop
If you need to handle flush errors, call app.save() explicitly before the appender is dropped. An error from the implicit drop-flush goes to stderr and is otherwise lost.

Custom AppendAble Implementation

For maximum performance or multi-column rows, implement AppendAble on your own struct. Inside appender_append, call the DuckDB FFI append functions directly — one call per column, in the column order your table was defined with.
use better_duck_core::{connection::Connection, types::appendable::AppendAble};
use better_duck_core::ffi::{
    duckdb_appender, duckdb_prepared_statement,
    duckdb_append_int32, duckdb_bind_int32,
};
use better_duck_core::error::Result;

struct IntRow(i32);

impl AppendAble for IntRow {
    fn appender_append(&mut self, appender: duckdb_appender) -> Result<()> {
        // SAFETY: appender is valid and the table has one INTEGER column.
        unsafe { duckdb_append_int32(appender, self.0) };
        Ok(())
    }

    fn stmt_append(&mut self, idx: u64, stmt: duckdb_prepared_statement) -> Result<()> {
        // SAFETY: stmt is valid; idx is a 1-based parameter index.
        unsafe { duckdb_bind_int32(stmt, idx, self.0) };
        Ok(())
    }
}

fn main() -> Result<()> {
    let mut conn = Connection::open_in_memory()?;
    conn.execute_batch("CREATE TABLE nums (v INTEGER)")?;

    let mut app = conn.appender("nums", "main")?;
    for i in 0..10_000i32 {
        app.append(&mut IntRow(i))?;
    }
    app.save()?; // flush to DuckDB

    let mut result = conn.execute("SELECT count(*) AS c FROM nums")?;
    let row = result.next().unwrap()?;
    println!("inserted rows: {:?}", row.get("c"));
    Ok(())
}
For multi-column tables, call one FFI append function per column inside appender_append:
use std::ffi::CString;
use better_duck_core::ffi::{
    duckdb_append_int32, duckdb_append_varchar,
    duckdb_bind_int32, duckdb_bind_varchar,
};

struct EventRow(i32, &'static str);

impl AppendAble for EventRow {
    fn appender_append(&mut self, appender: duckdb_appender) -> Result<()> {
        unsafe {
            duckdb_append_int32(appender, self.0);
            let label = CString::new(self.1).unwrap();
            duckdb_append_varchar(appender, label.as_ptr());
        }
        Ok(())
    }

    fn stmt_append(&mut self, idx: u64, stmt: duckdb_prepared_statement) -> Result<()> {
        unsafe {
            duckdb_bind_int32(stmt, idx, self.0);
            let label = CString::new(self.1).unwrap();
            duckdb_bind_varchar(stmt, idx + 1, label.as_ptr());
        }
        Ok(())
    }
}

Using DuckValue Directly

For simpler scenarios where you don’t need a custom struct, append DuckValue variants directly. This requires no additional code and works for any type that DuckValue already models.
use better_duck_core::{connection::Connection, types::value::DuckValue};

fn main() -> better_duck_core::error::Result<()> {
    let mut conn = Connection::open_in_memory()?;
    conn.execute_batch("CREATE TABLE scores (v DOUBLE)")?;

    let mut app = conn.appender("scores", "main")?;
    for i in 0..100 {
        app.append(&mut DuckValue::Double(i as f64 * 0.5))?;
    }
    app.save()?;

    Ok(())
}
For async bulk insert, use AsyncConnection::with_appender() which runs the appender closure on a blocking thread and never holds the Appender handle across an .await point. See the Async guide for details.

Build docs developers (and LLMs) love