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.

better-duck exposes three methods for executing SQL against a Connection. All three are synchronous and return immediately after DuckDB has processed the statement. For SELECT queries, the result set is streamed row-by-row through an Iterator; for DML, the affected-row count is available without consuming any rows. This page walks through each method, explains how to read values from the returned rows, and covers the utility helpers — changes(), exists(), and materialize() — that sit on top of the iterator.

execute_batch

execute_batch accepts one or more semicolon-separated SQL statements, executes them all, and discards every result. It is the right choice for DDL (CREATE TABLE, DROP TABLE, ALTER TABLE) and for DML where you only care whether the statement succeeded, not how many rows were affected.
use better_duck_core::connection::Connection;

fn main() -> better_duck_core::error::Result<()> {
    let mut conn = Connection::open_in_memory()?;

    conn.execute_batch(
        "CREATE TABLE events (id INTEGER, label TEXT, score DOUBLE);
         INSERT INTO events VALUES (1, 'alpha', 9.5), (2, 'beta', 7.2);",
    )?;

    Ok(())
}
execute_batch is annotated #[must_use = "execute_batch result should be checked"]. Always propagate or handle the returned Result<()> — ignoring it discards any error silently.
MethodSignature
execute_batchfn execute_batch(&mut self, sql: impl AsRef<str>) -> Result<()>

execute

execute prepares and executes a single SQL statement and returns a DuckResult. DuckResult implements Iterator<Item = Result<DuckRow>>, so iterating it yields rows one at a time. It works for all statement types:
  • SELECT — iterate rows via the Iterator implementation.
  • INSERT / UPDATE / DELETE — call .changes() for the number of affected rows (no need to iterate).
  • DDL — succeeds silently; .changes() returns 0.
  • INSERT … RETURNING — both iterate rows and check .changes().
use better_duck_core::connection::Connection;

fn main() -> better_duck_core::error::Result<()> {
    let mut conn = Connection::open_in_memory()?;
    conn.execute_batch(
        "CREATE TABLE events (id INTEGER, label TEXT, score DOUBLE);
         INSERT INTO events VALUES (1, 'alpha', 9.5), (2, 'beta', 7.2);",
    )?;

    let mut result = conn.execute("SELECT id, label, score FROM events ORDER BY id")?;
    for row in result {
        let row = row?;
        println!(
            "id={:?}  label={:?}  score={:?}",
            row.get("id"),
            row.get("label"),
            row.get("score"),
        );
    }
    Ok(())
}
execute is annotated #[must_use = "the DuckResult carries both affected-row count (.changes()) and a row iterator — consume it"]. Bind the result to a variable even when you intend to call only .changes().

Parameterized Queries with execute_with

execute_with is the parameterized counterpart to execute. Parameters are positional and referenced in SQL as $1, $2, etc. Values are passed as &mut [&mut dyn AppendAble]; DuckValue already implements AppendAble, so you can pass DuckValue variants directly.
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 events (id INTEGER, label TEXT, score DOUBLE);
         INSERT INTO events VALUES (1, 'alpha', 9.5), (2, 'beta', 7.2);",
    )?;

    let mut threshold = DuckValue::Double(8.0);
    let mut rows = conn.execute_with(
        "SELECT id, label FROM events WHERE score > $1",
        &mut [&mut threshold],
    )?;

    for row in rows {
        let row = row?;
        println!("{:?}", row);
    }
    Ok(())
}
Multiple parameters are supplied in the same slice, ordered to match $1, $2, …:
let mut min_score = DuckValue::Double(7.0);
let mut max_score = DuckValue::Double(10.0);

let mut rows = conn.execute_with(
    "SELECT id FROM events WHERE score BETWEEN $1 AND $2",
    &mut [&mut min_score, &mut max_score],
)?;
MethodSignature
execute_withfn execute_with(&mut self, sql: impl AsRef<str>, binds: &mut [&mut dyn AppendAble]) -> Result<DuckResult>

Reading Rows

DuckResult yields Result<DuckRow> items. Each DuckRow holds a Vec<DuckValue> aligned to the column list, and column names are Arc-shared across the entire result — cloning a row (for the rewind cache or other purposes) is a single atomic reference-count bump.

DuckRow::get

Look up a column value by name:
let value: Option<&DuckValue> = row.get("column_name");
Returns None if no column with that name exists. The comparison is case-sensitive.

DuckRow::get_idx

Look up by zero-based column index:
let value: Option<&DuckValue> = row.get_idx(0);

Matching on DuckValue

