library(ducklake)
library(dplyr)
attach_ducklake("checks_lake", lake_path = vignette_temp_dir)
df_cars <- data.frame(model = rownames(mtcars), mtcars, row.names = NULL)
with_transaction(
create_table(df_cars, "cars"),
author = "Data Engineer",
commit_message = "Add the Motor Trend car data"
)DuckLake enforces one rule about the values in a table: a column can
be NOT NULL. It has no check constraints, no primary or
unique keys, and no foreign keys, and its catalog accepts no custom tags
from SQL, so a rule like “cyl is 4, 6, or 8” has no slot of
its own. The catalog does hold views. A view that returns the rows
breaking a rule is a check: the rule’s logic sits in the catalog, it is
versioned with the data it describes, and any client of the lake runs it
by reading the view.
This article writes checks that way with create_check()
and runs them with run_checks(). Then it uses what a lake
adds: a load that rolls back when a check fails, the outcome of the
checks recorded on a commit, and the rules and the data read together as
of an earlier snapshot. It closes with how this sits next to affirm and
data-dict. Both functions are experimental while the convention
settles.
set_column_not_null() sets the one constraint DuckLake
has. From then on the lake itself refuses a row without the value,
whichever client writes it. Plain SQL stands in for another client
here:
Every other rule is a view. The checks get a schema of their own (a
schema is the lake’s namespace for tables and views), which keeps them
apart from the data and tells run_checks() where to look.
create_check() takes a rule the way you would say it, as
the condition every row should meet, and stores the view of the rows
where it is false, with the rule’s label as the view’s comment:
with_transaction({
get_ducklake_table("main.cars") |>
create_check(
"cyl_known", cyl %in% c(4, 6, 8),
label = "cyl is 4, 6, or 8",
listing = c(model, cyl)
)
get_ducklake_table("main.cars") |>
create_check(
"hp_plausible", hp > 0 & hp < 500,
label = "hp is between 0 and 500",
listing = c(model, hp)
)
}, author = "Data Manager", commit_message = "Add the first checks on cars")There is nothing more to a check than that view. The first call above
stores what this pipeline would, creating the checks schema
on the way:
get_ducklake_table("main.cars") |>
filter(!(cyl %in% c(4, 6, 8))) |>
select(model, cyl) |>
create_view("checks.cyl_known")
set_table_comment("checks.cyl_known", "cyl is 4, 6, or 8")Some rules are easier to state as their failure, and those are written just like that. No model should appear twice, and the rows that break the rule are the models counted more than once:
with_transaction({
get_ducklake_table("main.cars") |>
count(model) |>
filter(n > 1) |>
create_view("checks.model_unique")
set_table_comment("checks.model_unique", "model appears once")
}, author = "Data Manager", commit_message = "Check that models are unique")A rule that spans tables is written the same way, as a join: an
anti_join() of visits against subjects returns the visits
with no subject.
A few habits keep a check dependable:
create_check() find the rows where it is false. A rule that
forbids something stays readable (hp != 0, where the
pipeline by hand would negate a negation), and a compound rule is not
negated by hand, where & and | are easy to
swap. When the failure is the natural thing to describe, write it as a
pipeline into create_view(). The two forms select the same
rows, because three-valued logic treats NOT (hp != 0) and
hp = 0 alike.NA passes, as it does under a
SQL CHECK constraint, so a missing cyl passes
cyl_known. When a missing value matters, say so in the
rule, give it a check of its own, or make the column
NOT NULL."main.cars". DuckDB resolves an unqualified name from the
view’s schema and the session’s current database, so "cars"
can fail to bind for a client where another database is current.run_checks() counts the
rows of every view it finds there, and a view that summarizes (a row of
totals) would report a failure every time.run_checks() counts the rows each check returns:
The label comes from the view’s comment and n_fail is
the number of rows breaking the rule. The car data passes all three.
A new batch arrives, and one of its rows has a typing error:
df_batch <- data.frame(
model = c("Saab 99", "Audi 100 LS"),
mpg = c(24.5, 23.0),
cyl = c(4, 40),
hp = c(87, 91)
)Inside a transaction the checks see the rows that are waiting to be
committed. So a load can run the checks on itself and stop, and
with_transaction() rolls everything back when it does:
try(
with_transaction({
rows_insert(get_ducklake_table("cars"), df_batch, by = "model")
df_checks <- run_checks()
if (any(df_checks$n_fail > 0)) {
stop("failed ", toString(df_checks$check[df_checks$n_fail > 0]))
}
}, author = "Data Manager", commit_message = "Load the September batch")
)Nothing of the batch reached the lake. The table has its 32 rows and the history has no new snapshot:
get_ducklake_table("cars") |> count() |> collect()
list_table_snapshots("cars") |>
select(snapshot_id, author, commit_message)This is how a rule becomes a constraint in a lake. DuckLake would accept the row; a transaction that checks itself never commits it. The gate covers the writers that use it, and a client that writes without running the checks is not stopped.
Rejecting the batch is not always right. Raw data often has to land
as it arrived, with the problems flagged for follow-up. A manual
transaction can run the checks after the write and put the outcome on
the commit, in commit_extra_info:
begin_transaction()
rows_insert(get_ducklake_table("cars"), df_batch, by = "model")
df_failed <- run_checks() |>
filter(n_fail > 0) |>
select(check, n_fail)
commit_transaction(
author = "Data Manager",
commit_message = "Load the September batch",
commit_extra_info = as.character(
jsonlite::toJSON(list(checks_failed = df_failed))
)
)
v_loaded <- max(list_table_snapshots("cars")$snapshot_id)The snapshot now says what state the data was in when it was committed:
The check is a view, so the failing rows are one read away, with the columns the check selected:
The correction is an ordinary update, and its commit message can name the check it answers. Afterwards the lake’s history holds the load, the finding, and the fix:
A check is a catalog object, so changing a rule is a snapshot with an
author and a message, like a change to the data. Calling
create_check() again under the same name replaces the rule
and its label together:
with_transaction(
get_ducklake_table("main.cars") |>
create_check(
"hp_plausible", hp >= 40 & hp <= 400,
label = "hp is between 40 and 400",
listing = c(model, hp)
),
author = "Data Manager", commit_message = "Narrow the plausible hp range"
)
list_table_snapshots("checks.hp_plausible") |>
select(snapshot_id, author, commit_message)A check written with create_view() keeps its label when
the view is replaced, so only a change of wording needs
set_table_comment() again.
A lake attached at a snapshot shows its views as they were then,
along with its tables. Re-attaching at the snapshot of the September
load runs the rules of that day against the data of that day. The
uncorrected row fails again, and the hp rule reads as it
did then:
detach_ducklake("checks_lake")
attach_ducklake(
"checks_lake",
lake_path = vignette_temp_dir,
snapshot_version = v_loaded
)
run_checks()That answers a question an audit asks: which rules were in force when this data was committed, and did the data pass them?
Two other tools take a rule in the same form. affirm, from PCCTC, runs checks in R on data frames and turns the failures into a report. data-dict, from the tidyverse team, keeps the dictionary of a dataset and its rules in a YAML file and validates the data against it. The rule from the top of this article, in each:
# data-dict.yaml
columns:
- name: cyl
constraints:
- assert: cyl IN (4, 6, 8)
description: cyl is 4, 6, or 8# ducklake
get_ducklake_table("main.cars") |>
create_check("cyl_known", cyl %in% c(4, 6, 8), label = "cyl is 4, 6, or 8")All three ask for the rule as it is said and work out the failing
rows themselves. A missing value passes in data-dict and in a lake
check, the way it does under a SQL CHECK constraint. They
differ in where the rule lives and in what runs it:
| affirm | data-dict | ducklake checks | |
|---|---|---|---|
| The rule lives | in an R script | in a YAML file beside the data, easy to diff in git | in the lake’s catalog, in the same snapshots as the data |
| What runs it | R, on a data frame in memory | its own engine, on Parquet files | DuckDB, on the lake’s tables, from any client |
| What a rule can say | any R expression | an expression about one table’s rows, plus declared keys, required columns, allowed values, and ranges | anything a query can return, joins and aggregates included |
| What comes back | a gt or Excel report of the failing rows | an HTML report, and the dictionary rendered as a site | counts from run_checks(), and the failing rows as a
view |
The data-dict column describes its 0.0.3 preview of August 2026, and the project is moving quickly.
The three answer different questions, so they combine. data-dict
describes a whole dataset (types, units, what the values of an enum
mean, a glossary) in a file that people and agents can read without a
database, and its draft command writes most of that file
from the data. A lake’s checks are narrower and sit closer to the data.
They run where the data is, inside the transaction that loads it if need
be, and a change to a rule is a snapshot with an author, like a change
to the data. For now data-dict reads Parquet files, and a lake table is
more than its Parquet data files, since deletes are recorded separately
and small inserts live in the catalog, so a table has to be exported
before data-dict can validate it. What a lake holds of a dictionary
today is the comments and labels of
vignette("views-comments-labels").
run_checks() counts and nothing more, and a report is a
job for the tools built for one. The rows collected from a check view
are a data frame, ready for gt or for a report from affirm. pointblank
interrogates lazy tables, so one of its agents can also run on
get_ducklake_table("main.cars") inside DuckDB.