Working with Transactions

library(ducklake)
library(dplyr)

# Setup for examples
attach_ducklake("transactions_lake", lake_path = vignette_temp_dir)

Introduction

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:

  1. with_transaction() (Recommended): A modern, R-idiomatic approach that automatically handles errors and rollbacks
  2. begin_transaction() / commit_transaction() / rollback_transaction(): Manual transaction control for advanced use cases

This vignette demonstrates both approaches and explains when to use each one.

Setup: Loading Initial Data

We’ll use the mtcars dataset throughout this vignette to demonstrate transaction workflows.

# Load initial data
with_transaction(
  create_table(mtcars, "cars"),
  author = "Tutorial",
  commit_message = "Initial load of mtcars dataset"
)

# View the data
get_ducklake_table("cars") |>
  select(mpg, cyl, hp, wt) |>
  head()

Approach 2: Manual Transaction Control

For advanced use cases where you need explicit control over transaction boundaries, DuckLake provides manual transaction functions.

When to use manual transactions?

  • Interactive workflows: When you want to inspect data between operations before committing
  • Conditional commits: When commit logic depends on runtime conditions
  • Long-running transactions: When you need fine-grained control over transaction lifecycle
  • Legacy code: When migrating from other transaction systems

Basic Manual Transaction Workflow

# 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()

Manual Rollback

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")

Snapshot Metadata: At Commit Time vs After the Fact

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.

Viewing Transaction History

Regardless of which approach you use, all transactions are tracked with complete metadata:

# View recent transaction history
list_table_snapshots("cars") |>
  select(snapshot_id, snapshot_time, author, commit_message) |>
  tail(5)

Working with several writers

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:

set_ducklake_retry(max_retries = 20, wait_ms = 250, backoff = 2)

Comparison: with_transaction() vs Manual Control

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

Best Practices

  1. Default to with_transaction(): Use it for all standard workflows
  2. Always add metadata: Include author and commit_message for audit trails; options(ducklake.author = ...) names the author once for a whole session
  3. Keep transactions focused: Group related changes, but avoid overly long transactions; when other sessions write to the same lake, a long-open transaction is the one most likely to conflict
  4. Handle errors gracefully: When using manual transactions, always use tryCatch() to ensure rollback
  5. Test rollback behavior: Verify that your error handling works correctly

Key Concepts Summary

  • with_transaction(expr, author, commit_message, commit_extra_info): Modern, automatic transaction handling (recommended)
  • begin_transaction(): Start a manual transaction
  • commit_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 API
  • rollback_transaction(): Discard changes from a manual transaction
  • set_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.