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.
| Method | Signature |
|---|
execute_batch | fn 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],
)?;
| Method | Signature |
|---|
execute_with | fn 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);
| Method | Returns |
|---|
DuckResult::changes() -> u64 | Number 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
| Method | Returns |
|---|
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
| Method | Returns |
|---|
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() -> usize | Number of rows |
result_set.is_empty() -> bool | true if no rows |
result_set.first() -> Option<&DuckRow> | First row if any |
result_set.changes() -> u64 | Affected-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.