Working with Transactions

library(ducklake)
library(dplyr)

# Setup for examples
# The ducklake extension only needs installing once per machine:
# install_ducklake()
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"
)
#> Transaction started.
#> Transaction committed.

# View the data
get_ducklake_table("cars") |>
  select(mpg, cyl, hp, wt) |>
  head()
#> # A query:  ?? x 4
#> # Database: DuckDB 1.5.1 [tgerke@Darwin 25.5.0:R 4.5.2//private/var/folders/b7/664jmq55319dcb7y4jdb39zr0000gq/T/RtmpBfCpem/ducklake/ducklake8fa654044b50.duckdb]
#>     mpg   cyl    hp    wt
#>   <dbl> <dbl> <dbl> <dbl>
#> 1  21       6   110  2.62
#> 2  21       6   110  2.88
#> 3  22.8     4    93  2.32
#> 4  21.4     6   110  3.22
#> 5  18.7     8   175  3.44
#> 6  18.1     6   105  3.46

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?

Basic Manual Transaction Workflow

# Start a transaction
begin_transaction()
#> Transaction started.

# Make changes
get_ducklake_table("cars") |>
  mutate(weight_kg = wt * 453.592) |>
  replace_table("cars")

# Commit the changes with metadata
commit_transaction(
  author = "Data Team",
  commit_message = "Add weight in kg"
)
#> Transaction committed.

# Verify changes
get_ducklake_table("cars") |>
  filter(cyl == 4) |>
  select(wt, weight_kg) |>
  head()
#> # A query:  ?? x 2
#> # Database: DuckDB 1.5.1 [tgerke@Darwin 25.5.0:R 4.5.2//private/var/folders/b7/664jmq55319dcb7y4jdb39zr0000gq/T/RtmpBfCpem/ducklake/ducklake8fa654044b50.duckdb]
#>      wt weight_kg
#>   <dbl>     <dbl>
#> 1  2.32     1052.
#> 2  3.19     1447.
#> 3  3.15     1429.
#> 4  2.2       998.
#> 5  1.62      733.
#> 6  1.84      832.

Manual Rollback

Sometimes you may want to inspect data before deciding whether to commit:

# Start a transaction
begin_transaction()
#> Transaction started.

# Make a test change
get_ducklake_table("cars") |>
  mutate(test_flag = TRUE) |>
  replace_table("cars")

# Check the result
test_result <- get_ducklake_table("cars") |>
  select(mpg, test_flag) |>
  head() |>
  collect()

print(test_result)
#> # A tibble: 6 × 2
#>     mpg test_flag
#>   <dbl> <lgl>    
#> 1  21   TRUE     
#> 2  21   TRUE     
#> 3  22.8 TRUE     
#> 4  21.4 TRUE     
#> 5  18.7 TRUE     
#> 6  18.1 TRUE

# Decide to rollback
rollback_transaction()
#> Transaction rolled back.

# Verify the change was NOT applied
"test_flag" %in% colnames(get_ducklake_table("cars"))
#> [1] FALSE

# View all versioned changes
list_table_snapshots("cars")
#>   snapshot_id       snapshot_time schema_version
#> 1           1 2026-08-27 14:26:01              1
#> 2           2 2026-08-27 14:26:01              2
#> 3           3 2026-08-27 14:26:01              3
#> 4           4 2026-08-27 14:26:01              4
#>                                                                                                       changes
#> 1                                                          tables_created, tables_inserted_into, main.cars, 1
#> 2                                       tables_created, tables_dropped, tables_inserted_into, main.cars, 1, 2
#> 3 tables_created, tables_dropped, tables_inserted_into, inlined_insert, main.cars, main.cars_summary, 2, 4, 3
#> 4                                       tables_created, tables_dropped, tables_inserted_into, main.cars, 4, 5
#>      author                           commit_message commit_extra_info
#> 1  Tutorial           Initial load of mtcars dataset              <NA>
#> 2 Data Team          Add kilometers per liter column              <NA>
#> 3 Data Team Add efficiency ratings and summary table              <NA>
#> 4 Data Team                         Add weight in kg              <NA>

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()
#> Transaction started.

get_ducklake_table("cars") |>
  mutate(hp_per_liter = hp / (cyl * 0.5)) |>
  replace_table("cars")

commit_transaction(
  author = "Performance Team",
  commit_message = "Add horsepower per liter metric",
  commit_extra_info = '{"ticket": "DATA-123"}'
)
#> Transaction committed.

get_ducklake_table("cars") |>
  select(hp, cyl, hp_per_liter) |>
  head()
#> # A query:  ?? x 3
#> # Database: DuckDB 1.5.1 [tgerke@Darwin 25.5.0:R 4.5.2//private/var/folders/b7/664jmq55319dcb7y4jdb39zr0000gq/T/RtmpBfCpem/ducklake/ducklake8fa654044b50.duckdb]
#>      hp   cyl hp_per_liter
#>   <dbl> <dbl>        <dbl>
#> 1   110     6         36.7
#> 2   110     6         36.7
#> 3    93     4         46.5
#> 4   110     6         36.7
#> 5   175     8         43.8
#> 6   105     6         35

After the fact: Use set_snapshot_metadata() to retroactively update the metadata on the most recent snapshot. This directly updates the ducklake_snapshot_changes metadata table.

# Retrospectively update metadata on the last snapshot
set_snapshot_metadata(
  ducklake_name = "transactions_lake",
  author = "Performance Team (reviewed)",
  commit_message = "Add horsepower per liter metric (approved)"
)
#> Snapshot metadata updated.

Both approaches result in the same metadata fields being populated — the choice is about workflow. Use at-commit-time metadata for automated pipelines where metadata is known upfront. Use after-the-fact metadata for interactive workflows where you want to annotate a snapshot after reviewing the results.

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)
#>   snapshot_id       snapshot_time                      author
#> 1           1 2026-08-27 14:26:01                    Tutorial
#> 2           2 2026-08-27 14:26:01                   Data Team
#> 3           3 2026-08-27 14:26:01                   Data Team
#> 4           4 2026-08-27 14:26:01                   Data Team
#> 5           5 2026-08-27 14:26:02 Performance Team (reviewed)
#>                               commit_message
#> 1             Initial load of mtcars dataset
#> 2            Add kilometers per liter column
#> 3   Add efficiency ratings and summary table
#> 4                           Add weight in kg
#> 5 Add horsepower per liter metric (approved)

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 retroactive 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
  3. Keep transactions focused: Group related changes, but avoid overly long transactions
  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

Transactions ensure data integrity and provide complete audit trails for all changes in your DuckLake.