--- title: "ducklake Cookbook" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{ducklake Cookbook} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} # Evaluate chunks only where the ducklake DuckDB extension is already # installed. The probe never downloads anything, so building this vignette # needs no network access. ducklake_available <- ducklake::ducklake_extension_available() knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = ducklake_available ) # Use a unique temp directory for this vignette to avoid conflicts during R CMD check vignette_temp_dir <- file.path(tempdir(), "ducklake_vignette") dir.create(vignette_temp_dir, showWarnings = FALSE, recursive = TRUE) knitr::opts_knit$set(root.dir = vignette_temp_dir) ``` ```{r setup, message=FALSE} library(ducklake) library(dplyr) ``` ## Introduction This cookbook provides quick recipes for common ducklake operations. Each recipe is a self-contained example you can adapt for your workflow. For a comprehensive real-world example, see the [clinical trial data lake](clinical-trial-datalake.html) vignette. ## Setup recipes ### Create a new data lake ```{r create, message=FALSE} # Create a data lake in a specific directory attach_ducklake("my_lake", lake_path = vignette_temp_dir) ``` ### Attach to an existing data lake ```{r attach, eval=FALSE} # Attach to an existing lake (creates it if it doesn't exist) attach_ducklake("existing_lake", lake_path = "/path/to/data_lake") ``` ### Use an alternative catalog backend ```{r attach-postgres, eval=FALSE} # PostgreSQL catalog for multi-client access attach_ducklake( "shared_lake", backend = "postgres", catalog_connection_string = "dbname=ducklake_catalog host=localhost", lake_path = "/shared/lake/data/" ) # SQLite catalog for lightweight local multi-client setups attach_ducklake( "team_lake", backend = "sqlite", catalog_connection_string = "metadata.sqlite", lake_path = "data_files/" ) ``` ### Detach from a data lake ```{r detach, eval=FALSE} # Detach when done (doesn't delete the lake) detach_ducklake("my_lake") ``` ## Loading data recipes ### Load data from a data.frame ```{r load_df} with_transaction( create_table(mtcars, "cars"), author = "Data Engineer", commit_message = "Initial car data load" ) ``` ### Update an existing table ```{r update_cars} # Create a second version of the cars table with_transaction( get_ducklake_table("cars") |> mutate(kpl = mpg * 0.425144) |> # Add km/L conversion replace_table("cars"), author = "Data Engineer", commit_message = "Add km/L metric to cars table" ) ``` ### Load data from a CSV file ```{r load_csv} # First write a sample CSV (in practice, you'd have an existing file) csv_path <- file.path(vignette_temp_dir, "sample_data.csv") write.csv(head(iris, 20), csv_path, row.names = FALSE) # Load the CSV into the data lake with_transaction( create_table(csv_path, "iris_sample"), author = "Data Engineer", commit_message = "Load iris sample from CSV" ) ``` ### Load data from a URL ```{r load_url, eval=FALSE} # ducklake can load data directly from URLs with_transaction( create_table("https://example.com/data.csv", "remote_data"), author = "Data Engineer", commit_message = "Load remote dataset" ) ``` ### Register existing Parquet files without copying If your data is already in Parquet, `add_data_files()` records the files in the lake in place -- no copy, no rewrite, and no collection into R. A vector of files is registered atomically in one snapshot. This is the fast migration path from a folder of Parquet extracts. The target table can already exist with a compatible schema, or `create = TRUE` can create it directly from the Parquet schema. Note that the lake takes ownership of the files: later compaction may rewrite or delete them. ```{r add_data_files, eval=FALSE} add_data_files( "readings", c("extracts/jan.parquet", "extracts/feb.parquet"), create = TRUE ) # See which files back a table list_ducklake_files("readings") ``` ### Load with a dplyr pipeline ```{r load_pipeline} with_transaction( mtcars |> filter(mpg > 20) |> create_table("efficient_cars"), author = "Data Analyst", commit_message = "Load filtered car data" ) ``` ### List all tables in the lake ```{r list_all_tables} # Every table and view, with its schema and type list_ducklake_tables() ``` ## Shared logic and documentation recipes ### Store a pipeline as a view A view stores a query, not data: reads always run against the current tables, and every client of the lake -- R, Python, or plain SQL -- sees the same definition. ```{r create_view} get_ducklake_table("cars") |> filter(mpg > 25) |> create_view("v_efficient_cars") get_ducklake_table("v_efficient_cars") |> collect() ``` Drop it when the logic is no longer needed: ```{r drop_view} drop_view("v_efficient_cars") ``` For logic a view cannot hold -- a parameterized SQL macro, say -- DuckDB SQL is the escape hatch: `DBI::dbExecute(get_ducklake_connection(), "CREATE MACRO ...")`. ### Document tables and columns Comments live in the lake's catalog, so the documentation travels with the data instead of in a sidecar file: ```{r comments} set_table_comment("cars", "Motor Trend road tests of 1973-74 models") set_column_comments( "cars", mpg = "Miles per US gallon", wt = "Weight (1000 lbs)" ) get_table_comments("cars") ``` ### Keep variable labels through the lake If your data carries haven/labelled-style variable labels, they survive the lake: `create_table()` stores `label` attributes as column comments, and `collect()` puts them back, so label-aware tools like gtsummary and gt behave as if the data never left R. ```{r labels} df_visits <- data.frame(subject = c("S1", "S2"), sbp = c(128, 141)) attr(df_visits$subject, "label") <- "Subject identifier" attr(df_visits$sbp, "label") <- "Systolic blood pressure (mmHg)" create_table(df_visits, "visits") collected <- get_ducklake_table("visits") |> collect() attr(collected$sbp, "label") ``` ## Reading data recipes ### Read a table ```{r read_table} # Returns a lazy dplyr tbl cars_data <- get_ducklake_table("cars") # Use dplyr verbs cars_data |> filter(cyl == 6) |> select(mpg, cyl, hp) |> head(3) ``` ### Collect data into memory ```{r collect} # Fetch all data into a data.frame cars_df <- get_ducklake_table("cars") |> collect() head(cars_df, 3) ``` ### View all versions of a table ```{r view_versions} # See all snapshots for the cars table list_table_snapshots("cars") ``` ### Read a specific version ```{r read_version} # Query data as it existed at snapshot 1 -- before the kpl column was added get_ducklake_table_version("cars", version = 1) |> select(mpg, cyl, hp) |> head(3) ``` ### Read data at a specific timestamp ```{r read_timestamp, eval=FALSE} # Query data as of a specific time (see list_table_snapshots() for times) get_ducklake_table_asof("cars", timestamp = "2024-01-15 10:30:00") |> collect() ``` ## Updating data recipes ### Replace entire table ```{r replace_table} with_transaction( get_ducklake_table("cars") |> mutate(hp_per_cyl = hp / as.numeric(cyl)) |> # Add derived metric replace_table("cars"), author = "Data Engineer", commit_message = "Add horsepower per cylinder metric" ) ``` Note: Use `replace_table()` for structural changes (adding or removing columns) and the row-level operations (`rows_update()`, `rows_insert()`, `rows_delete()`) for targeted, incremental changes. Both are fully versioned -- every committed change creates a snapshot you can time-travel back to. See `vignette("modifying-tables")` for guidance on choosing between them. ## Metadata and versioning recipes ### View all snapshots ```{r list_snapshots} list_table_snapshots() ``` ### View snapshots for a specific table ```{r list_table_snapshots, eval=FALSE} list_table_snapshots("cars") ``` ### Restore a table to a previous version ```{r restore} # Roll cars back to snapshot 1. The restore is recorded as a new snapshot, # so nothing is lost -- you can still time-travel to any version. restore_table_version( "cars", version = 1, author = "Data Engineer" ) list_table_snapshots("cars") ``` ## Transaction recipes ### Simple transaction ```{r simple_transaction, eval=FALSE} with_transaction( create_table(my_data, "my_table"), author = "Your Name", commit_message = "What changed and why" ) ``` ### Multi-step transaction ```{r multi_step, eval=FALSE} with_transaction({ # All these operations happen atomically create_table(raw_data, "raw_table") cleaned <- get_ducklake_table("raw_table") |> filter(!is.na(key_field)) |> create_table("clean_table") get_ducklake_table("clean_table") |> mutate(derived_field = calculate_something(x)) |> create_table("analysis_table") }, author = "Data Engineer", commit_message = "Full ETL pipeline run" ) ``` ### Manual transaction control ```{r manual_transaction, eval=FALSE} # For fine-grained control begin_transaction() create_table(data1, "table1") create_table(data2, "table2") # Commit or rollback commit_transaction( author = "Your Name", commit_message = "Manual transaction commit" ) # Or if something went wrong: # rollback_transaction() ``` ## Query optimization recipes ### Preview query without execution To see the SQL a *read* pipeline will run, use dplyr's `show_query()`: ```{r show_query} get_ducklake_table("cars") |> filter(mpg > 25) |> select(mpg, cyl, hp) |> show_query() ``` To preview the SQL an in-place *modification* would run (before committing to it with `ducklake_exec()`), use `show_ducklake_query()`: ```{r show_ducklake_query} get_ducklake_table("cars") |> mutate(mpg = round(mpg)) |> show_ducklake_query() ``` ### Filter early for performance ```{r filter_early} # Good: Filter before other operations get_ducklake_table("cars") |> filter(cyl == 6) |> mutate(kpl = mpg * 0.425144) |> head(3) ``` ### Use specific columns ```{r select_columns} # Good: Select only needed columns get_ducklake_table("cars") |> select(mpg, cyl, hp) |> filter(mpg > 25) ``` ### Sort or partition large tables for file pruning For big tables, declaring a sort order or partition keys lets DuckLake skip whole Parquet files when a query filters on those columns: ```{r sorting_partitioning, eval=FALSE} # Sorting suits high-cardinality columns like timestamps or ids set_table_sorting("events", "event_time") # Partitioning suits low-cardinality columns like year or region set_table_partitioning("sales", c("year(order_date)", "region")) ``` ### Tune lake options `set_ducklake_option()` adjusts DuckLake's persisted settings at lake, schema, or table scope, and `get_ducklake_options()` shows what's set: ```{r options, eval=FALSE} # Trade write speed for smaller files set_ducklake_option("parquet_compression", "zstd") # Require a commit message on every snapshot -- useful for audit discipline set_ducklake_option("require_commit_message", TRUE) get_ducklake_options() ``` ## Cleanup ```{r cleanup} # Detach from the lake detach_ducklake("my_lake") ``` ## See also - [Modifying Tables](modifying-tables.html) - Detailed guide to table modification approaches - [Transactions](transactions.html) - Advanced transaction patterns - [Time Travel](time-travel.html) - Comprehensive time travel guide - [Clinical Trial Data Lake](clinical-trial-datalake.html) - Complete real-world workflow