The lake store
By default Dataflare keeps events in D1, which is exact, real-time and free-plan friendly. D1 databases have a size limit, though, so for high volumes Dataflare can stream events into Apache Iceberg tables in R2 and query them with Basin SQL (Cloudflare's DataFusion engine, formerly R2 SQL):
npx dataflare-cli init --lake
Status: stable on Dataflare's side (0.8). Every read API is checked against DataFusion in CI and gives the same answers as D1; every query kind runs against production Basin SQL at 30 million events in 1–7 seconds; erasure, retention and schema changes work end to end. Cloudflare Pipelines is still in beta, so watch its changelog for changes. Please report anything odd.
Lake mode can't be combined with --eu: Basin Catalog doesn't support R2 buckets in a jurisdiction yet, so init refuses the combination. Use the D1 store if your data must stay in the EU jurisdiction.
What it creates
- An R2 bucket
<name>-datawith the data catalog enabled - Two Pipelines streams,
<name>_eventsand<name>_identities(the Worker name with-as_), with fixed schemas (packages/core/src/lake.ts). Stream, sink and pipeline names are unique per account, so each deployment gets its own. - Two sinks writing Iceberg tables
glass.eventsandglass.identities(Parquet, files rolled every 60 seconds) - Two pipelines connecting them (
INSERT INTO <sink> SELECT * FROM <stream>) - Worker bindings
EVENTS_STREAMandIDENTITIES_STREAM, and the API token as theCF_API_TOKENsecret so the Worker can query Basin SQL
D1 still holds projects, people, identity links, flags, experiments and the replay index.
Differences from D1 mode
| D1 | Lake | |
|---|---|---|
| Freshness | Immediate | About 1–2 minutes (file roll interval) |
| Query time | Milliseconds | About 1–5 seconds per query |
EU jurisdiction (--eu) | Yes | Not yet (Basin Catalog limitation) |
| History | Until D1's size limit | Unbounded |
| Cost | Included in Workers plans up to limits | R2 storage, Pipelines and Basin SQL scans |
| Person properties | At event time, or current via SQL join | At event time |
| Deleting a person's events | Immediately | Within a day (queued; the daily maintenance job deletes them) |
Retention (--retention-days) | Worker cron, daily | Maintenance job, daily |
| Other engines | Any Iceberg reader (DuckDB, Spark, Snowflake) can read the same tables |
Erasure and retention
Basin SQL is read-only, so the Worker can't delete lake rows. Instead:
dataflare person <id> --deletedeletes the person, their device links and replays at once, and queues their ids in D1 (lake_deletions).dataflare lake maintainreads the queue and deletes those people's events and identity links with PyIceberg through Basin Catalog, then deletes events older than--retention-daysif set. The rows disappear from queries at once.- The deploy workflow (
.github/workflows/glass.yml) runs it daily at 03:17 UTC. Run it by hand from a laptop withCLOUDFLARE_API_TOKENset; it needs uv. - Deletes rewrite data files and leave the old ones in earlier snapshots.
init --laketurns on Basin Catalog's snapshot expiration (7 days) and compaction, so erased rows leave R2 within about a week.
Each person is removed by device, person and user id in separate passes: PyIceberg keeps a row when a delete filter evaluates to NULL, so one OR across columns would miss anonymous rows (whose user_id is NULL).
Using a narrower token
The Worker only needs to read in lake mode. After init, you can replace its token with one that has just Workers R2 SQL › Read, Workers R2 Data Catalog › Read and Workers R2 Storage Bucket Item › Read (Cloudflare's docs list R2 storage access as well; keep it read-only, since vended credentials inherit it):
npx wrangler secret put CF_API_TOKEN --config .glass/wrangler.jsonc
Schema changes
Custom properties never need a schema change: they live in the props JSON column. A new built-in column does, and Cloudflare constrains how: a Pipelines stream's schema can't change, and a sink can't write to a table it didn't create. So a schema change makes new tables:
LAKE_SCHEMA_CHANGESinpackages/core/src/lake.tslists the columns each version adds;LAKE_VERSIONfollows it.dataflare upgrade(from CI) creates the new version's streams, sinks and tables (<name>_events_v2,glass.events_v2, and the same for identities) and points the Worker at them.- Old tables keep their rows. Queries read every version through a
UNION ALL, with NULL for columns an older version lacks (LAKE_VERSIONSon the Worker lists them). Your own SQL can keep sayingglass.events: the Worker expands it to every version. - Erasure and retention cover every version's table.
- The old pipeline is left in place; with nothing writing to its stream it costs nothing, and you can delete it in the dashboard.