DuckValue is a #[non_exhaustive] enum. Always include a wildcard arm to stay forward-compatible as new type variants are added:
use better_duck_core::types::value::DuckValue;

fn main() -> better_duck_core::error::Result<()> {
    let mut conn = Connection::open_in_memory()?;
    conn.execute_batch(
        "CREATE TABLE events (id INTEGER, label TEXT, score DOUBLE);
         INSERT INTO events VALUES (1, 'alpha', 9.5), (2, 'beta', 7.2);",
    )?;

    let mut result = conn.execute("SELECT id, label, score FROM events")?;
    for row in result {
        let row = row?;

        match row.get("id") {
            Some(DuckValue::Int(n))    => println!("id (i32): {n}"),
            Some(DuckValue::BigInt(n)) => println!("id (i64): {n}"),
            Some(other)                => println!("unexpected type: {other:?}"),
            None                       => println!("column not found"),
        }

        match row.get("label") {
            Some(DuckValue::Text(s)) => println!("label: {s}"),
            Some(DuckValue::Null)    => println!("label is NULL"),
            Some(other)              => println!("unexpected: {other:?}"),
            None                     => println!("column not found"),
        }

        match row.get("score") {
            Some(DuckValue::Double(f)) => println!("score: {f}"),
            _                          => {},
        }
    }
    Ok(())
}
DuckDB maps INTEGER columns to DuckValue::Int(i32) and BIGINT to DuckValue::BigInt(i64). If you see BigInt where you expected Int, DuckDB may have inferred a wider type — match both arms or use get_idx with a known column position.

Checking Changes

For INSERT, UPDATE, and DELETE statements, call .changes() on the DuckResult to retrieve the number of affected rows. There is no need to iterate the result — you can call changes() and discard it immediately.
let mut conn = Connection::open_in_memory()?;
conn.execute_batch("CREATE TABLE t (id INTEGER)")?;

let n = conn.execute("INSERT INTO t VALUES (1), (2), (3)")?.changes();
assert_eq!(n, 3);

let deleted = conn.execute("DELETE FROM t WHERE id > 1")?.changes();
assert_eq!(deleted, 2);
MethodReturns
DuckResult::changes() -> u64Number of rows affected by the last INSERT/UPDATE/DELETE; 0 for SELECT

Checking Existence

exists() peeks at the first row without consuming it. A subsequent call to next() — or a for loop — still yields that row. This is useful for existence checks before committing to a full iteration, or for early-return patterns where you only need to know whether any rows matched.
let mut result = conn.execute("SELECT id FROM t WHERE id = 42")?;

if result.exists()? {
    println!("found at least one row");
    for row in result {
        let row = row?;
        // ... process row
    }
} else {
    println!("no rows matched");
}
exists() is also idempotent — calling it multiple times before consuming rows always returns the same answer:
assert!(result.exists()?);
assert!(result.exists()?); // still true; the row has not been consumed
MethodReturns
DuckResult::exists() -> Result<bool>true if at least one row remains; peeks without consuming

Materializing Results

DuckResult holds raw FFI handles and is not Send or Sync. To move query results across thread boundaries — for example, out of a spawn_blocking closure into async code — call .materialize() to consume the iterator and produce an owned ResultSet.
use better_duck_core::result_set::ResultSet;

let result_set: ResultSet = conn
    .execute("SELECT id, label FROM events ORDER BY id")?
    .materialize()?;

println!("rows: {}", result_set.len());
println!("changes: {}", result_set.changes());

for row in result_set.rows() {
    println!("{:?}", row.get("label"));
}

// Or iterate by value and take ownership of each row
for row in result_set {
    println!("{:?}", row.get_idx(0));
}
ResultSet is Send + Sync + Clone and holds no FFI handles, so it can freely cross thread or task boundaries.

ResultSet API

MethodReturns
result_set.rows() -> &[DuckRow]All rows as a slice
result_set.into_rows() -> Vec<DuckRow>Consume and take ownership of the rows
result_set.len() -> usizeNumber of rows
result_set.is_empty() -> booltrue if no rows
result_set.first() -> Option<&DuckRow>First row if any
result_set.changes() -> u64Affected-row count from the originating query
result_set.column_names() -> &[Box<str>]Column names in result order
AsyncConnection::execute() and AsyncPool::execute() return a ResultSet directly — the async layer calls .materialize() internally before crossing the spawn_blocking boundary. You only need to call it yourself when working with the synchronous Connection in a threading scenario.

Build docs developers (and LLMs) love