rubber duck in sunglasses driving a DeLorean with fire trails

Quack to the Future

Building traceable data lakehouses with DuckLake in R, on a laptop


DestinationPOSIT::CONF 2026 · HOUSTON TX
PresenterTRAVIS GERKE · PCCTC
Last time departedFINAL_v2_FINAL.CSV · 1985

Unversioned data fades

~/sales-report/analysis.R  ·  git: last commit Mar 14
df <- read_excel( "data/2026-03-14/export_v2_FINAL.xlsx" ) sum(df$revenue) #> [1] 48123004633150
~/sales-report/data
2025-12-01/ 2026-01-15/ 2026-03-14/ export.csv Mar 14 export_v2.csv Mar 14 export_v2_FINAL.xlsx Mar 14Jun 2 ← edited in place

Marty's fading family photograph

“Why did this number change?” You have no idea. Somewhere in the past, the timeline skewed.

We solved this once, for code

For code, versioning is a reflex:

  • every change is a commit: author, message, diff
  • any past state is one checkout away
  • “who changed this, and why?” is git blame

For data, we do our best:

  • dated folders, _v2 suffixes
  • careful names, good intentions
  • but no author or diff, no way back

Trip I: build the time machine

DestinationA VERSIONED LAKE
Present timelibrary(ducklake)
Last time departedFLAT FILES · 1985

Two lines of R to a lake where every write is an authored commit.

The lakehouse, in one slide

  • Your tables live as open files, almost always Parquet (cheap, columnar)
  • A catalog tracks which files make up each table, at each version
  • Writes are transactions: atomic, versioned, never in a half-state
  • This is what Iceberg and Delta Lake do for the Spark world

The catch: built for Spark clusters, run by platform teams.

You don’t need 1.21 gigawatts for this.

What a DuckLake is

A lakehouse that fits in a folder:

  1. Parquet files hold the rows
  2. A plain SQL database is the catalog: every table, column, and change

DuckDB, the analytical database that already lives inside your R session, does the work. {ducklake} puts dplyr on top.

your entire lakehouse

