Changelog
Changes users can see, newest first. The format follows Keep a Changelog. The crates, the CLI, the Python package, the WASM reader and the TypeScript client share one version, which follows Semantic Versioning as Cargo applies it: before 1.0, a minor release (0.3 → 0.4) may break things, and its entry says how; a patch release never does. Each version is a tag on GitHub, so its code and benchmarks can be checked out.
Unreleased
Added
-
The website has a light theme. It follows the system's setting, and a button in the header switches between light and dark.
-
ASOF [LEFT] JOIN r ON l.key = r.key AND l.time >= r.time(DuckDB's form) andASOF JOIN r MATCH_CONDITION (l.time >= r.time) ON l.key = r.key(Snowflake's): each left row with the right row nearest in time. A day of 8.6 M IIoT readings matched with machine states: 0.23 s, against 78 s as a range join. -
Aggregates
ohlc(time, price), a group's open, high, low and close;lttb(time, value, n), a series downsampled tonpoints for a chart (Largest-Triangle-Three-Buckets); andhistogram(x, low, high, bins), counts per bin as numpy's. -
Aggregates
quantile(x, q), exact with pandas' linear interpolation (DataFusion'spercentile_contdrifts in the sixth digit), andapprox_quantile(x, q), a name for the t-digestapprox_percentile_cont. -
Aggregates
flatline_count(time, value, threshold)andflatline_seconds(…): how often and how long a series kept exactly the same value for longer than a threshold (a stuck sensor). A day of 700 IIoT tags: 0.13 s, against 4.4 s written with window functions. -
Window functions
diff(x),percent_change(x)(pct_change),rate(time, x),rolling_mean(x, n),rolling_std(x, n)andzscore(x), as pandas defines them;rolling_meanover 60 rows is about 4× faster than a framedavg. Aggregatesskewness(skew) andkurtosis(kurt), and the pandas namesstdandcov. -
Aggregates
first(time, value)andlast(time, value), a group's value at its earliest and latest time, andvalue_at(time, value, at)(aliasasof), the latest value at or before a time. Rollups answerfirstandlast. -
Rollup routing follows filters on grouping columns that come from another table, such as
tag IN (SELECT tag FROM tags WHERE line = 'L0002'): the rollup's rows are kept the same way. IIoT daily statistics for one line's tags from an hourly rollup: 551 → 37 ms. -
The design moved from one file,
SPEC.md, to numbered specs inspec/(in the spirit of Rust's RFCs and Python's PEPs): one spec per feature with the same template, its status (done, in progress, planned, dropped), the releases that changed it, and its history, and an index for people and agents. Milestones made of specs (spec/milestones/, v0 among them) are the roadmap, and background pages hold the purpose, architecture, license and releases. The website shows them at /spec and /roadmap, generated from the same files. Open questions and tasks moved to GitHub issues (#104–#143), linked from the specs. -
Aggregates
integral(time, value [, 'forward_fill' | 'linear'])(the area under the signal, in value × seconds),counter_diff(time, value)(how much a counter grew; a drop is a reset to zero) andcounter_rate(time, value)(that growth per second), exact over irregular readings liketime_weighted_mean, and about as fast (hourly values of 1,000 tags over 30 days: 2.2 s,avg2.0 s). Rollups answer them from a storedtime_weighted_mean, or store them. -
The signal aggregates take a condition as the value, 1 while true:
integral(time, pressure > 8.5)is the seconds above 8.5,time_weighted_mean(time, state = 'running')the share of time running. IIoTtime-above-limitwritten this way: 334 → 191 ms, same answers. -
Gap filling: the table function
time_buckets(width, start, end [, origin]), every bucket of a range to join readings against, and the window functionsforward_fill(ffill),backward_fill(bfill) andinterpolate(x [, t]).forward_fillover 10 M rows takes 0.26 s;last_value(x) IGNORE NULLS3.6 s. -
Aggregates
gap_count(time, threshold)andgap_seconds(time, threshold): how many times a series' readings stopped for longer thanthreshold(an interval, such asINTERVAL '5 minutes'), and how long those gaps lasted in all, exact over each group's first to last reading. The gaps of 100 one-second tags: a day 226 → 77 ms, 30 days 17.8 → 1.4 s against the same query written withlead(). -
Rollups answer queries over a time range without a
time_bucket, grouped by some of their columns (each tag's average over the last day): the rollup's buckets inside the range, the raw rows at its edges, merged. Averages need the rollup'ssumandcountof the column. With an hourly rollup on the industrial benchmark, 30 days of statistics per tag take 55 ms instead of 1.75 s, of time-weighted means 0.15 s instead of 1.5 s. -
Rollups store
gap_countandgap_seconds(as their state for the threshold) and answer both from it; 30 days of gaps per tag from an hourly rollup: 44 ms instead of 1.34 s. -
Setting
window-series-order(auto,true,false): a window over each series in time order (lead(…) OVER (PARTITION BY <series key> ORDER BY time)) reads the files merged in series order, so they are neither redistributed nor sorted.autodoes so when the files are in one time partition: IIoTgaps260 → 215 ms,time-above-limit396 → 327 ms. Over a week of files merging is slower (2.0 → 3.0 s), soautoleaves it. -
Setting
shared-signal-state(on by default): signal aggregates of the same column in one query compute their runs of readings once. All four, hourly for 1,000 tags over 30 days: 3.5 → 2.1 s. -
TypeScript client:
table.info()has the table'singestTimeColumnandretention(90d), as the CLI and Python give them. -
timelake create --schematakes every Iceberg primitive type by name, such aslong,decimal(10,2),timestamptz_nsanduuid, besides the SQL spellings it took (bigint,text,bool).
Fixed
- A table with the property
read-dedup = falsewas still answered from its rollups, which count each reading once, so itsGROUP BY time_bucket(…)queries could differ from reading the table. Only a session'sSET timelake.read_dedup = falsestopped routing; the table's property now does too. SET timelake.rollup_routing = truedidn't route a table whoserollup-routingproperty is off. The session's value now overrides the table's, as for every other setting.- Snapshot expiry from the TypeScript writer retried at once and without end while other writers kept committing first. It waits between attempts and gives up after 10, as commits do.
- Snapshot expiry in the engine stopped with an error when a manifest only expired snapshots used was already gone (an earlier expiry was interrupted). It skips it now, as the TypeScript writer did; a missing manifest of a snapshot it keeps still stops it.
- The TypeScript client and
TableWriterlet every manifest entry without a sequence number take its manifest's, where Iceberg gives it only to files the manifest's snapshot added. Such an entry from another writer now counts as the oldest data, as in the engine, so of two rows with the same key and time the newer write wins in both. - Two appends at once through one TypeScript
TableWriter, or one PythonTablefrom two threads, could leave the handle on the older of the two versions when they finished out of order. A handle now only moves forward. - The TypeScript client's
lateston a table without a last-value index could return an older write of a reading rewritten at the same time: it read files newest first, and the last one read won. Files are now planned oldest data first, so the newer write wins, as in the engine. - The TypeScript client failed to return rows or latest values from files with different columns (written before a
column was added, or by another writer), or with a column at a narrower type (
intbefore it becamelong). Rows are now matched by column name, with nulls where a file lacks a column. - The TypeScript client ignored a series filter or time column the table doesn't have (a typo such as
tags_Id) and returned every row; such a query is now an error naming the column. A file written before a filtered column was added has no rows matching the filter, rather than all of them. - A time column whose name isn't a valid Avro name, such as
event-time, gave the table a partition field (event-time_day) that manifests can't hold: reading them without the head failed, and the TypeScript client couldn't commit at all. New tables name it as Iceberg's Avro encoding does (event_x2Dtime_day), and the TypeScript client writes any partition name, with the field id the partition spec gives it. Existing tables with such a column need recreating. - On a table with delete files (row-level deletes another engine such as Spark or Trino wrote), the TypeScript client's reads skipped them, so deleted rows came back, and its commits wrote their manifests back as data manifests. Both are now an error naming the delete file.
- Snapshot expiry removed branches and tags that another engine had made on old snapshots, and deleted the files only they used. A snapshot a branch or tag points at is now never expired.
- A
max-snapshotsormin-snapshotsnear 2^64 overflowed the check for inline snapshot expiry after an append, and atarget-file-size-bytesnear 2^63 the compaction plan: a crash in debug builds, a wrong decision in release ones. - Data files recorded the bounds of a decimal column wider than 18 digits as 16 padded bytes; the Iceberg spec asks for the fewest bytes that hold the value, as Timelake already wrote narrower decimals.
- A
write.parquet.compression-levelthat isn't a level of the codec (high, or a negative level forgziporbrotli) was ignored, and files were written at the default level. Setting one is now an error, and writing to a table that has one fails, naming the property. - A setting given twice in one call under its two spellings (
timelake.read-dedupandtimelake.read_dedup) kept either value, at random. It is now an error. timelake.row-group-rows=0was accepted and then crashed every write;timelake.target-file-size-bytesandtimelake.data-page-rowstook 0 too. All three must now be positive.- The WebAssembly reader compared the row-group statistics of a decimal column, stored unscaled (1.5 as 15), with a
query's values, so a filter on
1.5skipped the rows holding it. Statistics of decimals, unsigned integers and other types it can't compare are no longer used to skip row groups. - The WebAssembly reader read the file bounds of a
timestamp_nscolumn in a table's head as microseconds, so a time range skipped files that held matching rows. It scales them as it does a manifest's. - Compaction from the TypeScript client dropped the table's last-value index: later appends didn't rebuild it, so
latestqueries read every file from then on. Compaction and retention now keep it, as the engine's do. - Appending nanosecond timestamps to a microsecond column (or a
date64with a time of day to a date column) truncated them, so two distinct times could become one row. Converting to a coarser unit is now checked like other conversions: a value it would change is an error. A supplied_ingest_tsthat doesn't parse is an error too; it was replaced with the time of the append. - The CLI prints a failed query's error once: DataFusion's errors repeat their cause, and each was printed again.
time_weighted_meanover a span of several files per scan partition, such as a month of daily files grouped by tag, failed with "runs that overlap in time": each partition now reads a stretch of time, kept to its files. Files that need merging are merged with the files of the time they span, not with the whole table.- The TypeScript client's
TableWritertrims trailing slashes from the warehouse in linear time; the regular expression it used took quadratic time on a string with many slashes. - The TypeScript client's
TableWriterwritesbigintvalues beyond 2^53 exactly; it rounded them to the nearest float. WhereJSON.rawJSONis missing (runtimes before ES2025), such a value is an error. - Settings given at creation by their short names (
--prop retention=90d,properties={"read-dedup": "false"}) were stored under that name and ignored. They are now read asalterreads them, and an unknown setting is an error. timelake infoshowed a retention shorter than a day as0d. It shows the duration as set (12h), and the JSON'sretention_daysis nowretention, the same string.- Python:
Lake(storage_options=…, settings=…),create_table(properties=…)andset_propertiestake any mapping, as the type stubs say; they raisedTypeErrorfor anything but adict. - The WebAssembly module grew every vector and string by copying it into a new block: its memory-counting allocator
left out
realloc, so the system allocator's growth in place went unused. - The WebAssembly reader (the TypeScript client) treated every time column as microseconds: on a
timestamp_nscolumn, or a Parquet file with millisecond times, time ranges, buckets and pruning gave wrong results without an error. - A failed write from the TypeScript client (or its compaction) left the multipart upload it had started open, and the files it had already finished in the table's directory; S3 and R2 keep and bill such parts. It now aborts the upload and deletes the files.
- A commit whose metadata write landed but reported a failure (a retried request that got "already exists", or a timeout) was taken as lost or failed, and its cleanup deleted the manifests (and for an append, the data files) the committed version uses, leaving the table pointing at missing files. The writer now reads the version back: its own metadata there is the commit. When that can't be told, the commit fails with "commit outcome unknown" and deletes nothing. Timelake also no longer commits metadata it couldn't load itself.
- Snapshot expiry treated a kept snapshot whose manifest list was missing as using no files, and deleted every file it shared with the expired snapshots. It now reads what the kept snapshots use before changing anything, and stops with an error that names the broken snapshot.
- Benchmarks: ingest request counts and costs cover only the writes; they included the checkpoint queries and the
live probes, which now go through an uncounted proxy listener (
-listen-uncounted). Ingest seconds no longer include deleting the previous run's tables, and every S3 run sets and records the proxy's latency and throughput cap instead of keeping the last run's. - Benchmarks: concurrent dashboard clients start together once all have opened their readers, so queries per second counts clients that overlap. A/B reader settings apply to probes and dashboard clients too, not only the measured queries.
- Benchmarks: ClickHouse's answer to TSBS IoT
avg-daily-driving-sessionis right; the query relied onleadgiving NULL at a series' edge, which ClickHouse doesn't. QuestDB commits reuse one client instead of connecting each time. S3 Tables maintenance that never compacted is an error, not ok. Lance's maintenance on the local S3 target goes through the counting proxy like every other system's (BENCH_LANCE_DIRECT, which sent it around the proxy, is gone). - With read deduplication off,
ORDER BYthe series key and time could return the rows of a file another engine wrote unsorted in the file's order. Such files are now sorted. - A
column IN (…)list of more than 20 values withNaNamong them could skip the parts of a file holdingNaNrows. Such lists no longer prune. time_buckets,gap_count,gap_secondsand the flatline aggregates overflowed (a panic in debug builds, wrong buckets otherwise) on intervals or timestamps beyond the microsecond range, such asINTERVAL '200000000 days'. They are now an error.- With
SET timelake.rollup_routing = false, planning a query still loaded the table's rollups, a few requests each, and so did everyCREATE MATERIALIZED ROLLUP. They no longer do. CREATE EXTERNAL TABLE IF NOT EXISTSnaming an existing lake table failed instead of doing nothing, and the DataFusion context'stable_existwas false for tables until aninformation_schemaquery had listed the lake. It now also knows the tables the process has created or read.- A latest-row query written
WHERE code = 1 AND rn = 1(another column compared with 1 beforern) read every row instead of the last-value index. - With
SET timelake.filter_pushdown = false, a newest-first query (ORDER BY time DESC LIMIT k, or the newestkbuckets) with a filter that file statistics can't rule out returned too few rows, often none: it stopped after the newest partition's unfiltered rows. Each partition's rows are now filtered before they count. - A failed append or compaction could leave multipart uploads open on object storage, billed until a lifecycle rule removes them: the upload of the file being written, those of the other partitions' files, and uploads cut off when another one failed. They are now aborted, or run to their end and deleted. (A part that fails only once the file is finished can still leave its upload open: object_store gives no way to abort it.)
- A stray file in the disk cache's directory (such as macOS's
.DS_Store), or an object directory removed while a sweep ran, stopped every sweep, so the disk cache grew past its budget. Sweeps now pass over such entries. - A read cut short (a
LIMIT, a timeout) could leave the block cache's GETs half done: the cache, and the store under it, then stayed in memory for good, and a later read resumed the stale request. Each fetch now runs to its end on its own, and another read can't miss both the fetch and its blocks. - Through the block cache, a missing object was a generic error, so snapshot expiry failed instead of passing over a
manifest list another process had already deleted. It is
NotFoundagain. - Changing settings,
rollbackand snapshot expiry retried without limit or pause when another writer kept publishing first (or a store kept reporting the version as taken). They now back off and give up with a conflict error after 10 attempts, as commits do. - A table with
head-format = nonesent a DELETE of its head after every commit. Each process now deletes it once. - The disk cache keyed S3 objects by the endpoint only when it was given in a few option names, not when it came from
AWS_ENDPOINT_URL/AWS_ENDPOINT_URL_S3or asendpoint_url: two endpoints with the same bucket name could then share cached blocks. The endpoint now comes from the same place as the store's own. - The engine (and so the CLI and Python) read the delete files of a table another engine had written row-level deletes to as data, so deleted rows came back, and its commits and compaction would carry them on. Reads and writes of such a table are now an error naming the delete file, as in the TypeScript client.
timelake importandappendfailed on a file whose extension isn't lower case (data.CSV). Any case works.timelake sqlprinted nothing for an empty result with--format csvand++as a table; both now show the result's columns. A statement without columns (SET) prints nothing.- The CLI's error messages could leave out a cause whose text appeared inside an earlier message. Only a cause the message before it ends with (DataFusion's repeats) is left out.
timelake sql --set read_dedup=falsefailed: the name needed itstimelake.prefix. A name without a prefix is now a Timelake setting, as in Python.timelake info --jsongivesformat_versionas a number (2, as the TypeScript client'sinfo()does), not"V2", and the text output shows snapshot times in UTC, markedZ.timelake maintain --helpdescribes--no-compact,--no-retentionand--no-expire.--chunkand--retentionoftimelake createandimportare checked when the arguments are parsed, with the usage message, before any file is read.- Python:
repr(Lake(...))quotes the location as Python'sreprdoes (Lake('…')), not with Rust's double quotes. - TypeScript client: a table handle many commits behind reads the newest version. It moved forward at most two versions per query and took that for current; it now probes forward (doubling, then halving the step) until a version is missing.
- TypeScript client: a handle behind the metadata files maintenance deleted reads the newest version. It kept answering from its old version.
- TypeScript client: public S3 and CloudFront buckets that answer
403for a missing key work. Every query aftercacheMsfailed on the check for a newer version. - TypeScript client: a query whose request fails frees the Parquet files it already opened.
- TypeScript client:
lake.tables()checks thattimelake-tables.jsonis a list of names. - TypeScript client:
zodSourcenames a table such asstatusStatus, notStatu. scripts/serve-range.mjsanswers a malformed path with 400 instead of crashing, ends a response whose file can't be read, serves the whole file for a suffix range longer than it (bytes=-500of 100 bytes), and ignoresbytes=-. The website's lab route reads suffix ranges too.- The website's benchmark pages show sizes in decimal units throughout: a megabyte is 1,000,000 bytes, as the labels say. The per-system storage column divided by 1,024, so it showed the same bytes as about 5% fewer MB than elsewhere.
- The website shows a time just under a second as "1.00 s" instead of "1000 ms", and likewise at the other unit boundaries.
- The website draws a system it has no color for in gray, listed after the known ones, instead of in S3 Tables' color and first.
- On the website's benchmark pages, closing a query's results on a narrow screen and then widening the window no longer leaves the results pane empty, and a ranking whose values are all zero draws its bars instead of failing.
- Resizing the website's window redraws the requests chart's dots instead of stretching them.
- Benchmarks: maintenance request counts and costs cover only the maintenance itself; they included reading the tables' state and sizes afterwards (all metadata for Iceberg, a listing for Timelake, manifests for S3 Tables). Saving the state for the live probes is no longer counted as ingest either.
- Benchmarks: ingest seconds leave out reading the workload's data from its files (the initial rows and each
arrival). With
--pace, the next arrival is read while waiting for its time, and there is no wait after the last one. - Benchmarks: a
--reuserun starts each database server and records its versions, and skips a query at later checkpoints once it timed out, as a full run does. - Benchmarks:
timebench reportshows only the costs the run priced; it showed S3 Standard storage prices for database servers, S3 Tables and the reference. - Benchmarks: each TSBS IoT arrival holds the same time window of readings and diagnostics; they were paired by position, so a window missing from one table shifted the rest.
- Benchmarks: concurrent dashboards' queries per second count the queries that finished within the measured seconds, not the last one each client started before the end.
- Benchmarks: answer checks sum
Date64columns as the Timelake CLI does, plain Parquet files are split into days correctly for any timestamp unit, and QuestDB maintenance closes the connection ingest left open. - Benchmarks: the S3 proxy in front of a local object store counts a PUT with
Expect: 100-continueonce and adds its latency once; the interim100 Continueresponse made it count the request twice. - Benchmarks: a system missing a setting (such as
BENCH_TIMESCALEDB_DSNor S3 Tables'BENCH_TABLE_BUCKET_ARN) fails its own ingest and the run goes on; it stopped the whole profile and left the run file without its end. A database server is stopped even when reading its version fails.
Changed
- Maintenance in the engine merges several groups of files at once (up to 8, reading at most 24 files together, the most one merge reads), instead of one partition after another. Rollup refreshes load their rollups, and queries look up the rollups they may route to, at once rather than one by one.
- Maintenance in the engine (
timelake maintain, Python'smaintain(), Rust) merges at most 24 files at once, as the TypeScript writer does, so a merge's memory stays bounded. A partition with more files to merge is merged in parts, the report'smore_to_compactsays so, and the next run finishes it (timelake upgraderuns until it is done). A table now compacts to the same files whichever writer runs maintenance. - Maintenance reports have the same fields everywhere. Rust's
MaintainReportgainsbytes_rewrittenandmore_to_compact; Python's dict gains those andfiles_dropped; the TypeScriptMaintainReportgainsrollupsRefreshed,rollupRows(always 0 there: only the engine refreshes rollups) andseconds.partitions_compactedcounts each partition once, not each merged group.timelake maintainalso prints the files retention dropped, the megabytes compaction read, and when to run again. - Rust:
MaintainOptionshas amax_bytesfield in the engine too, the bytes one compaction run reads at most, andMaintainStepsbuilds options from the steps to run.MaintainOptions,MaintainReportandRollupRefresharetimelake_format's, re-exported fromtimelakeas before. - Snapshot expiry lists only the table's metadata files (
metadata/v…) to find old versions to delete, not the whole metadata directory with every manifest. - Appends convert a column to the table's type only where no value is lost. A value that doesn't fit (an integer out of range, text that doesn't parse, a fraction for an integer column, an integer above 2^53 for a double) is an error naming the column; it used to be written as NULL or truncated without a word. Conversions that are never exact, such as a boolean or a timestamp to an integer, are an error too. The architecture page lists what converts.
- The website's Spec section opens on the first spec, with every spec in the sidebar (and in the menu on phones), instead of a list page. Spec pages are wider, and table cells break long words only when they don't fit.
- The website's roadmap is a board with a column per status and a small card per milestone; a milestone's page has no
sidebar. The benchmarks page lists the benchmarks in a sidebar and shows each one's query times after maintenance,
with the rest on its own page. The requests chart is drawn complete instead of played. The home page no longer has
a benchmarks section. Query cost is shown for one query (
$0.000021) instead of for a million. The header's icons are evenly spaced, and with a mouse or trackpad the page no longer bounces past its ends, which dragged the header along. Scrolling is smoother: nothing reacts to the cursor while the page scrolls (hover effects, chart tooltips and legends, the hero), the use-case pictures read their colors once instead of every frame, and the header is solid instead of blurred. - Setting
newest-firstalso covers the newest buckets of aGROUP BY time_bucket(…)sorted by the bucket with a limit: older time partitions are skipped once they can't hold a row of those buckets. TSBS DevOpsgroupby-orderby-limit: 254 → 115 ms. - Appends to tables with many series extend the last-value index by merging it with the append's newest rows instead of sorting both: a 100,000-row append to a table of 1,000,000 series takes 171 ms instead of 264 ms.
- When several rollups could answer a query, the widest is used. A rollup answers time-weighted averages, integrals
and counters only where it holds 32 readings or more per row,
m4and gaps at 4: daily time-weighted means from a 1-minute rollup of one-second readings took 3.6 s, against 2.0 s from the readings, and now read the readings. - Breaking: default names of rollup columns use
_where they had:(integral_linear_value, notintegral:linear_value). Existing rollups keep their names. - Float columns are written with a dictionary, falling back to byte-stream-split once it outgrows its page, instead
of byte-stream-split alone (setting
float-encoding). IIoT readings take a third of the space (5 days: 366 → 119 MB) and TSBS DevOps scans run up to twice as fast (high-cpu-all274 → 136 ms, 38% more space); random doubles cost 5% more space and 6% more scan time. Files already written keep their encoding until compaction rewrites them. - Breaking: setting
time-weighted-meanis nowseries-order(SQLtimelake.series_order), since it applies to all four aggregates over a signal. The state a rollup stores fortime_weighted_meangained fields: a rollup withtime_weighted_meancreated by 0.7.0 is no longer used to answer queries, and its refresh fails until it is dropped and created again. - Planning a newest-first query (
ORDER BY time DESC LIMIT k) reads the file footers of all time partitions at once instead of one partition after another: one round trip instead of one per partition, when the footers aren't in the table's head. - TypeScript client: durations use the engine's syntax, a whole number and
s,m,h,d,wory, or spelled out (90 minutes,1 day).500msand fractions such as1.5hare now errors; pass a number of milliseconds instead (500). - TypeScript client: time strings must be ISO 8601, and one without an offset (
2026-01-03T12:00:00) is UTC, as in the engine's SQL. It was local time, so the same query returned different rows in a browser and on a server. Strings keep microseconds. - TypeScript client:
table.lastStatscounts the requests that loaded or checked the table version for the query (the head, the version hint, metadata and manifests), andbytesis what was received, whole files included when a server ignoresRange. - Rust API:
timelake::align_batchandtimelake::latest_per_seriesaretimelake-format's and return itsWriteError, which?turns into atimelake::Error.TableNameistimelake-format's too (still re-exported), so a bad table name is anError::Format, not anError::Invalid. - A table property outside its setting's values (
read-dedup-strategy,filter-pushdown,series-order,window-series-order,float-encoding), which only another engine could have written, makes opening the table an error naming the property, as a bad boolean or duration already did.series-orderused to fall back toauto,float-encodingtodictionary, and the others failed queries. - Table creation and
timelake alter(Table.set_propertiesin Python and Rust) reject an unknownwrite.parquet.compression-codec, or awrite.parquet.compression-levelthe codec doesn't have, instead of letting the next write fail. Set by another engine, such a value still fails writes only; the error is now aValueErrorin Python, where it was aRuntimeError. - Rust API: the contract's settings with fixed values are enums in
timelake_format::value(ReadDedupStrategy,AutoBool,SeriesOrder,FloatEncoding,CompressionCodec),Contract::compressionholds the checked codec and level,Contract::head_formatisNonefor no head, andSetting::kindis asettings::Kind. - Rust API:
timelake_format::rollupholds a rollup's aggregates astimelake.rollup-aggregatesstores them, each function aRollupFunction(the stored JSON is unchanged), and the settings registry checks that property against it. - Rust API:
Table::filesreturns the live files as a sharedArc<Vec<LiveEntry>>instead of a copy. - Rust API:
RollupStatement's names are DataFusionTableReferences, parsed once, so a quoted name keeps its dots. - Rust API:
timelake_format::IcebergTypenames a column's Iceberg type:bounds::decodetakes one andmetadata::Column::iceberg_typeis one (Nestedfor structs, lists and maps).NewSnapshot::operationis anOperation, which moved totimelake_format::metadata(still re-exported fromcommit). - Rust API:
timelake_format::merge::Merge::newtakes aMergeOptions(inputs,unique,batch_rows) instead of three positional numbers, andiceberg::write_manifest_filewrites a manifest and returns its manifest-list entry, replacingmanifest_file. - Rust API:
timelake_format::FormatErroris an enum (FormatError::Invalidholds a message; Arrow and JSON errors of the head keep their cause), andWriteErrorreports its cause throughsource(), with Avro errors asWriteError::Avro. An append'smaintenanceerror is theWriteErroritself, not its message. Arrow errors are no longer prefixed withhead:. - TypeScript client:
TableWritertakes a table name without a namespace (readingsisdefault.readings), like the reader and the engine, and both check names with the engine's rules, soiot/readingsora.b.cis an error. - The WebAssembly reader (TypeScript client) decodes a file a batch at a time and, for a query with a
limit, keeps only the rows that can still be in the result, so it uses memory for aboutlimitrows rather than every matching row. The newest 10 of 1 Mi rows: 4 MB at most instead of 55 MB, which matters in a Worker's 128 MB. - TypeScript client: without
cacheMs, a table handle reuses a loaded version for the table'sversion-check-interval(1 s unless the table sets it), as the engine does, instead of 5 s;cacheMsstill overrides it.table.info()gives the setting asversionCheckIntervalMs. - Python:
Table.info()returns every field oftimelake info --json, typed asTableInfo: the schema, retention, partitions, and the newest 50snapshots, newest first.snapshotswas a count before; the count is nowsnapshot_count.timelake info --jsongainstimelake_format_versionandcurrent_snapshot. The Rust API hasTable::info().
Removed
- The website's Explorer (
/explore, with/api/sqland/api/series): the browser for tables, latest values, series charts and SQL is developed in its own project.
0.7.0 – 2026-10-18
Added
- Aggregate
time_weighted_mean(time, value [, 'forward_fill' | 'linear']): the mean of irregular readings weighted by how long each held, over each group's first to last reading; exact, as fast asavg(hourly means of 1,000 tags over 30 days: 2.0 s). Rollups store its state and answer wider buckets. Settingtime-weighted-mean(auto,segments,merge) for tables with dedup off whose files overlap. - Aggregate
m4(time, value): a group's first, smallest, largest and last points, as a list of{time, value}. Rollups store it and answer wider buckets from it (hourly points of 1,000 tags over 30 days: 2.0 → 0.51 s from a 1-minute rollup). After Apache IoTDB's M4. - Rollups of rollups: a table's rollups are refreshed narrowest first, and each is computed from a narrower one where that is up to date (a daily rollup from the minute rollup: 5.2 → 1.8 s on 30 days of IIoT readings).
- Rollups answer coarser queries: a query whose
time_bucketis a multiple of the rollup's width (daily from an hourly rollup), or that groups by fewer of its columns, is answered by aggregating the rollup's rows again. IIoT, 30 days, a 1-minute rollup: daily statistics per tag 2.1 → 0.25 s, 6-hourly totals 1.1 → 0.11 s. - Setting
newest-first(on by default):ORDER BY time DESC LIMIT kreads time partitions newest first and stops once older ones can't hold any of the k newest rows (#72). IIoT, 30 days: the 100 newest readings before a past time 298 → 38 ms; TSBS IoT on local S3: the 10 newest readings 392 → 320 ms. - Setting
in-list-pruning(on by default):column IN (…)filters of more than 20 values skip row groups and pages whose range holds none of the values, which DataFusion's pruning leaves alone (#103). IIoT, compacted, local disk: a 100-tag list 54 → 31 ms, a 21-tag list 53 → 26 ms. On object storage it also prunes with a join's runtime filter: IIoTenergyon local S3 (20 ms per request) 54 → 26 ms repeated, 204 → 183 ms first run. - Setting
filter-pushdown(auto,true,false): the Timelake reader applies filters while decoding Parquet and decodes the other columns only for rows that pass.auto, the default, turns it on for local files (TSBS geomeans 4–5% faster, selective group-bys up to 40%) and off on object storage, where it adds a round trip per row group. - Data files use the codec in Iceberg's
write.parquet.compression-codecandwrite.parquet.compression-level(ZSTD 3 by default;lz4,snappy,gzip,brotli,uncompressed). Before, Timelake always wrote ZSTD 3.
Changed
- Breaking: Timelake's scans no longer follow DataFusion's
datafusion.execution.parquet.pushdown_filters; settimelake.filter_pushdowninstead (see Added). - Breaking (Rust): in
timelake-format,rows::PartitionRowsholds a partition's rows as a list of batches ((Option<i32>, Vec<RecordBatch>)), andContracthas new fields for the new settings. Callers ofsort_and_splititerate the batches; code that builds aContractfield by field usesContract::from_properties. - Tables created by other engines with a codec other than ZSTD in
write.parquet.compression-codecare now written with that codec (see Added); tables Timelake created already say ZSTD. - Compaction's merge (also used for appends past 1 GiB) is up to 5× faster on files that alternate series by series, such as 5-minute batches of every device: 10 M rows of two such inputs merge in 2.6 s instead of 13.9 s.
- The block cache sends GETs of at most 1 MiB (storage option
timelake_cache_request_bytes;0for no limit), longer runs split into parts fetched at once. With 20 ms per request and 50 MiB/s per request, TSBS IoT first queries 126 → 115 ms (geomean), with 47% more requests.
Fixed
- An append whose string or binary column held more than 2 GiB failed with "offset overflow" (one hour of a 400,000-tag plant): the rows were concatenated into one batch to sort. Appends now sort groups of up to 1 GiB and merge them.
- The block cache fetched the runs of blocks one read needs one after another; it fetches them at once. First queries on object storage with 20 ms per request: TSBS IoT geomean 162 → 125 ms, one truck's history 233 → 109 ms.
- Tigris is recognised when its endpoint comes from
AWS_ENDPOINT_URL_S3(as Fly.io and the Tigris CLI set it), so readers check for new versions with a LIST there, about 245 ms faster per check than a HEAD (#102).
0.6.0 – 2026-10-17
Added
- Writes from JavaScript.
TableWriterin@timelake/clientappends to tables from Cloudflare Workers, Node, Deno, Bun, or browsers, through aStore(r2Storefor an R2 binding,memoryStore, or your own). The WebAssembly module runs the engine's write path: the same row preparation, Parquet files, commits with retries, last-value index and head. Rows are objects or an Arrow IPC stream;batchIdmakes retries safe. Appends run inline maintenance like the engine's (size-tier and cold-partition compaction by streaming merge, withinmaxCompactBytes, default 64 MiB; retention; snapshot expiry), andwriter.maintain()runs a full pass. Tables are created with the CLI or Python (spec 13). In Cloudflare Workers, import from@timelake/client/workersand pass the module from@timelake/client/timelake.wasmtoinitWasm. Measured in workerd: an append of 100,000 rows takes 0.7 s and 37 MiB, compaction of a day of 24 files 2.7 s and 95 MiB (bench/results/WORKERS.md). data-page-rows: rows per Parquet data page in the files a table gets (default 20,000; 8,192 for files written from JavaScript, where compaction holds a page per column of each file it merges).- Version check interval. The setting
version-check-interval(table or session) says how long the Timelake reader uses a table version it loaded or checked before checking for a newer one. Within it, queries send no request for the table's version, and can miss commits by other processes; the process's own commits are seen at once, and writers always check. The default, 1 second, is the old fixed behaviour;0schecks on every query (SET timelake.version_check_interval = '30s',--set,Lake(settings=…), ortimelake alter --set). - Ingestion guides. The Quick start has eight sub-pages that set up
continuous ingestion, each with several designs, a recommendation, scheduled checks on recent data, and
maintenance: Local, AWS (S3, Kinesis, SQS, IoT Core, and Firehose into Lambda), Azure (Blob Storage, Event Hubs,
Service Bus, and IoT Hub into Azure Functions), Google Cloud (Cloud Storage and Pub/Sub into Cloud Run functions),
Cloudflare (R2 and Queues, appended by a consumer Worker with
TableWriteror by a writer script), Fly.io, Vercel, and Tigris. Each platform has an overview, a setup page, a page per design, and a checks page; the docs sidebar opens and closes nested pages. The Quick start compares the platforms and designs. The AWS page compares S3 with S3 Tables.
Changed
- Compaction streams. Compaction merges its input files as sorted streams instead of loading a partition into memory, so its memory no longer grows with the partition. Full compaction of TSBS IoT (72 hourly files into 3 days): 3.65 → 2.37 s, peak memory 1.95 → 0.53 GB, with identical files.
- Columns are encoded in parallel. Timelake writes Parquet with its own writer (shared with the WebAssembly writer), encoding each row group's columns on threads of their own. The files are the same, byte for byte. The compaction above takes 1.17 s instead of 2.37 s; importing 11.7M rows 4.7 s instead of 6.0 s.
Fixed
- Docs: the storage page's check for conditional writes created a table twice, which fails the second time on any
store. It now sends a put-if-absent twice with the AWS CLI. The quick start no longer says that Timelake reads AWS
profiles: it reads the
AWS_*environment variables (aws configure export-credentialsexports a profile's). The storage page says to create a Tigris lake bucket as single-region or multi-region: on Tigris's defaultglobalbuckets, conditional writes are checked within one region only. @timelake/clientdidn't run in Cloudflare Workers: Workers can't compile WebAssembly from bytes at runtime, which the client did on first use. The@timelake/client/workersentry takes the compiled module instead.
0.5.0 – 2026-10-17
Added
- Dedup on read. In tables with dedup on, the Timelake reader (CLI, Python, Rust) returns only the latest write of
each
(series, time), before compaction has removed the older copies. Only files that may share a key are merged; the rest are read as before. DuckDB, other Iceberg readers and the TypeScript client still see the copies until compaction. It can be turned off per table (timelake.read-dedup = false) or per session (SET timelake.read_dedup = false, or--set timelake.read_dedup=falseon the CLI). A second strategy,timelake.read-dedup-strategy = keys-first, lists the older copies first and leaves them out of a normal scan. - Cold partitions compact sooner. Once newer data is
timelake.compact-cold-after(default 1 hour) past a partition's end, inline maintenance rewrites the partition into sorted, duplicate-free files when it has two small files, even if the append didn't touch it. - Insertion time. Time-series tables get an
_ingest_tscolumn: each append fills it with the time the append started, unless the input brings its own values (an import keeping arrival times). Among rows with the same(series, time)the latest insertion wins, then the later commit, everywhere: within an append, on read, in compaction and in the last-value index. DuckDB can pick the same rows withQUALIFY row_number() OVER (PARTITION BY <series>, <time> ORDER BY _ingest_ts DESC) = 1. Leave it out withingest_time=False(Python) or--no-ingest-time(CLI);timelake infoshows it. - Settings. Every switch is declared in one registry, with its default, the levels it can be set at (creation,
table, session, call) and the version that added it.
timelake settingslists them, and with a table name shows the table's values;timelake alter <table> --set read-dedup=false --unset retentionchanges a table's settings (Table.set_propertiesin Python and Rust).Lake(..., settings={...})in Python sets session settings for every query, andSHOW timelake.read_dedupshows a session's value. Names work with dashes or underscores, with or withouttimelake.. The docs page "Table properties" is now "Settings". - Rollups.
CREATE MATERIALIZED ROLLUP iot.readings_1h AS SELECT device, time_bucket(INTERVAL '1 hour', time) AS bucket, avg(value), max(value), count(*) FROM iot.readings GROUP BY device, bucketcreates a table of aggregates per time bucket and computes it. Maintenance keeps it up to date: inline after appends once its last refresh is older thantimelake.rollup-refresh-interval(default 1 minute), and on everytimelake maintain;REFRESH MATERIALIZED ROLLUPrefreshes it now andDROP MATERIALIZED ROLLUPremoves it. AGROUP BY time_bucket(…)query on the raw table with the same width and grouping reads the buckets the rollup has right from it, and the rest (buckets changed since the refresh, partial buckets at the edges of the time range) from the raw table, so the answer is the same as without it;SET timelake.rollup_routing = falseturns that off. First version: one level from the raw table, grouped by series-key columns, withcount,sum,min,max,avg,first_valueandlast_value.timelake maintainandTable.maintain()reportrollups_refreshedandrollup_rows(--no-rollups,rollups=Falseto skip). The docs have a new page, "Rollups". - Block cache. Reads of files a table never changes (data files, manifests, manifest lists, last-value index
files) go through a cache of 1 MiB blocks, in memory by default on object stores and HTTPS (256 MiB), and on local
disk with
timelake_cache=disk, shared by every process on the machine (4 GiB). A query still checks for a newer table version, so answers are as fresh as before. Storage optionstimelake_cache,timelake_cache_dir,timelake_cache_memory_bytes,timelake_cache_disk_bytesandtimelake_cache_block_bytes;Lake.cache_stats()in Python andStorage::cache_stats()in Rust count hits and misses. On S3, TSBS IoT repeat queries went from 54 to 16 ms with no requests, and 16 clients from 288 to 398 queries per second.
Changed
- Table format 0.3.0 for new tables (the insertion-time column). Timelake 0.4 and older can't open them: upgrade
every reader and writer first.
timelake upgraderaises older tables to 0.3.0 but doesn't add_ingest_ts(a table's columns are fixed at creation for now). - Settings are checked. Creating or altering a table with an unknown
timelake.*property, a bad value, or a setting fixed at creation (time-column,series-key,chunk-interval,dedup,ingest-time-column) as a property is an error; use the table options for those.timelake.format-versioncan no longer be set by hand.SETof a table-only setting in SQL is an error.timelake sql --setwith a bad key reports an error instead of crashing. - Rust:
SessionSettingshasgetandflaginstead of public fields.Session::parsereturnstimelake::Statement(DataFusion's statement, or a rollup statement) forSession::execute.MaintainOptionshas arollupsfield andMaintainReporthasrollups_refreshedandrollup_rows. - With dedup on, rows with the same
(series, time)within one append keep only the last, so the append'srowscounts them once. Files written by earlier versions may still hold such copies until compaction. - Benchmarks: Timelake's repeat runs on object storage now read from its memory cache, so their times drop from
this version on; first runs, in a fresh process, are unaffected.
timebench run --reusealso runs the concurrent dashboards, andTIMEBENCH_TIMELAKE_OPTIONSpasses storage options to Timelake's readers. - The TypeScript client's WebAssembly reader is about 4% smaller (6.7 MB), built with Rust 1.99.
Fixed
- Compaction could let an older write win. Inline compaction merged files of a similar size even when a file of
another size was committed between them, and the merged file then counted as newer than that file: where both held
the same
(series, time), the older value won from then on. Compaction now merges only files next to each other in commit order, and a merged file keeps the data sequence number of the newest file it replaces.
0.4.0 – 2026-10-14
Added
- Install from the usual registries:
pip install timelake,cargo install timelake-cli(or thetimelakelibrary crate), andnpm install @timelake/client. The CLI also has one-line installers for macOS, Linux and Windows and binaries on each GitHub release, with build attestations. See the Quick start.
Changed
- Table format versions are semantic versions, separate from Timelake's: tables created now record
timelake.format-version = 0.2.0. Tables marked2by Timelake 0.3.0 read as 0.2.0 and need no upgrade; tables without the property are 0.1.0. A newer patch format is read and written by older Timelakes, a newer minor format (before 1.0) by neither, with a message to upgrade Timelake. See Upgrading. Timelake 0.3.0 can't open tables created from now on: upgrade every reader and writer first.
0.3.0 – 2026-10-14
Added
- Table format version: tables record
timelake.format-version(2 for tables created now; tables without it are format 1). Timelake reads tables of any older format and refuses to write to a table of a newer format than it knows, with a message to upgrade. timelake upgrade(alsoTable.upgrade()in Python and Rust) brings tables to the current format: records the format version, builds a missing last-value index, rewrites unsorted or small files and writes the head. Each step is a commit, and no snapshot expires for 7 days (--keep-snapshots), sotimelake rollback <table> <snapshot>(Table.rollback()) undoes it.--dry-runshows the plan. New table propertytimelake.keep-snapshots-until.
Fixed
- After a rollback, a batch id from the abandoned snapshots no longer counts as already committed.
Changed
- Faster first queries: after each commit the writer also writes
metadata/timelake-head, which holds the table metadata, the live files, and (while they fit in 1 MiB) the last-value index and the Parquet footers of the newest files. A cold query reads it together with a listing of the table's metadata files that confirms it is current, so a firstlatestquery takes one round trip to storage instead of three, and a first filtered scan two instead of six. A process that already has a table only checks for a newer version. Measured from a laptop, first queries take 245–545 ms instead of 510–1,030 on S3, and 210–520 instead of 760–1,190 on Tigris. The TypeScript client reads the head too and checks for a newer version while its query runs (TableInfo.manifestsisnullfor a table read from its head). Other engines see no change. New table propertiestimelake.head-format(arrow,json, a codec registered withLake::register_head_codec, ornone) andtimelake.head-inline-bytes; new storage optionstimelake_version_lookup(listorhint: no LIST, one more round trip),timelake_freshness_check(headorlist,listby default on Tigris) andtimelake_warm_connections(off by default). The requests each operation makes, and what they cost, are in the storage docs. If you write a table with an older Timelake after this one, delete itsmetadata/timelake-head.
0.2.0 – 2026-10-14
Tagged after the fact: the code at this tag still reports version 0.0.1.
Changed
- Faster commits: an append uploads its data files and metadata all at once, then makes the one conditional
write, so it waits for two round trips to storage instead of five. The version hint is now updated in the
background; Rust programs should call
timelake::flush()before exiting (the CLI and the Python package do).
0.1.0 – 2026-10-13
The first version. Tagged after the fact: the code at this tag reports version 0.0.1.
Added
- Engine (
timelakecrate): time-series and plain tables stored as Parquet files with Apache Iceberg metadata, on S3, GCS, Azure, S3-compatible stores (R2, MinIO, RustFS) or a local disk. Read-only access over HTTPS.- Time-series tables are partitioned by
hour,dayormonthand sorted by(series key, time). - Every append is one atomic commit. Concurrent writers are safe without a catalog service (put-if-absent on
vN.metadata.json); a conflicting commit is rebased and retried. - Idempotent appends with a
batch_id: a repeated batch is skipped and reported as 0 rows. - A last-value index per snapshot gives the newest row of every series without a scan.
- Maintenance (compaction, deduplication where the latest write wins, retention, snapshot expiry) runs inline after appends, or on demand. A maintenance failure is reported separately and never undoes the append.
- SQL through DataFusion, with
time_bucket(), file, row group and page pruning, and "latest row" queries rewritten to read the index.
- Time-series tables are partitioned by
- CLI (
timelake):create,append,import,sql,latest,maintain,info,ls,duckdb,dropandindex. Loads Parquet, CSV, gzipped CSV and TSV. - Python (
timelakepackage):LakeandTablewithcreate_table,append(pyarrow, polars or any Arrow stream),sql,latestandmaintain, returning pyarrow tables. Full type stubs. Invalid input raisesValueError, a missing tableKeyError, and an unresolvable concurrent committimelake.ConflictError. - WebAssembly reader and TypeScript client (
@timelake/client): reads tables over HTTPS in the browser or Node, with latest, range and time-bucket queries and Zod-typed rows. - Open format: plain DuckDB, Spark and PyIceberg read every table.
timelake duckdb <table>prints the SQL.