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-diesel implements Diesel’s AnsiTransactionManager, which means transactional safety works the same way it does against PostgreSQL or SQLite. Wrap a block of DML operations in conn.transaction(|conn| { ... }) and the outcome is fully atomic: return Ok to commit, return Err to roll back. Every DML error propagated out of the closure automatically triggers a rollback before the error is surfaced to the caller.

Basic Transactions

Pass a closure that borrows the connection and returns a Result. The closure receives a &mut DuckDbConnection that is tied to the in-progress transaction — use it as you would the outer connection.
use better_duck_diesel::DuckDbConnection;
use diesel::{connection::SimpleConnection, prelude::*};

diesel::table! {
    products (id) {
        id    -> Integer,
        name  -> Text,
        price -> Double,
    }
}

let mut conn = DuckDbConnection::establish(":memory:")?;
conn.batch_execute(
    "CREATE TABLE products (id INTEGER PRIMARY KEY, name VARCHAR NOT NULL, price DOUBLE NOT NULL)",
)?;

conn.transaction(|conn| {
    diesel::insert_into(products::table)
        .values((
            products::id.eq(3),
            products::name.eq("doohickey"),
            products::price.eq(4.99),
        ))
        .execute(conn)?;
    // Returning Ok(()) commits the transaction.
    Ok(())
})?;
Returning Err from any point inside the closure — including via the ? operator — causes an immediate rollback and no rows are persisted:
let result = conn.transaction(|conn| -> QueryResult<()> {
    diesel::insert_into(products::table)
        .values((products::id.eq(4), products::name.eq("ephemeral"), products::price.eq(1.00)))
        .execute(conn)?;
    // Force a rollback.
    Err(diesel::result::Error::RollbackTransaction)
});

assert!(result.is_err());
// The row with id=4 was never committed.

Nested Transactions

AnsiTransactionManager uses SQL SAVEPOINT / RELEASE SAVEPOINT / ROLLBACK TO SAVEPOINT for nested transactions. Calling conn.transaction(...) inside an already-open transaction automatically issues a savepoint rather than a bare BEGIN. Rolling back the inner transaction releases only its savepoint; the outer transaction remains open and can still commit.
conn.transaction(|outer| {
    diesel::insert_into(products::table)
        .values((products::id.eq(10), products::name.eq("outer row"), products::price.eq(5.00)))
        .execute(outer)?;

    // Inner savepoint — this is rolled back.
    let _ = outer.transaction(|inner| -> QueryResult<()> {
        diesel::insert_into(products::table)
            .values((products::id.eq(11), products::name.eq("inner row"), products::price.eq(3.00)))
            .execute(inner)?;
        Err(diesel::result::Error::RollbackTransaction)
    });

    // The outer transaction still sees its own row (id=10) but not the inner one (id=11).
    let n: i64 = products::table.count().first(outer)?;
    assert_eq!(n, 1);

    QueryResult::Ok(())
})?;

// After the outer commits, only "outer row" is present.

Error Handling

Any QueryResult::Err returned from inside the closure — including constraint violations raised by DuckDB — rolls the transaction back before propagating the error to the caller. You can match on the error to distinguish constraint violations from other failures:
use diesel::result::{DatabaseErrorKind, Error as DieselError};

let result = conn.transaction(|conn| -> QueryResult<()> {
    // Insert a row.
    diesel::insert_into(products::table)
        .values((products::id.eq(1), products::name.eq("first"), products::price.eq(9.00)))
        .execute(conn)?;
    // Insert again with the same primary key — violates the PK constraint.
    diesel::insert_into(products::table)
        .values((products::id.eq(1), products::name.eq("duplicate"), products::price.eq(9.00)))
        .execute(conn)?;
    Ok(())
});

match result {
    Err(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, info)) => {
        eprintln!("PK conflict rolled back: {}", info.message());
    }
    Err(e) => return Err(e),
    Ok(()) => {}
}
Because the whole transaction is rolled back on any error, the table is left in a consistent state regardless of where inside the closure the error occurred.

With Async Connections

Transactions must not span an .await point. A conn.transaction(|conn| { ... }) closure is synchronous — you cannot .await a future inside it, and you must not hold a transaction open across a thread-yield boundary. Use better_duck_core’s AsyncPool::with() to check out a connection and run the entire transactional block on a blocking thread via spawn_blocking. See the Async page for the full pattern.
If you are using the async facade from better-duck-core, run your transactional logic inside with() so that the blocking work executes entirely on a dedicated thread:
// Pseudocode — full example in the Async section.
async_pool.with(|conn| {
    conn.transaction(|conn| {
        diesel::insert_into(products::table)
            .values(/* ... */)
            .execute(conn)?;
        Ok(())
    })
}).await?;
The closure passed to with() is not async, which prevents accidentally awaiting inside a live transaction.

Build docs developers (and LLMs) love