data/lake/
├── quack_lake.ducklake  # catalog
├── bronze/vehicles/*.parquet
├── silver/vehicles/*.parquet
└── gold/efficiency_by_cyl/*.parquet

Attach a lake

library(ducklake)

attach_ducklake("quack_lake", lake_path = "data/lake", author = "doc")


One call opens a lake, and creates it first when nothing is at the path yet.

That’s a working data lake in a folder. Every write from here is transactional and versioned.

Doc Brown

The plan: a medallion pipeline

Bronze
raw, as received
nothing edited, ever
bronze.vehicles
Silver
cleaned & conformed
types, names, dedup
silver.vehicles
Gold
analysis-ready
business logic applied
gold.efficiency_by_cyl

Each layer is a schema: a named group of tables inside the lake. On disk, it’s a folder.

Keep raw data sacred, refine in stages, analyze from gold. Each hop is one versioned, documented transaction.

Create the layers

with_transaction({
  create_schema("bronze")
  create_schema("silver")
  create_schema("gold")
}, author = "marty", commit_message = "Create medallion layers")
Created schema "bronze".
Created schema "silver".
Created schema "gold".
Committed snapshot 1 (marty): Create medallion layers

Everything inside with_transaction() lands as one snapshot. If any step fails, nothing lands.

Marty McFly

Load the raw layer

with_transaction(
  create_table(mtcars, "bronze.vehicles"),
  author = "marty", commit_message = "Load raw vehicle data"
)
Committed snapshot 2 (marty): Load raw vehicle data

Every commit carries an author and a message, and the confirmation names the snapshot.

Marty McFly

Derive silver and gold

silver <- get_ducklake_table("bronze.vehicles") |>
  select(model, mpg, cyl, wt) |>
  mutate(cyl = as.integer(cyl))

with_transaction(create_table(silver, "silver.vehicles"),
  author = "doc", commit_message = "Clean and standardize vehicles")
Committed snapshot 3 (doc): Clean and standardize vehicles
gold <- get_ducklake_table("silver.vehicles") |>
  group_by(cyl) |>
  summarise(avg_mpg = mean(mpg, na.rm = TRUE), n_models = n())

with_transaction(create_table(gold, "gold.efficiency_by_cyl"),
  author = "doc", commit_message = "Gold: efficiency by cylinder count")
Committed snapshot 4 (doc): Gold: efficiency by cylinder count

get_ducklake_table() returns a lazy table. create_table() runs the query inside DuckDB and writes the result into the lake: the rows never pass through R.

Doc Brown

Your data has a git log now

list_table_snapshots() |>
  select(snapshot_id, author, commit_message)
  snapshot_id author                     commit_message
1           0    doc                        Create lake
2           1  marty            Create medallion layers
3           2  marty              Load raw vehicle data
4           3    doc     Clean and standardize vehicles
5           4    doc Gold: efficiency by cylinder count


Six months from now, “why did this change?” has an answer you can query.

Trip II: drive the time machine

The lake remembers everything: query the past, see exactly what changed, undo mistakes.

Biff gets write access

butthead_cars <- get_ducklake_table("silver.vehicles") |>
  filter(mpg >= 20) |>         # 20+ mpg? butthead cars
  select(model)

with_transaction(
  rows_delete(get_ducklake_table("silver.vehicles"), butthead_cars, by = "model"),
  author = "biff", commit_message = "small tweak"
)
Committed snapshot 5 (biff): small tweak
get_ducklake_table("silver.vehicles") |> tally() |> collect()
# A tibble: 1 × 1
      n
  <dbl>
1    18

32 rows → 18 rows, labeled “small tweak.”

Biff Tannen

“What happened?” is now a query

get_table_changes("silver.vehicles", start = 5, end = 5) |>   # snapshot 5: Biff's commit
  select(change_type, model, mpg) |>
  collect() |>
  print(n = 10)
# A tibble: 14 × 3
   change_type model            mpg
   <chr>       <chr>          <dbl>
 1 delete      Mazda RX4       21  
 2 delete      Mazda RX4 Wag   21  
 3 delete      Datsun 710      22.8
 4 delete      Hornet 4 Drive  21.4
 5 delete      Merc 240D       24.4
 6 delete      Merc 230        22.8
 7 delete      Fiat 128        32.4
 8 delete      Honda Civic     30.4
 9 delete      Toyota Corolla  33.9
10 delete      Toyota Corona   21.5
# ℹ 4 more rows

All 14 deleted rows, with the values they had before Biff touched them.

The change feed, point and click

get_table_changes("silver.vehicles") |> view_table_changes()

the change viewer with Biff's snapshot selected: author biff, message small tweak, 14 deleted rows

Click a snapshot: who, when, why, and every row it touched.

Fixing the timeline

get_ducklake_table_version("silver.vehicles", version = 3) |> tally() |> collect()
# A tibble: 1 × 1
      n
  <dbl>
1    32
restore_table_version("silver.vehicles", version = 3, author = "doc")
Committed snapshot 6 (doc): Restored silver.vehicles to snapshot 3
list_table_snapshots("silver.vehicles") |> select(snapshot_id, author, commit_message)
  snapshot_id author                         commit_message
1           3    doc         Clean and standardize vehicles
2           5   biff                            small tweak
3           6    doc Restored silver.vehicles to snapshot 3

Snapshot 3 never went anywhere. Restore rolls forward as a new snapshot: Biff’s mistake stays in the log.

You fix the timeline without erasing anyone from the photo.

Doc Brown

Trip III: trace the flux capacitor



{dplyneage}: column-level lineage for dplyr pipelines

Every column in a result, traced back to the source columns it came from.

The recipe is the lineage

library(dplyneage)

lake_lineage <- extract_lineage(list(
  "silver.vehicles"        = silver,
  "gold.efficiency_by_cyl" = gold
))

lineage_flow(lake_lineage, theme = "dark", height = "560px")

{dplyneage} walks the query tree dbplyr already built.

The recipe is the lineage

Before you change the past…

lineage_downstream(lake_lineage, "bronze.vehicles.mpg")           # what would I break?
[1] "gold.efficiency_by_cyl.avg_mpg" "silver.vehicles.mpg"           
lineage_upstream(lake_lineage, "gold.efficiency_by_cyl.avg_mpg")  # where did this come from?
[1] "bronze.vehicles.mpg" "silver.vehicles.mpg"

Impact analysis as a function call: know what a change touches before you make it.

Where we’re going

The whole paper trail, assembled

question answer
ducklake hex sticker {ducklake} when / who / why did tables change? every write is an authored, versioned commit
dplyneage hex sticker {dplyneage} where did each column come from? exact lineage from your dplyr code