library(ducklake)
library(dplyr)
# Setup for examples
attach_ducklake("transactions_lake", lake_path = vignette_temp_dir)Transactions are essential for maintaining data integrity when making multiple related changes to your data lake. DuckLake provides full ACID (Atomicity, Consistency, Isolation, Durability) transaction support, ensuring that either all operations succeed or none do.
DuckLake offers two approaches for working with transactions:
with_transaction() (Recommended): A
modern, R-idiomatic approach that automatically handles errors and
rollbacksbegin_transaction() /
commit_transaction() /
rollback_transaction(): Manual transaction control
for advanced use casesThis vignette demonstrates both approaches and explains when to use each one.
We’ll use the mtcars dataset throughout this vignette to
demonstrate transaction workflows.
The with_transaction() function provides automatic error
handling and cleanup, similar to the withr with_*() pattern
used throughout the R ecosystem. This is the recommended
approach for most use cases.
begin_transaction() and
commit_transaction()withr# Correct values in place, with automatic metadata tracking: the filter
# becomes the WHERE clause of a single UPDATE
with_transaction(
get_ducklake_table("cars") |>
filter(cyl == 4) |>
mutate(mpg = mpg * 1.05) |>
ducklake_exec(),
author = "Data Team",
commit_message = "Apply the revised 4-cylinder efficiency factor"
)
# Verify the change
get_ducklake_table("cars") |>
select(mpg, cyl) |>
head()You can group multiple operations together by wrapping them in curly braces:
# Multiple related changes in one atomic transaction
with_transaction({
# Add efficiency rating
get_ducklake_table("cars") |>
mutate(
efficiency = case_when(
mpg >= 25 ~ "high",
mpg >= 20 ~ "medium",
TRUE ~ "low"
)
) |>
replace_table("cars")
# Create a summary table
get_ducklake_table("cars") |>
group_by(cyl) |>
summarize(
avg_mpg = mean(mpg, na.rm = TRUE),
avg_hp = mean(hp, na.rm = TRUE),
count = n()
) |>
create_table("cars_summary")
}, author = "Data Team", commit_message = "Add efficiency ratings and summary table")
# View results
get_ducklake_table("cars") |>
select(mpg, cyl, efficiency) |>
head()
get_ducklake_table("cars_summary") |>
collect()One of the key benefits of with_transaction() is
automatic error handling:
# This transaction will fail and automatically rollback
tryCatch(
with_transaction({
# This will succeed
get_ducklake_table("cars") |>
mutate(test_column = "temporary") |>
replace_table("cars")
# This will fail
stop("Simulated error - something went wrong!")
}, author = "Data Team", commit_message = "This will be rolled back"),
error = function(e) {
message("Transaction automatically rolled back: ", e$message)
}
)
# Verify that test_column was NOT added (transaction was rolled back)
get_ducklake_table("cars") |>
colnames()
# View all versioned changes
list_table_snapshots("cars")For advanced use cases where you need explicit control over transaction boundaries, DuckLake provides manual transaction functions.
# Start a transaction
begin_transaction()
# Make changes: declare a column, then fill it in place
add_table_column("cars", "weight_kg", "DOUBLE")
get_ducklake_table("cars") |>
mutate(weight_kg = wt * 453.592) |>
ducklake_exec()
# Commit the changes with metadata
commit_transaction(
author = "Data Team",
commit_message = "Add weight in kg"
)
# Verify changes
get_ducklake_table("cars") |>
filter(cyl == 4) |>
select(wt, weight_kg) |>
head()Sometimes you may want to inspect data before deciding whether to commit:
# Start a transaction
begin_transaction()
# Make a test change
add_table_column("cars", "test_flag", "BOOLEAN", default = TRUE)
# Check the result
test_result <- get_ducklake_table("cars") |>
select(mpg, test_flag) |>
head() |>
collect()
test_result
# Decide to rollback
rollback_transaction()
# Verify the change was NOT applied
"test_flag" %in% colnames(get_ducklake_table("cars"))
# View all versioned changes
list_table_snapshots("cars")DuckLake supports two ways to attach metadata (author, commit message, and optional extra info) to a snapshot.
At commit time (recommended): Pass
author, commit_message, and/or
commit_extra_info to commit_transaction() or
with_transaction(). This uses the DuckLake v1.0
set_commit_message() API to record metadata as part of the
transaction itself, before the commit is finalized.
# Metadata set at commit time (preferred approach)
begin_transaction()
add_table_column("cars", "hp_per_liter", "DOUBLE")
get_ducklake_table("cars") |>
mutate(hp_per_liter = hp / (cyl * 0.5)) |>
ducklake_exec()
commit_transaction(
author = "Performance Team",
commit_message = "Add horsepower per liter metric",
commit_extra_info = '{"ticket": "DATA-123"}'
)
get_ducklake_table("cars") |>
select(hp, cyl, hp_per_liter) |>
head()Once per session: a session that commits as one
person can name the author once, with
options(ducklake.author = "Performance Team"). Every
commit_transaction(), with_transaction(), and
restore_table_version() call in the session then records
that author unless the call names another. The creation snapshot gets it
too, when attach_ducklake() creates a lake.
set_snapshot_metadata() does not read the option: the
person labeling a snapshot after the fact is not always the one who
committed it.
After the fact: set_snapshot_metadata()
fills in the metadata of a snapshot that was committed without any, such
as a quick interactive change. The latest snapshot is the target unless
snapshot_id names another, such as snapshot 0 of a lake
created before ducklake labeled it at attach time. It updates the
ducklake_snapshot_changes metadata table directly, outside
DuckLake’s transaction model, so by default it only fills empty fields
and refuses to replace a value that is already there:
# A quick interactive change, committed without metadata
begin_transaction()
rows_update(
get_ducklake_table("cars"),
data.frame(mpg = 21, hp_per_liter = 36.7),
by = "mpg"
)
commit_transaction()
# Fill in the metadata afterwards
set_snapshot_metadata(
ducklake_name = "transactions_lake",
author = "Performance Team",
commit_message = "Correct hp_per_liter for the 21 mpg cars"
)
# Replacing an existing value takes overwrite = TRUE, and leaves no trace
# of the previous value
try(
set_snapshot_metadata("transactions_lake", commit_message = "Reworded")
)Both approaches populate the same fields. Set metadata at commit time
whenever you can: it is recorded inside the transaction, by DuckLake
itself. Keep set_snapshot_metadata() for the occasional
snapshot that was committed without it. Where the snapshot history is
the audit trail, treat overwrite = TRUE as what it is: an
edit to that trail.
Regardless of which approach you use, all transactions are tracked with complete metadata:
When a lake has more than one writer (a PostgreSQL or SQLite catalog shared by several sessions, or a Quack server), two transactions can race to commit the next snapshot. DuckLake resolves the race itself: the transaction that lost checks whether its changes conflict with the winner’s, and when they do not, it is retried against the new snapshot. Two sessions appending to the same table, or editing different tables, both commit. Real conflicts surface as an error in the losing session, whose changes are rolled back: two transactions deleting from the same data file, an insert into a table the other transaction dropped or altered, or two creates of the same name. Re-run that transaction on the current data.
The retry policy is a per-session DuckDB setting: 10 attempts, 100 ms
apart, with the wait growing by 1.5 each time.
set_ducklake_retry() adjusts it for a busy catalog:
| Feature | with_transaction() |
Manual (begin/commit/rollback) |
|---|---|---|
| Ease of use | ✅ Simple, one function | ❌ Requires multiple function calls |
| Error handling | ✅ Automatic rollback | ❌ Must handle manually |
| Metadata | ✅ Inline with transaction | ✅ Inline via parameter, or filled in later via
set_snapshot_metadata() |
| Safety | ✅ Can’t forget to commit | ❌ Risk of open transactions |
| Use case | Most production workflows | Interactive/conditional workflows |
| Code clarity | ✅ Clear transaction scope | ⚠ Scope can be unclear |
with_transaction(): Use it
for all standard workflowsauthor
and commit_message for audit trails;
options(ducklake.author = ...) names the author once for a
whole sessiontryCatch() to ensure rollbackwith_transaction(expr, author, commit_message, commit_extra_info):
Modern, automatic transaction handling (recommended)begin_transaction(): Start a manual
transactioncommit_transaction(author, commit_message, commit_extra_info):
Apply changes from a manual transaction, with optional metadata set at
commit time via the DuckLake v1.0 APIrollback_transaction(): Discard
changes from a manual transactionset_snapshot_metadata(): Fill in
metadata a snapshot was committed without, the most recent one unless
snapshot_id says otherwise (overwrite = TRUE
to replace a value)Transactions ensure data integrity and provide complete audit trails for all changes in your DuckLake.