Changelog¶
ursus is on ZeroVer: 0.x, and a stable 1.0 is not expected yet. Go's rule for v0 applies, so a minor version may change behaviour, and an API may be renamed, removed or reshaped when the result is better, without being deprecated first. Each release below therefore says what a user of the previous one can trip on, not only what is new; from 0.6 on, each lists its breaks with their replacements.
Every step of construction has an as-built record in
context_files/, with the measurements behind each change.
Step numbers below point at those records.
v0.7.0 — 2026-10-29¶
Everything since v0.6.8 (2026-10-26): steps 375–391, and 408.
v0.7-scope.md set the scope: performance, the
Parquet read first. Step 408 checked the whole, on 0.7's code, 16e617a.
Where it ends¶
- Against Polars 1.44.1, the two run together on eight threads: h2o at ten million rows in 1.19 times Polars' time over Parquet and 1.23 over CSV, where v0.6.4's report had 1.31 and 1.64; PDS-H at SF=1 in 1.96, 1.82 with the two alternated query by query, where it had 2.01; at SF=0.1 in 1.87, where it had 2.29.
- Against v0.6.8, both built alike and alternated query by query on four threads: PDS-H at SF=1 0.94 of its time and at SF=0.1 0.91, h2o over Parquet 0.93 and over CSV 1.00. No query is slower once run again.
- Every answer matches DuckDB's, ursus's and Polars': PDS-H's 22 at both scales and h2o's 15 over Parquet and over CSV.
- Built: the Parquet read's buffers, its columns read at a text predicate's kept
rows and its dictionary text (R1 to R3); a join's text gathered by runs (J1);
integer keys packed into words for a group-by and a join (G1); a filter of keys
below a group-by or a window (O1);
Upsample's grid joined in its order (U1); a large top-k that sorts (T1); windows over a group's list, and a sink's at once (W1, W2); the CSV writer on the query's threads (C1); a CSV file's floats in one pass (C2).
Faster¶
- A join on several integer keys packs them (391): two Int64 keys, or a key
whose nulls match, are packed into two or three machine words and hashed as such,
where each row's key was encoded as bytes; a join of a million rows on two keys
takes 0.76 of its time, and an
Upsampleby groups 0.85. - The CSV writer runs on the query's threads (390): each batch's text is made on a goroutine of its own and the texts written in order, the same bytes however many run; and a date, an instant without a zone or a time of day is written digit by digit where Go's time package read a layout for each. A million rows of an integer, a float, text and an instant are written in a third of their time, about three quarters of Polars'.
- A CSV file's floats are read in one pass however they are written (389): a
float of 17 digits, as Go and Polars write one in full, was parsed three times; its
value is now found by the Eisel-Lemire algorithm, as strconv finds it, and its
fraction read eight digits at a time. Such a float parses in a third of its time,
and a
Cast(String)to a float with it. Collectcopies its answer on the query's threads (389): a result's batches were concatenated into one frame on one thread after the query ran on all of them; each column's rows are now copied in pieces at once. A million rows read from a CSV file on four threads take 0.78 of their time.- An aggregate over a window no longer copies the frame (388):
x - x.Mean()orx.Sum().Over(g)in aWithColumnshands its batches on as they came, each with the window's values, where the window concatenated them all first. - Windows in one
WithColumnsorSelectare computed at once (387), a thread each, where they were one after another: a rolling mean, max and std over a million rows take 0.55 of their time. An integer rolling sum reads its values in place. - A window function in an aggregation runs over each group's list (386):
GroupBy(g).Agg(Col("x").CumSum())collects each group's values and runs the window over them, where it partitioned the rows a second time; a million rows of a thousand groups take 0.52 of their time.List().Evalof a window with no order takes half its time, and an integerCumSumreads its values in place. - A large top-k sorts rather than heaps (385): a
Head(k)after aSortkept a heap of k rows, whose cost grows past a sort's for a k of some tens of thousands; past that it sorts every row and keeps k. The first million of ten million floats take 327–357 ms where they took 3,994–4,202. Upsampleno longer sorts its answer (383): its grid is joined to the frame keeping the grid's order, where both were numbered and the join sorted back. A million rows of 100 groups upsampled by the second take 0.74 of their time.- A filter of a group-by's keys runs before it (382):
GroupBy(k).Agg(...)filtered onkfilterskfirst, through a key's definition, and a filter of a window's partition keys runs before the window; a filter of an aggregate, HAVING, stays after, as does one of a float key. Over ten million rows of a thousand keys filtered to ten, the group-by takes 12 ms where it took 52, and a window 14 where it took 197. - A group-by of several integer keys packs them (381, 384): where they fit two
words with a null bit each, as two Int32s do, or three, as two Int64s do, a row's
keys are hashed and compared as integers, where they were encoded to bytes; and
NUniqueof an integer column keys its values so too. h2o's gb6 takes 0.85 to 0.93 of its time, PDS-H's q16 0.89 to 0.94 and q20 0.96. - A join gathers its text by runs (379): a run of consecutive matched rows, as a probe side's are in order, is copied at once, and a short string alone is moved as two words, where each row's bytes were a copy of their own. The gather takes 0.53 to 0.60 of its time on a join's two sides; h2o's joins over Parquet 0.96 to 0.99 of theirs.
- A Parquet scan filtered by text reads its other columns at the kept rows (377): where the reader judges a text's conjunct on its dictionary, as since 0.6.1, the batch's other columns are read only at the rows it keeps, where every row was decoded and the kept ones taken; and the text itself is marked and copied by the kept rows alone (378). PDS-H's q10 and q19 take 0.82 and 0.84 of their time at SF=1, and q12 0.92 of that.
- A Parquet file's pages are decompressed into reused buffers (376): each column chunk's page reader allocated its buffers afresh, a megabyte a page of DuckDB's lineitem, and the collector and the zeroing followed. A scan of four lineitem columns at SF=1 allocates 2.2 GB in seven runs where it allocated 3.9; PDS-H at SF=0.1 takes 0.93 of v0.6.8's time, and at SF=1 its q14, q17, q19, q12 and q15 0.91 to 0.95.
Known limitations¶
As v0.6.8's, except:
- Lifted: the CSV writer on one thread (390).
- Measured and left:
- expression-level CSE: the repeated row-wise subexpression is a sixth of its probe, and windows and aggregates are shared already (388);
- a CSV file read a column at a time: the builders' calls made direct measured nothing (389);
- zstd: about 170 ms of the 248–277 ms a bare scan of q14's four columns takes on
one thread, where Polars' whole scan takes 141–156 (380,
feature-gaps.md§8); Upsample, about nine times Polars' time, its join's keys the most of it (383, 391);- a window's partitions walked on one thread (386, 391).
- Not measured: a work-stealing scheduler, which the scope made depend on a profile showing threads idle (S1).
v0.6.8 — 2026-10-26¶
Everything since v0.6.7 (2026-10-26): steps 365–374.
v0.6.8-scope.md set the scope: the leftovers
of feature completeness, and five of 0.7's performance tasks done ahead of it. Step
374 checked the whole.
Where it ends¶
- The leftovers of feature completeness:
- a database written,
WriteDatabase, Polars'write_database, for SQLite, PostgreSQL, MySQL and SQL Server; - partitioned writes for the four sinks, Polars' tree name for name;
- a String or Binary column past 2 GiB, exported to Arrow as the large type.
- Five of 0.7's performance tasks:
Profile, Polars'profile();- a count that reads no column:
Countover PDS-H's lineitem at SF=1 in 2.5 ms where it took 400 to 500; - slice pushdown through what keeps rows: a projected sort's head over lineitem a top-k, 147 ms where it took 380;
- a frame's aggregate that takes its columns whole:
Lenover a million rows 0.13 ms where it took 2.5; - a CSV file read in about 1.4 times Polars' time, where it was 2.4, and h2o's
gb1over CSV in 0.78 of its time. - Each answers as Polars 1.44.1 does, checked against its answers on the same
inputs, but for the differences
feature-gaps.md§9 records. - Nothing the benchmarks run got slower:
- the optimized plans of the 22 PDS-H and 15 h2o queries are byte-identical to v0.6.7's;
- the strings, the scans, the aggregates and the CSV reader changed beneath them, so PDS-H at SF=0.1 was run against v0.6.7's, alternating: 1.00 of its time in all, twice, every query within its noise;
- every PDS-H answer at SF=0.1, at every step, and every h2o answer at 10,000,000 rows, over Parquet and over CSV, match DuckDB's.
Breaks¶
- An empty hive key of text is the empty string (367), as Polars reads
k=, where it was null; in a key of any other type it is null still. Countcomputes no column it does not need (370): a query's rows are counted through a projection of none, so a column that would fail to compute, or a udf's that nothing reads, no longer runs, and no longer fails or acts under a count. A filter's columns still do.SinkNDJSONtakesNDJSONSinkOptions (367): aCollectOption, as before, or aPartitionOption. A call passing a[]CollectOptionspread with...needs its slice's type changed.
Features¶
-
Profile(369), Polars'profile(): a query's answer and a frame of each node of its plan, asExplainprints it, after the optimization's row: itsstartandendin microseconds, as Polars', and itsrows,batches,owntime, summed over the threads that did it, andpeak_memory.Collectbuilds none of its wrappers.ExplainOptFlags(f)renders the planWithOptFlags(f)runs. -
Large strings (368): a String or Binary column past 2 GiB of characters holds 64-bit offsets, where it was refused, so a Collect, a sort, a join, a group-by or a window over more than 2 GiB of text answers. It exports to Arrow as a LargeString or a LargeBinary, a slice of it that fits as a String;
ScanArrowRecordsreads such a record back, the IPC writers split a batch that would be one, and it spills. A batch a source reads keeps 32-bit offsets and refuses past them, saying to read it in smaller batches. -
Partitioned writes (367):
WithPartitionBy(keys...)makesSinkParquet,SinkCSV,SinkIPCandSinkNDJSONwrite a hive dataset, Polars'write_parquet(partition_by=...)andsink_parquet(pl.PartitionBy(...)): a directory a key's value, its files numbered from00000000, the values escaped as Polars escapes them and a null__HIVE_DEFAULT_PARTITION__, so the tree is Polars' name for name and each reads the other's;WithPartitionIncludeKey(false)drops the keys from the files, andWithMaxRowsPerFile(n)ends a file at n rows, with or without keys. Streamed, a file written to a temporary name and renamed as it ends. -
A database written (366):
WriteDatabase, Polars'write_database, on aLazyFrameor aDataFrame, throughdatabase/sqlinto a*sql.DB, a*sql.Connor a*sql.Tx;WithIfTableExists(IfExistsFail | IfExistsReplace | IfExistsAppend)as Polars' modes; a table created of the typesReadDatabasereads back, for SQLite, PostgreSQL, MySQL or SQL Server, named by the driver orWithDialect; the rows in INSERTs of as many as the dialect's parameters allow, in one transaction.
Faster¶
- A CSV file read faster (373): a float parsed in one pass, to
strconv's bits, and an integer; a batch's column with no null has no validity bitmap, so the kernels after the read take their paths for none; an unquoted field's end found in a mask of 64 bytes. A million rows of an integer, two texts and a float, 43 MB, in 165–250 ms on one thread where they took 243–405, 78–142 on four where they took 133–188, at a load of 4 to 5.7; Polars 111–146 and 55–68. A cast from String to a float takes the same parse. - A frame's aggregate takes its columns whole (372): an aggregate of no keys,
Select(c("x").Mean()), makes no group id a row; a count, a sum, a mean, a minimum and a maximum, a first and a last take each batch's column at once, an integer's sum and mean exact in 128 bits without a copy, a float's in the same order, to the bit. Over a million rowsLentakes 0.13 ms where it took 2.5, an integer's mean 1.0 where it took 3.8;Select(Len())over lineitem at SF=1 2.3 ms where it took 30. - Slice pushdown (371): a
Heador aSlicetells the scan or the sort beneath it how few rows it needs through a projection, aWithColumns, a row index, a union's inputs and an ordered left join's left input, where it reached only the one directly beneath it. A projected sort's head over lineitem at SF=1 is a top-k, 147 ms where it took 380. - A count reads no column (370):
CountandSelect(Len())scan none of a frame's columns, where they read all, a Parquet file's rows its footers', no column chunk opened. Over PDS-H's lineitem at SF=1,Counttakes 2.5 ms where it took 400–500;Select(Len())27–39, the rest its aggregation's.
Known limitations¶
As v0.6.7's, except:
- Lifted: a database written, partitioned writes and a String column of more than 2 GiB (366–368); a count that reads every column (370).
- Not built, in
polars-parity.md: Excel, Avro, Delta and Iceberg, and a plan serialized. - A batch a source reads holds at most 2 GiB of text a column (368): a Parquet column chunk's, a CSV block's or one Arrow record's rows. Past it the read refuses, saying to read it in smaller batches; a whole frame past it is held.
- A CSV file is read in about 1.4 times Polars' time (373), and the CSV writer runs on one thread.
v0.6.7 — 2026-10-26¶
Everything since v0.6.6 (2026-10-19): steps 288–364.
v0.6.7-scope.md set the scope: feature
completeness, part 3 — frame operations. It took in what was to be 0.6.8's I/O and
the types after it, and three steps of performance. Step 364 checked the whole.
Where it ends¶
- Polars' frame operations:
- rows:
UniqueKeep, duplicates over columns, samples, negative slices, the row shortcuts; - columns and types: a frame's cast,
MatchToSchema, a frame's aggregates and fills,InsertColumn,HashRows; - reshaping: a lazy
Pivot,UnpivotwithoutOn,PartitionBy,Upsample,Concat's modes,Transposenamed from a column,ToDummies; - group-bys: a group's
HeadandTail,Having,FirstNonNull, the groups as frames; - joins:
maintain_order, an as-of numeric tolerance and coalesce,Update; - Go values in and out:
FromRows,Rows,ToMaps,IterRows, aSerieswith operations. - What remained of Polars' API, by a diff of the two (
polars-parity.md): selectors, the name and meta namespaces,TopKBy,InterpolateBy, an expression's rolling, a window inside a window, a list a group inAgg. - Types:
Arrayand theArr()namespace,Categorical, a List, an Array or a Struct as a key. - I/O: Arrow IPC, JSON and NDJSON read and written; compressed input; Parquet,
CSV, NDJSON and IPC directories, globs and hive partitions; Parquet schema
evolution; databases read through
database/sql; the rest ofread_csv's andwrite_csv's options, and Polars' date patterns. - Performance: a sort and a unique on several threads, and text sorted by its bytes: a million distinct integers sort in 30 ms on eight threads where they took 100, and their unique takes 54 to 105 ms where it took 430 to 640.
- Each answers as Polars 1.44.1 does, checked against its answers on the same
inputs, but for the differences
feature-gaps.md§9 records. - Nothing the benchmarks run got slower:
- the optimized plans of the 22 PDS-H and 15 h2o queries are byte-identical to v0.6.6's;
- the sort changed beneath them, so PDS-H at SF=0.1 was run against v0.6.6's, alternating: 0.95 to 0.98 of its time in all, every query within its noise;
- every PDS-H answer at SF=0.1, at every step, and every h2o answer at 10,000,000 rows over Parquet match DuckDB's.
Breaks¶
- A CSV record of too few fields is padded with nulls, and a blank line read as a
record of nulls (357), as Polars reads them: the record was refused unless
WithTruncateRaggedLines(true), and the line skipped in a file of more than one column.WithTruncateRaggedLinesnow concerns a record of too many fields alone. - A CSV file's text that is not UTF-8 is refused (357), as Polars refuses it,
where a String held its bytes as they were, which no text kernel reads rightly.
WithEncoding(ursus.UTF8Lossy)reads each invalid sequence as U+FFFD, and a column's name is read so whatever the encoding. Str().ToDatetime(unit, tz)reads a text of no offset ontz's wall clock (356), as Polars'str.to_datetime(time_zone=tz)does:2024-01-02 03:04:05in Tokyo is 03:04:05 there, where it was a cast's, 03:04:05 UTC, 12:04:05 in Tokyo. A text with an offset is its instant, as before..Cast(ursus.Datetime(unit, tz))reads it as UTC's still, as Polars' CSV reader does.- A CSV file's instants are written as Polars writes them (354): a naive one with no
Z,2024-01-02T03:04:05.123456, where it had one, as though it were UTC, and a zoned one with its offset as+0900, where it was+09:00, orZin UTC. A float is written in Polars' text, the cast's since step 325:123456789.0and1e-7, where they were Go's1.23456789e+08and1e-07.ScanCSVreads both forms. - An Arrow dictionary of text is read as a Categorical (352), as Polars reads one,
where it was read as a String: an IPC file's, a Parquet file's that its footer says
is one, a pandas categorical's.
.Cast(ursus.String)gives the text. QCutandHist's category answer a Categorical (352), as Polars' do, where they answered a String.Cutanswers an Enum (350) of its bins' labels, in their order, as Polars'cutdoes, where it answered a String:.Cast(ursus.String)gives the text. Its labels must differ, as an Enum's categories do, and a break is written in decimal,(-inf, 0.0000001], as Polars writes it, where it was(-inf, 1e-07].- A null's rank is null (335), as Polars':
Rankranked a null as the least value and gave it a position, so[5, null, 4]ranked[3, 1, 2]; it ranks[2, null, 1]now, the values among themselves, by every method, in a window and over the frame, streamed past a memory limit and inList().Eval. A rank's column is nullable where its input is. - A float cast to text is Polars' text (325):
2.0is"2.0",-0.0"-0.0",1e-7"1e-7",1e16"1e+16"and+Inf"inf", where they were Go's"2","-0","1e-07","1e+16"and"+Inf". A pivot named by a float's text names its column so too,2.0where it was2. - An as-of tolerance of zero admits only an exact match (315), as Polars' does:
AsOfTolerance(ursus.FromDuration(0))admitted any distance. - An as-of join keeps both ordering keys when they are named apart (315), as
Polars' does:
AsOfLeftOn(ts)withAsOfRightOn(quote_ts)givestsandquote_ts, wherequote_tswas merged intots. JoinMaintainOrdertakes aJoinOrder(314), Polars'maintain_order, where it took a Boolean:JoinMaintainOrder(true)isJoinMaintainOrder(ursus.JoinOrderLeft).RollingOptions.Offsetis where a window starts (313), as Polars'offset:(t + Offset, t + Offset + Period], minus the period when unset, as before. It moved the window's end, so an offset ofeis noweless the period:Every("15m")at a period of2hisEvery("-1h45m").NoOffset()is Polars'offset="0d", a window after its row. A window whose offset is as long as its period, behind its row, ends at the row, as Polars', a month counted as 28 days.- A null condition takes
Otherwise(311), as Polars'whenand SQL'sCASE WHENdo:When(c).Then(a).Otherwise(b)of a nullcisb, or the nextWhen's, where it was null. A conditional's result is nullable only where a branch is.Clipwith a null bound now leaves that side unclipped, as Polars' does, where it gave null;FillNanstill leaves a null alone. ConcatModeis ursus's own type (307), no longer the plan's, itsStringthe name of Polars' mode:ConcatStrictprints "vertical", where it printed "strict". Code that names the constants is unchanged.PivotOptions.Onis a[]string(303), for several columns whose values together name a new column:On: "quarter"isOn: []string{"quarter"}.- A selection among a variadic function's operands is now its operands, as in
Polars (302).
SumHorizontal(All())is each row's sum across every column, one column. Before, the function was repeated a column at a time, soMaxHorizontal(All())gave each column itself. This holds for the six horizontal reductions,ConcatStr,ConcatStrIgnoreNulls,Struct,ConcatList,List().ConcatandCoalesce.Formatstill formats a column at a time, as Polars' does. FillStrategyis auint64, where it was auint8(301): its bits above the rule carry a limit. A conversion touint8no longer compiles, and a strategy with a limit is not==the bare one,FillForward.Limit(1) != FillForward.- A slice whose window starts before the first element is cut by as much, as
every Polars slice is (291):
Str().Slice(-7, 2)of"012345"is"0", andList().Slice(-99, 2)of three elements is none. Both clamped the start to the first element and kept the whole length.
Fixes¶
-
A date before 1677 or after 2262 as a Go value (355):
Rows,ToMapsandAtgave a Date or a Datetime of such a year as a wrong instant, its nanosecond count overflowed, 0999-01-02 as 2168-02-10; it is the instant now. -
A frame collected from several batches says its columns hold no null (321), where they hold none: concatenating the batches built each column a bitmap with every bit set, and every kernel after read it a bit a row.
- A null instant decodes as the zero
time.Time(317), inRowsandCollectInto, as a null decodes as every other field type's zero: it was the epoch, 1970-01-01, a plausible instant no row held.IsZeronow tells it. - A conditional of Lists or Structs (310):
When(...).Then(list).Otherwise(...), andCoalesceof Lists, were refused, "a conditional is not implemented for List(String)"; each row is now its branch's, as Polars' when/then gives. - An expression that reads no column is named as its first input is (309), as
Polars names it:
Len().Cast(Int64)andLen().Add(1)are "len", where they were "literal". - A pivot's cell that no value falls in is
Aggof none (303), as Polars' is: a count's 0, where every such cell was null. A sum of none stays null, as ursus's every empty sum is. - A fold in a list's body (302):
List().Eval(Fold(...))and aFoldinCumulativeEval's body failed, "fold reached typing unexpanded", since folds came in (266). A body is lowered when it is written, before any schema, so a fold or a function across columns in it is now built over its expressions as written. - A printed frame aligns text outside ASCII (296). It padded each cell by its bytes, so a row holding "Zoë" came out a column short; it pads by the columns a terminal draws, two for a wide East Asian character and none for a combining mark, as Polars measures them.
WithMemoryLimit's doc said the default budget is Linux's alone; it has been macOS's and Windows's too since step 181 (294).- The expression
Slicein aSelectkept a row too many where its window began before the first row,Slice(-7, 2)of six rows two (291); and in an aggregation, a negative length, "to the end", failed.
Features¶
- A sort of text by its bytes, seven at a time (363): a String, Binary or Categorical key is radix-sorted, alone or beside other keys, where it went to the comparator, in the comparator's order exactly, ties included. A million distinct strings sort in 150 ms on one thread and 75 on eight, where they took 853 and 274; a million URLs of one prefix in 240 and 126, where they took 1,128 and 370.
- A unique on several threads, in the group-by's tables (362): an integer key's values, a string key's bytes, any other key encoded, where each key was a Go map's string; a table a thread, a key in the one its hash names, each table's rows inserted in order, so the rows kept are one thread's, each key's first. A million distinct integers take 54 to 105 ms where they took 430 to 640, and 1,000 keys among a million rows 16 to 25 where they took 40 to 58.
- A sort on several threads (361): the radix passes, the merge sort a String key takes, the rows' places in their batches and the gather of the output each split across the query's threads, in the one-thread sort's order exactly, ties included. A million distinct integers sort in 30 ms on eight threads, where they took 100, and in 69 on one.
- Several IPC files as one frame, and IPC hive datasets (360):
ScanIPCFiles,ScanIPCGlob, andScanIPC(dir)a hive dataset, as Polars'scan_ipc; a later file read by name, a filter on the keys skipping the files it rules out;WithIPCHivePartitioning,WithIPCHiveSchema,WithIPCHiveDatesandWithIPCIncludeFilePaths. - A column of each row's file path for CSV and NDJSON (359):
WithCSVIncludeFilePathsandWithNDJSONIncludeFilePaths, Polars'include_file_paths, the path as it was given; each file's repeated once and sliced for its batches, so a scan pays nothing measurable for it. - CSV rows and lines skipped, and quoting off (358):
WithSkipLines, Polars'skip_lines, lines skipped by their line ends alone;WithSkipRowsAfterHeader,skip_rows_after_header, in each file of several;WithQuoting(false),quote_char=None, a quote read as any byte. - The rest of Polars'
read_csvoptions (357):WithIgnoreErrors, a value its type cannot take read as a null;WithInferSchema(false), every column a String;WithRaiseIfEmpty(false), an empty file a frame of no columns;WithDecimalComma,1,5read as 1.5, the writer's option renamedWithWriteDecimalComma; andWithEncoding(ursus.UTF8Lossy). Text is checked as UTF-8 a span of records at a time, by a vector check nine times as fast as Go's on text of a non-ASCII character a word, so a read is as fast as before. - Text parsed by Polars' patterns (356):
Str().ToDate(),ToDatetimeandToTime("", …), andStrptimeof an empty format, read what Polars'to_date,to_datetimeandto_timeof no format read,02/01/2024and2024-01-02 03:04:05+0900among them, each value by its own pattern;ToDatetimeis 13 times as fast as before, and six times Polars'. - CSV dates inferred (355):
WithTryParseDates, Polars'try_parse_dates, infers a column of dates, instants or times of day by Polars' patterns,2024-01-02,02/01/2024,2024-01-02 03:04:05.123+0900and03:04; a Date, Datetime or Time column a schema gives is read by them too, scanned by hand, 7.7 times as fast as before. - The CSV writer's options (354), Polars'
write_csv's:WithQuoteStyle, ofQuoteNecessary,QuoteAlways,QuoteNeverandQuoteNonNumeric, andWithWriteQuote;WithDateFormat,WithDatetimeFormatandWithTimeFormat, in strftime;WithFloatPrecision,WithFloatScientificandWithWriteDecimalComma;WithIncludeBOM; andWithCSVCompression, gzip or Zstandard, atWithCSVCompressionLevel,SinkCSVchecking its path's extension unlessWithCSVCheckExtension(false). - Databases (353):
ReadDatabase(ctx, db, query, args...), Polars'read_database, runs a query throughdatabase/sql, on any driver, and reads its rows as a frame;ReadDatabaseRows(rows)reads a result already run, andWithDatabaseSchemaOverridesis Polars'schema_overrides. A column is typed by its values, every row's, met as Polars meets them, then by the type its driver names where every value fits it: anINT4an Int32, aNUMERIC(10, 2)a Decimal, aDATEa Date, aTIMESTAMPTZa Datetime in UTC. It costs 9% over a hand-writtenScanloop. - A Categorical in and out of Arrow, IPC and Parquet (352), as Polars writes one: a dictionary of its values, which Polars and PyArrow read back as one, and Polars' own files read as one.
- The
Categoricaltype (351), Polars' Categorical: text with a type of its own, made by a cast from a String or an Enum and read from CSV and NDJSON by a schema; sorted, compared, grouped and joined as its text, as Polars has since 1.32; its codes,Cat().Physical()andToPhysical(), the order its values first appear in over the frame.Cat()'s text functions take it, andselector.Categoricalselects it. It is stored as its text, and costs what a String does. **in globs, and CSV and NDJSON directories (349): a glob's**matches any number of directories, none among them, as Polars' do, inScanCSVGlob,ScanNDJSONGlobandScanParquetGlob;ScanCSVandScanNDJSONof a directory read every file below it, as Polars does; a directory of files of two extensions is refused, as Polars refuses it, Parquet's too.- Parquet files whose columns differ (348), as Polars' schema evolution reads them:
WithParquetSchema,WithInsertMissingColumns,WithIgnoreExtraColumns,WithUpcastIntegersandWithUpcastFloats, Polars'schema,missing_columns,extra_columnsandcast_options: a column a file lacks read as nulls, one it has beyond the schema passed over, a narrower integer or float widened. Each is refused otherwise, as before and as Polars refuses it. - A Parquet directory and its hive partitions (347):
ScanParquet(dir)reads every file below a directory as one frame, as Polars'scan_parquetdoes, each directory namedkey=valuea column of the files beneath it, typed as Polars types it; a filter on the keys skips the files it rules out before any is opened.WithHivePartitioning,WithHiveSchema,WithHiveDatesandWithIncludeFilePaths, Polars'hive_partitioning,hive_schema,try_parse_hive_datesandinclude_file_paths. - Compressed input (346): a CSV, NDJSON or JSON file compressed by gzip, zlib or
Zstandard is read as itself, known by its first bytes whatever its name, as Polars
reads one, decompressed as it is read and parsed in parallel as a plain file is.
A text a zlib header could begin, a CSV header of
x^, is read as text, where Polars finds no rows. A million rows by gzip in about 200 ms, twice Polars' time, as a plain file's read is. - JSON read (345):
ScanJSONandScanJSONBytes, Polars'read_json, a file of an array of objects, a row an object, or of one object, one row. Each object is made a line of NDJSON where it is read, in place, and read as NDJSON is, its blocks parsed by every thread at once, under the same options; what Polars refuses is refused, a key past the inferred schema among it, and an error names its object.WithNDJSONSchemaOverrides, Polars'schema_overrides, for both. Half a million objects of five keys in 66.9 ms, where Polars takes 321.8. - A List, an Array or a Struct is a key (344), as Polars keys one: of
GroupBy,Unique,Join,Over,PartitionBy,Mode,RleandHashRows, element by element and field by field, an empty list apart from a null one, NaN equal to NaN and-0.0to0.0, and a group-by of them spills. Each was refused. A mode of them withoutmaintainOrdercomes in the order its values first appear, having no order to sort by. ReshapeandConcatArr(343), Polars'reshapeandconcat_arr:Reshape(-1, 3)makes Arrays of three of a column's values, the rows inferred, more dimensions an Array of Arrays andReshape(-1)the values flattened, an Array's null row its size of nulls, as Polars lays it out; in an aggregation, a list of the Arrays a group makes.ConcatArrmakes one Array of each row's Arrays and values, of one element type. Values a shape does not divide are refused, as Polars refuses them.- The
Arr()namespace (342), Polars'arr:Sum,Min,Max,Mean,Median,Std,Var,NUnique,ArgMin,ArgMax,First,Last,Get,Contains,CountMatches,Any,All,Len,Head,Tail,Slice,Unique,Join,ExplodeandAgganswer asList()'s of the same elements;Reverse,Sort,SortWith,ShiftandEvalof an element's value keep each array's length and its Array, as Polars does,Eval(body, true)a List of any body's;ToStructnames its fieldsfield_0on, as Polars, unless named;Dotsums two arrays' products. AsList()'s, an integer sum is an Int128 andNUniquepasses a null over, where Polars' sum is an Int64 and itsn_uniquecounts the null. - The
Arraytype (341), Polars' fixed-size list:Array(T, n)holdsnvalues ofTa row, or a null.List().ToArray(n)and a cast make one of a list whose every row holdsn, and refuse one that does not, as Polars does;Arr().ToList()and a cast give the list back; an Array casts to another of its size. A Go[n]Tis one inFromRowsandRows. It is read and written as Polars writes it: an ArrowFixedSizeListin IPC andRecord, and in Parquet a list whose size the footer's Arrow schema keeps, so a file of either reads back an Array; NDJSON reads one by a schema and writes it, as JSON does, as a list. It explodes, filters, sorts, joins, stacks and spills; as a key it is refused, as a List is.selector.Arrayselects it. Modein an aggregation is an aggregate of its own, and spills (340): a group's mode counted the group's values imploded, held every one of them, and failed past the memory limit; it numbers each distinct value as it comes, counts each group's in an array, and past the limit hands its counts to disk, in a group-by or a dynamic or rolling group. It is 3 to 5 times as fast: by a thousand groups of a million rows 38 ms where it took 174 (Polars 19), over the frame 18 where it took 91 (Polars 5). A float's mode is its first row's,-0.0where it came before0.0, as Polars answers.CorrSpearmanspills its pairs (339): over a group larger than the memory limit, in a group-by, a window or a dynamic or rolling group, where it failed; its pairs go to disk sorted by one variable, are ranked as they come back, and sorted again by the other. Its sums are exact integers now, so its answer no longer depends on the order its pairs came in, spilled, merged from several threads or neither; and it is faster, 89 ms where it was 332 by a thousand groups of a million rows (Polars 123), and 98 where it was 909 over the frame (Polars 82).RollingRankByandRollingMap(338), Polars'rolling_rank_byandrolling_map: each row's value ranked among its window's by time, and a Go function over each row's window of rows, given its values and whether each is there, centred or not, within each partition underOver.- The rolling of an expression (337), Polars'
Expr.rolling:e.Rolling(index, o)is any aggregate expression over each row's window, placed as the frame'sRollingplaces it, and the list of the window's values for a row's; underOverwithin each partition, its index sorted within it, the answer in the frame's rows' order. A sum, a mean, a minimum, a maximum, a variance, a deviation, a median and a quantile are rolled by their own windows by time, thirty times as fast as by the rolling group, and a rolling window by an index already sorted skips its sort. - A window inside a window, and an aggregate inside an aggregate (336), as Polars
computes them:
Col("x").Shift(1).CumSum(false).Over(g)shifts and then sums within each g,Col("x").Sum().Over(g).CumSum(false).Over(h)sums g within each h, and(x - x.Mean()).Abs().Sum()is each group's, or the frame's, sum of distances from its mean. InAgga mix,x - x.Mean(), is a list a group,x.Sum().Mean()the group's sum, andSortBytakes an aggregate in its keys. Each inner one is computed beneath the outer in its partitions and order; an aggregate the order does not change takes none of it.(x - x.Mean()).CumSum(false).Over(g), which failed with an internal error, answers. A window over one integer key numbers its partitions as the group-by does, twice as fast:Sum().Over(g)over a million rows of 1000 groups in 15.5 ms, where it took 38. - NDJSON read (334):
ScanNDJSON,ScanNDJSONFiles,ScanNDJSONGlob,ScanNDJSONFromandScanNDJSONBytes, as Polars'read_ndjsonandscan_ndjson, types inferred as Polars infers them;WithNDJSONSchemaof any type,WithInferSchemaLinesandWithNDJSONIgnoreErrors. Each block of lines is parsed by every thread at once, faster than Polars: a million lines of four keys in 70 ms to its 79, nested ones 2.3 times as fast. - JSON written (333):
WriteNDJSON,WriteJSONandSinkNDJSON, as Polars'write_ndjson,write_jsonandsink_ndjson, each value in Polars' own text, to the byte. - Arrow IPC (332), as Polars reads and writes it:
ScanIPC,ScanIPCStream,WriteIPC,WriteIPCStream,SinkIPC, uncompressed, LZ4 or Zstandard, andReadIPCSchema; andReadParquetSchema. Polars' own files read, string views and large lists in them, and Polars reads ursus's. - A list a group, and a window a group at a time (331): a non-aggregate in
Aggis each group's values as a list, as Polars answers it,Agg(Col("x"))andAgg(Col("x").Mul(2)); and a window anywhere inAggruns a group at a time,Agg(Col("x").CumSum(false))a list andAgg(Col("x").CumSum(false).Max())one value. They were refused, "expression is not an aggregate" and a hint to move the window into aWithColumns. TopKByandBottomKBy(330), Polars'top_k_byandbottom_k_by: the k values where another column is greatest or least, a List a group asTopK's, bounded, and parallel in a group-by: rows that tie are ordered by their values.InterpolateBy(329), Polars'interpolate_by: a gap filled on the line at each row'sby, a time or a number, sorted or not, to Polars' last bit.MapRows,MapColumns,ShowandCache(328), as Polars'map_rows,map_columns,showandcache: a Go function of each row, typed by its struct or a[]any, or of each named column; the first n rows drawn, every one; and a query run once where it is used twice, whatever it holds.- A frame's conveniences (327), as Polars' frame methods:
Corr, NumPy's correlation of every pair of columns;Gather,Row,RowNamed,RowWhere,ItemandItemAt;GetColumnIndex,ReplaceColumn,DropInPlace,ToSeriesandToStruct;SumHorizontal,MeanHorizontal,MinHorizontal,MaxHorizontalandFoldacross the columns; and a lazy frame'sColumns,DTypesandWidth. - The meta namespace (326),
Expr.Meta(), as Polars'meta:RootNames,OutputName,HasMultipleOutputs,IsColumn,IsColumnSelection,IsLiteral,IsRegexProjection,UndoAliases,Eq,PopandTreeFormat. Str().ToTime,Str().ToDecimalandDt().CastTimeUnit(325), as Polars'str.to_time,str.to_decimalanddt.cast_time_unit.- Polars' odds (324):
Item,LowerBound,UpperBoundandIsSorted, as aggregates;ToPhysicalandReinterpret;Arctan2,TrueDiv,LimitandPipe. - The name namespace (323),
Expr.Name(), as Polars'name:Keep,Map,Prefix,Suffix,ToLower,ToUpper,Replace, and a struct'sPrefixFields,SuffixFieldsandMapFields; andStruct().Drop, asstruct.drop. - Selectors (322), package
ursus/selector, as Polars'polars.selectors: columns by their types,Numeric,Integer,Float,Temporal,Stringand the rest; by name,StartsWith,EndsWith,Contains,Matches,ByName; by place,ByIndex,First,Last; and theirUnion,Intersect,Difference,SymmetricDifference,ComplementandExclude, each an expression byExpr, its names byExpand. - A group-by's groups as frames (321):
Groups, an iterator of each group's key and rows, andMapGroups, a Go function over each, as Polars' iteration andmap_groups; and a frame'sExtend,GlimpseandEstimatedSize, andWithColumnsSeqandSelectSeq, as Polars'.PartitionBy, which they are built on, is three and a half times as fast: its parts numbered with a group-by's tables and gathered in parallel. ToDummies(320), as Polars'to_dummies, on a frame and on aSeries: aUint8column for each distinct value, named by the value as Polars displays it and ordered by name, as Polars orders them.Unstack, as Polars'unstack, vertical or horizontal, with its fill values.Transposenames its columns from a column,ColumnNamesFrom, as Polars'column_names, and meets text beside numbers at text, as Polars does, where it refused them (320).- A
Serieswith operations (319), as Polars':Sort,Unique,Reverse,DropNulls,FillNull,Cast[U],Head,Tail,Slice,Rename, and the aggregates.Seriesis ursus's own type now, the view it was embedded, so every method it had is there still. - Rows out (318):
RowsByKey, as Polars'rows_by_key;ToMaps, as itsto_dicts; andIterRows, a query's rows as structs streamed a batch at a time, as itsiter_rows. FromRows(317), a frame from Go structs, as Polars builds one from dataclasses: a column a field, named asRowsreads it, pointers nullable, slices Lists and structs Structs; andRowsdecodes a Struct column into a struct field, so the two round-trip.Update(316), as Polars'DataFrame.update: another frame's values written over this one's, matched by keys or by place, a null leaving the value unlessIncludeNulls, keeping the frame's rows, all of them, the matched ones or other's new ones too, in an order, under a memory limit as well.- An as-of join's numeric tolerance and its coalesce (315), as Polars':
AsOfTolerance(5)on an integer key,AsOfTolerance(0.5)on a float one, andAsOfCoalesce(false)keeping the right frame's key; andJoinWhereSuffix, Polars'join_where(suffix=). - A join's order, Polars'
maintain_order(314):JoinOrderLeftandJoinOrderRight, and Polars'left_rightandright_left, for every kind of join: the left or the right frame's rows in their order, each row's matches in the other's, and the unmatched rows of the other after them. The order holds under a memory limit, where a join's never did: a join whose table spills sorts its rows back. A join that fits pays nothing for the left order; the right one hashes the left frame. - An integer index (312), Polars'
"3i":GroupByDynamicandRollingover anInt32orInt64index, its windows cut from its values and its type kept, andUpsampleand the rolling windows and ewms by a column over one. All butGroupByDynamictake aUInt32or aUInt64too, as Polars' do."i"mixes with no unit of time, andTruncate,Round,OffsetBy, a range or an as-of tolerance refuses it, where Polars moves nothing, panics, or reads it as nanoseconds. FirstNonNullandLastNonNull(310), Polars'first(ignore_nulls=True)andlast(ignore_nulls=True), of an expression and as group-by shorthands, and a group-by'sAll(), each column a List a group, as Polars'group_by().all().Having(309), as Polars'GroupBy.havingand SQL'sHAVING: the groups whose predicates, aggregates of their own, all hold, forAgg, the shorthands,Head,Tail, and dynamic and rolling groups.- A group's
Head(n)andTail(n)(308), as Polars'group_by().head()andtail(): each group's first or last rows as rows, the keys first, the groups in the order each first appears, spilling past a memory limit with the order kept. Concat's modes (307), Polars' eleven:ConcatRelaxedandConcatDiagonalRelaxed, meeting a column's types at Polars' supertype;ConcatHorizontalandConcatHorizontalExtend, side by side, a shorter frame padded with nulls; andConcatAlign,ConcatAlignInner,ConcatAlignLeftandConcatAlignRight, frames joined on the columns every frame has and sorted by them.ConcatFramesconcatenatesDataFrames in any mode.Upsample(306), as Polars'upsample, and lazy: a row at every step from each group's first instant to its last, nulls where the frame has none, its rows in the grid's order with or without a memory limit.PartitionBy(305), as Polars'partition_by: a frame split into a part for each distinct key, in the order the keys first appear, each part's typedKeybeside itsFrame, with or without the key columns.UnpivotwithoutOn(304), as Polars'on=None: every column not inIndexmelted, every column with noIndexeither, and nothing, no rows, whenIndexnames every column.- A lazy
Pivot(303), as Polars'LazyFrame.pivot: its new columns given inOnColumns, so the plan knows them before it runs. A group-by of the index and the On values reduces the rows, streaming them and spilling past a memory limit. ADataFrame's pivot takesOnColumnstoo, or finds them; and severalOncolumns, whose values together name a new column,{"q1","web"}. InsertColumnandHashRows(302), as Polars'insert_columnandhash_rows: a column, an expression or Go values, put at a place counted from either end; and each row's hash over its columns, an expression, and aSeriesof a frame.- A fill strategy's limit (301), Polars'
fill_null(strategy="forward", limit=1):FillForward.Limit(1)andFillBackward.Limit(n), for an expression or every column of a frame at once. - A frame's
DropNansandInterpolate(301), as Polars': the rows with a NaN in the named or every float column dropped, a null kept; every column a line can fill interpolated, numbers and instants, text and Booleans left as they are. Interpolateof instants, durations and times (301), on their ticks in integers, keeping their type, as Polars': halfway from January 1 to January 4 is January 2. It was refused.- A frame's aggregates (300), as Polars':
Sum,Mean,Median,Min,Max,Std,Var,Quantile,ProductandNullCounton both frames, one row, every column kept, a null where the aggregate does not take the column, as Polars answers: a Boolean's mean is the share of its trues, a text column's sum is null. Quantileof instants, durations and times (300), one of them as their median is, a Date's a Datetime(us), as Polars'. It was refused.MatchToSchema(299), as Polars'match_to_schema: a frame made to a schema's columns, order and types, its missing columns inserted or refused, its extra ones dropped or refused, a narrower number widened where nothing can be lost, a struct's fields matched by name at any depth, each option for one column too, and a missing column computed by an expression.- A frame's cast (298), as Polars' frame
cast:Castby column name,CastTypesby type,CastAllevery column, on both frames;CastStrict(false)turns what does not convert into nulls. A column of its own type is left out of the plan, and the plan is the frame's order whatever the map's. - A documentation site (294), advenn.github.io/ursus:
a quick start, the concepts, a page for Polars users and the benchmarks, versioned
by release from 0.6.6 on. Every code block on it is an example in
docs/examplesthatgo testruns. Its user guide has chapters on selecting and filtering, aggregation, window functions and joins (295); reshaping, strings, lists and structs, and dates and times (296); and missing data, Go values in and out, Go functions, and CSV, Parquet and Arrow (297). - Row shortcuts (293): a frame's
Shift,ShiftFill,GatherEvery,Clear,FirstandLast;DataFrame.IsEmptyandEquals(other, nullEqual). - A frame's sample (292):
SampleN,SampleFractionandShuffleon both frames, every column's values of the same rows, seeded. -
Slicewith a negative offset (291), counted from the end as Polars counts it:Slice(-3, 2)is the last three rows' first two. It holds the rowsTailholds. -
Duplicates over several columns (290):
IsDuplicated(exprs...)andIsUnique(exprs...), a Boolean a row;DataFrame.IsDuplicatedandIsUniqueof whole rows;DataFrame.NUnique(subset...). UniqueKeep(keep, subset...)(289): the first, last, any or none of each key's rows, kept in their order, as Polars'unique(keep=, maintain_order=True).
Known limitations¶
As v0.6.6's, except:
- Lifted: a window inside a window and an aggregate inside an aggregate (336);
CorrSpearmanandModepast a memory limit, which spill (339, 340);.arr(341, 342). - Not built, in
polars-parity.md: Excel, Avro, Delta and Iceberg; a database written; partitioned writes; a plan serialized; a String column of more than 2 GiB, which is refused. 0.6.8 takes partitioned writes, large strings and a database written. - A count still reads every column:
Select(Len())over Parquet projects them all; Polars reads none. - A CSV file is read in about three and a half times Polars' time (step 357), and the CSV writer runs on one thread.
v0.6.6 — 2026-10-19¶
Everything since v0.6.5 (2026-10-15): steps 262–287.
v0.6.6-scope.md set the scope: feature
completeness, part 2 — namespaces and generators. Frame operations are 0.6.7 and
I/O 0.6.8; feature-gaps.md lists what is
missing. Step 287 checked the whole.
Where it ends¶
- Polars' namespaces and generators:
- generators, temporal ranges and values from their components; folds; selection by
position;
List().Eval; - the
.list,.strand.dtmethods that were missing,.caton an Enum,.bin, and struct fields of several names; - the statistics, integers as bits, search, hashing and counts of values, run lengths and sequences, and rounding modes;
- rolling skew, kurtosis, rank, cov and corr,
ewm_sumand the ewms by time,histandcumulative_eval. - Each answers as Polars 1.44.1 does, checked against its answers on the same inputs.
- Many hold to the bit, the rounding, the moments, the ewms and
histported from Polars' own source. - The exceptions:
Hash,SampleandApproxNUnique, which cannot, and the differencesfeature-gaps.md§9 records. - Each spills or streams where its pieces do. A per-row function streams a batch
at a time, a reshape in a
Selectspills through the frame operator it is written in, and an aggregate spills with the group-by. The rolling and ewm functions stream over one partition. What refuses under a limit is listed below. - Nothing the benchmarks run changed:
- the optimized plans of the 22 PDS-H and 15 h2o queries are byte-identical to
v0.6.5's, once step 287 named
Len()without the Alias node that showed in gb9's plan. So no timing ran, and 0.6.4's figures stand. - Every PDS-H answer at SF=0.1, at every step, and every h2o answer at 10,000,000 rows over Parquet match DuckDB's.
Breaks¶
Str().SplitN(by, n)is a Struct, fieldsfield_0tofield_{n-1}, as Polars'splitn(270). It was a List,n <= 0unlimited:Str().Split(by)is every part.- A struct's field is named after the field, as Polars names it (275):
Col("s").Struct().Field("b")isb, where it wass. SoWithFieldsof a field read sets that field, where it added one named after the struct. - A null bound of
Clipis no bound, for its row, as Polars'Noneis (283). It was a null result, and a null literal bound nulled the whole column. Str()refuses a Binary column, as Polars'.strdoes (274). It read one as text, and a text function such asSlicesplit its bytes as runes;Bin()has the byte functions.
Features¶
- Generators (263):
IntRange,LinearSpace,Repeat,OnesandZeros, a column of their own length, e.g.WithColumns(IntRange(0, Len(), 1).Alias("i")); and their per-row formsIntRanges,LinearSpacesandExpr.RepeatBy, a List a row.LinearSpaceagrees with Polars' to the bit. - Temporal ranges (264):
DateRange,DatetimeRangeandTimeRange, andDateRanges,DatetimeRangesandTimeRangesa row, by anIntervalor atime.Duration, each value start plus k intervals as Polars counts them. - Values from their components (265):
MakeDate,MakeDatetime,MakeTimeandMakeDuration, Polars'pl.dateand its kin, from constants or expressions;FromEpoch; andBusinessDayCountwith aBusinessCalendar. - Folds and constructors (266):
ConcatList,Fold,Reduce,CumFold,CumReduce,CumSumHorizontalandFormat. A fold's function builds the expression once, when the query is planned, over a selection's columns too:Fold(0, func(acc, x Expr) Expr { return acc.Add(x) }, All()). - Selection by position and the index functions (267):
Nth,First()andLast()as column selectors;ArgWhere,ArgSortBy, andArgSort,ArgTrueandArgUniqueon an expression, UInt32 indices, a list a group in an aggregation. List().EvalandElement()(268), an expression over each list's elements: values an element, aggregates, reshapes, filters and window functions, e.g.Col("l").List().Eval(Element().Sub(Element().Mean())). AndList().AggandList().Filter.- The list namespace's missing methods (269):
Concat,Shift,Diff,CountMatches,Any,All,Item,SampleN,SampleFraction, andSortWithfor nulls last. - Matching, splitting and forms of strings (270):
ContainsAny,ReplaceMany,ExtractManyandFindManyover many patterns, by Polars' matching rules;ExtractGroupsandSplitExact, a Struct;SplitInclusive;ToTitlecase; andNormalizeto NFC, NFD, NFKC or NFKD. - Encodings, integers in a base, and JSON (271):
Str().EncodeandDecode, hex or base64;ToIntegerWith(base, dtype, strict), the base a constant or a column;ReplaceN, the first n matches;ConcatStrIgnoreNulls;JsonPathMatch(path); andJsonDecode(dtype), to a type given, with Polars' conversions. - The temporal namespace's missing functions (272):
IsoYear,CenturyandMillennium;BaseUtcOffsetandDstOffset;WithTimeUnit;TotalMilliseconds,TotalMicrosecondsandTotalNanoseconds, and every total and the second with its fraction,TotalSecondsFractionaland the rest;EpochIn,EpochDaysandTimestampin a unit;IsBusinessDayandAddBusinessDayswith aBusinessCalendarand aRoll; andReplace(DateTimeParts{...}), setting components. - The categorical namespace on an Enum (273):
Cat().GetCategories,StartsWith,EndsWith,LenBytes,LenChars,SliceandPhysical; andStr()reads an Enum as its text, where it refused one. - The binary namespace (274):
Bin().Contains,StartsWith,EndsWith,Get,Head,TailandSlice, each argument a constant or an expression;Size,Encode,Decode, andReinterpretto a number or an instant, little- or big-endian. - Histograms and prefixes (286):
Hist(opts...), bins by count or edges, a count or a Struct with each bin's edge and interval;CumulativeEval(body), an aggregate ofElement()over each row's prefix. - Exponential weights (285):
EwmSum, andEwmMeanByandEwmSumByby time, each Polars' to the bit; over the whole frame they stream under a memory limit. - Rolling statistics (284):
RollingSkew,RollingKurtosisandRollingRank, andRollingCovandRollingCorrof two expressions. The moments are Polars' algorithm, a kurtosis Polars' to the bit. - Numeric odds (283):
Round(decimals, RoundHalfToEven), Polars' default mode;Truncate(decimals);RoundSigFigs(digits);Cot();ClipLowerandClipUpper. Rounding is Polars' algorithm, so each answer is Polars' to the bit. - Sequences (282):
Append(other, upcast),ExtendConstant(value, n),SampleN,SampleFractionandShuffle(seed), each a column of its own length in a Select and a list a group in an aggregation. - Run lengths (281):
RleID(), a window function, andRle(), a Struct of each run's length and value. - Counts of values (280):
UniqueCounts()andValueCounts(sort, normalize, name...), a Struct of each value and its count, a column of its own length in a Select and a list a group in an aggregation. - Search, peaks and hashing (279):
SearchSorted(value, side, descending),IndexOf,PeakMax,PeakMinandHash(seed), each comparing in the total order Sort uses, NaN above every number, as Polars'. - Integers as bits (278):
And,Or,XorandNotof integers are their bits', at their common type, as Polars'&,|,^and~; a Go integer beside one keeps its type where it fits. A Boolean beside an integer is refused. - Entropy, mode and Spearman's correlation (277):
Entropy(base, normalize);Mode(maintainOrder), a column of its own length or a list a group; andCorrSpearman(a, b). - Skew, kurtosis and small aggregates (276):
Skew(bias)andKurtosis(fisher, bias), kept online and merged pairwise;BitwiseAnd,BitwiseOrandBitwiseXorof integers or Booleans;NanMinandNanMax;HasNulls,IsEmpty,ApproxNUnique(exact) andDot. Struct().Fieldof several names (275), or a regex between^and$, or"*": a column a field, in the struct's order, as Polars'struct.field.
Fixes¶
- A list's eval over an aggregate in a
Select,Col("a").Implode().List().Eval(...), failed as an internal error (271). Len()is namedlen, as Polars'pl.len()is; in most plans it was namedliteral(273).Aliasof an alias now replaces the name. Since step 287 the name isOutputName's, not an Alias node's, so every plan that holdsLen()renders as at v0.6.5.Unnest().Suffix(...)keeps its suffix (275); each field's name undid it.
Known limitations¶
As v0.6.5's, except:
- Refused under a memory limit, each by name:
CorrSpearmanandModein a group-by past the limit: their per-group pairs and values do not spill;RleIDandRleover a partition larger than the limit;SampleN,SampleFraction,ShuffleandHistin aSelect, which implode the column, as implode refuses;CumulativeEvalover a partition larger than the limit;- a generator whose argument is an aggregate of the frame, beside a string column,
such as
WithColumns(IntRange(0, Len(), 1)).WithRowIndexstreams. - Last bits:
- a rolling var, std, skew, kurtosis, cov or corr streamed under a limit can differ from the answer in memory;
- a float aggregate's depend on the thread count,
Sum's andVar's as the new moments'; a spilled run is the one-thread run's. - Refused where Polars answers:
- a window inside a window,
Col("a").Shift(1).CumSum(); - an aggregate inside an aggregate's input;
CumulativeEvalinGroupBy(...).Agg, as every window is..arrwaits for an Array type, in a release of types after I/O.- A count reads every column:
Select(Len())over Parquet projects them all; Polars reads none.
v0.6.5 — 2026-10-15¶
Everything since v0.6.4 (2026-10-14): steps 248–261.
v0.6.5-scope.md set the scope: feature
completeness, part 1 — the bugs, and the expression model. The rest of 0.6 is
feature completeness, and 0.7 performance;
feature-gaps.md lists what is missing. Step 261
checked the whole.
Where it ends¶
- Expressions work where Polars' do. An aggregate is a scalar in
Select,WithColumnsandFilter.Expr.Filteris an aggregate's input. A length-changing expression is a list a group in an aggregation, and a column of its own length inSelectandWithColumns. Arguments take expressions, and a Go slice is a List literal.ExplodeandUnnestare expressions, asDropNullsandDropNansbecame. - Each answers as Polars 1.44.1 does, checked against its answers on the same
inputs, but for the differences
feature-gaps.md§9 records. - Each spills or streams where its pieces do: a broadcast aggregate as a window, a filtered aggregate through the same accumulators, a column of another length as a plan of its own over the frame. A group's values in an aggregation are held by its implode, which refuses past the limit, as it did.
- Nothing the benchmarks run changed: the optimized plans of the 22 PDS-H and 15 h2o queries are byte-identical to v0.6.4's, so no timing ran; 0.6.4's figures stand. Every PDS-H answer at SF=0.1, at every step, and every h2o answer at 2,000,000 rows match DuckDB's.
Breaks¶
List().Get, andStr()'sContains,StartsWith,EndsWith,StripPrefix,StripSuffix,Split,ReplaceandReplaceAll, are generic methods now, over a constant or anExpr. A call compiles as before; a method value needs its type argument:l.Get[int], notl.Get.IsIn,OffsetByandLitwere generic already and take a wider type set.Expr.Explode,Unnest,DropNullsandDropNansno longer refuse; code that relied on the refusal to catch a mistake gets an answer.
Fixed¶
lf.FillNull(ursus.FillForward)fills by the strategy (249). It filled every null with the constant 2:FillStrategyis auint8, so it was taken for a value.Expr.FillNullWith(strategy)did the same. With no subset, forward and backward fill every column, min and max every ordered one, and zero, one and mean every numeric one; Polars fills a String with "" and a Bool with false for zero, and refuses a String for one and mean.
Features¶
Dt().Date()andDt().Time()(249), Polars'dt.dateanddt.time: the date and time of day on the column's zone's wall clock.Cast(Date)reads the UTC one, as Polars' cast does.- An aggregate in
Select,WithColumnsandFilteris a scalar (250), as in Polars:Select(Col("x"), Col("x").Sum())gives each row the total,WithColumns(Col("x").Sub(Col("x").Mean()))centres x, andFilter(Col("x").Gt(Col("x").Mean()))keeps the rows above the mean. ASelectof aggregates, with or without literals, is one row. It was refused. The broadcast streams past a memory limit. - A window in
Filter(250), SQL's QUALIFY:Filter(Col("x").Sum().Over(Col("g")).Gt(10)). It was refused. Expr.Filteras an aggregate's input (251), a conditional aggregate:Col("a").Filter(Col("b").Gt(0)).Sum(), in a group-by, a window, a rolling or dynamic group, and as a scalar. The aggregate reads only the rows that pass, soLencounts them. Anywhere else a filter is refused for now.Sort,SortDesc,Head,Tail,Slice,Reverse,Unique,DropNullsandGeton an expression (252), as Polars has them: in an aggregation a list a group, and an aggregate over one a value,Col("a").Head(2).Sum(), there and inSelect,WithColumns,Filterand.Over.Expr.DropNullswas a refusal. Alone outside an aggregation they are refused for now.SortBy,SortByDesc,GatherEveryandDropNanson an expression, andmedian,std,varandquantileover any of them (253).Expr.DropNanswas a refusal. The list namespace gainsMedian,Std,Var,Quantile,DropNans,GatherEvery,SortByandSortByDesc.IsIn(Col("tags")), a value in each row's own list, andList().GetandList().Containsof an expression, a per-row index and value (254).IsInnow takesOperands, andGetanintor anExpr.- String patterns as expressions (255):
Contains,StartsWith,EndsWith,StripPrefix,StripSuffixandSplittake a pattern a row, andReplaceandReplaceAlla value a row, inserted as written. A null replacement value gives null, where Polars keeps the string. Dt().OffsetByof an expression (256): an offset a row, a String such as"1mo2d". One that does not parse, or that the type cannot hold, is an error naming its row.- List literals (257):
Lit([]int64{1, 2})and the other slices are one List value, e.g.Col("x").IsIn(Lit([]int64{1, 2})). AndExpr.Gatherin an aggregation, by a List literal or a column of indices. - Length-changing expressions in
Select(258): a reshape or anExpr.Filteris a column of its own length, e.g.Select(Col("a").Sort(), Col("b").Head(3))is refused for its two lengths andSelect(Col("a").Head(3), Col("b").Sum())is three rows. Each is a plan of its own over the frame, so it spills as the frame-level operation does. WithColumnstakes them at the frame's height, andExpr.Explodeis an expression (259), inSelect, in an aggregation and under an aggregate; it was a refusal.Expr.Unnest(260): a struct's fields, a column each named after its field, inSelect,WithColumnsand under an operation, as Polars'struct.unnest(); it was a refusal.StructExpr.Unnestspells it as Polars does.
Known limitations¶
As v0.6.4's, except:
- Of the expression model, each refused by name:
IsIn(Col("b").Implode()), membership among a column's values: 0.7's scalar subqueries;- a length-changing expression in
Filter; GatherinSelect;- a reshape of a selection of several columns,
Col("a", "b").Sort(); DropNansof a column that is not a float, andExplodeof one that is not a List, where Polars returns them as they were.- A
Selectof literals alone is a row for each of the frame's rows; Polars' is one row. - A column of another length in
Selectreads the frame again: each is a plan of its own over it. A frame that joins, aggregates or sorts is computed once and replayed; a scan is read once a plan.
v0.6.4 — 2026-10-14¶
Everything since v0.6.3 (2026-10-12): steps 234–247.
v0.6.4-scope.md set the scope: out of core, the
leftovers — what still refuses under a memory budget, made to spill or stream.
Step 247 measured the whole.
Where it ends¶
- Every window function streams past a memory limit, over one huge partition too;
a rolling window by time holds the rows of its period. Keyed
RollingandGroupByDynamichand their answer out. n_unique, and a median, quantile or n_unique in a window or a temporal group, spill their values; a large top-k spills. - Out of core, measured: each new path under a tenth of its input gives the answer it gives without a limit, the same to the byte, peaking at 1.01 to 2.11 times the limit (step 247 §3).
- Against v0.6.3, both built alike and alternated: level. PDS-H at SF=1 takes 1.4% less wall time by geomean and 0.4% more CPU, at SF=0.1 2% less; h2o's gb1, gb4, gb5 and j1 move by −8% to +2%. 0.6.4 changed what happens past a limit, and these run without one.
- Against Polars, in one session, every answer validated against DuckDB's:
| suite | ursus | Polars | ratio | at v0.6.3 |
|---|---|---|---|---|
| PDS-H SF=1 | 158 ms | 79 ms | 2.01× | 1.94× |
| PDS-H SF=0.1 | 20 ms | 9 ms | 2.29× | 1.82× |
| h2o 10M, Parquet | 697 ms | 531 ms | 1.31× | 1.20× |
| h2o 10M, CSV | 1,784 ms | 1,088 ms | 1.64× | 1.77× |
The builds are level, so the ratios move with the session: both engines ran h2o faster than in 0.6.3's, which was short of memory, Polars by more over Parquet and ursus by more over CSV. - CI's race job, which had timed out since step 237, has twenty minutes, and its heaviest budget tests run smaller under the race detector (244).
Out of core¶
- A window with an order_by streams over one huge partition (235), with no partition_by or over one hot key: its rows' order keys and inputs are sorted, the functions walked over them, and the answers sorted back beside the rows. It refused.
- A reversed cumulative function and a backward fill with no limit stream over one huge partition (236), walked backward through a reverse of that stream. They refused.
- Rank, every method, streams over one huge partition (237), in its child's order: max and average from the peer group's first position counted both ways. It refused.
- Interpolation, linear and nearest, streams over one huge partition (238), from a walk each way finding the values on either side and their positions. It refused.
- A sort merges its spilled runs as they gather, sixteen of one level into one of the next (239), so its final merge holds a batch of fewer than sixteen runs a level. A window's sorted pass peaked at 6.7× its limit and now at 1.2×.
- A window with no partition_by beside a keyed one spills (240), its keyless functions one more pass of the keyed spill. It refused. Of windows over one huge partition, only a rolling window by time still refuses.
- n_unique over a few huge groups, or with no key, spills its values (241), as sorted runs of their bytes, counted as they merge back. It refused. Implode, whose answer is every value, still does.
- A median, quantile or n_unique in a window spills its values over one huge partition (242), as a group-by's does. It refused. A run of spilled values, a group-by's too, is now written a batch at a time, so the merge back holds a batch of each run rather than a chunk of 8,192 values.
- Keyed
RollingandGroupByDynamichand their answer out (243) as their windows close, and a sort that spills puts it in bucket order. They held every window, and refused past the limit. In memory a keyed group is a little slower: over 2,000,000 rows under 1,000 keys, +4% rolling and +13% dynamic. - A median, quantile or n_unique in
RollingorGroupByDynamicspills its values (244), keyed or not, as a group-by's does. Windows that overlap hold a value for every (window, row) pair, many times their input, and refused past the limit. Implode still does. Sort(...).Head(k)with a large k spills (245): past the limit its best k rows go to disk as runs, and the first k of their merge are the answer, ties in input order. It held them past the limit.- A rolling window by time streams over one huge partition (246), in a sorted pass by its by, each function's state carried from batch to batch so that the answers match the in-memory ones to the bit. It refused. It holds the rows of its period, and refuses, naming the period, only when one period is past the limit. Every window function now streams past the limit.
Known limitations¶
As v0.6.3's, except:
- Operators that do not spill, each failing under a budget with an error naming itself:
- a rolling window by time one of whose periods holds more rows than the limit;
- implode over a group, a window partition or a temporal window larger than the limit: its answer is every value;
- join_asof's read-ahead: a left by-key the right side lacks reads that side to its end and holds it;
- a dynamic group's long gap in its index makes every empty window in it at once, under the window ceiling.
- A keyed
RollingorGroupByDynamicis a little slower in memory, +4% and +13% over 2,000,000 rows under 1,000 keys: its windows go out in segments and are put in bucket order at the end. - Speed: 2.01× Polars on PDS-H at SF=1. The widest gaps are q12 at 4.3×, q19 at 3.5× and q16 at 2.9×; over h2o, gb4 at 2.3× over Parquet and 3.1× over CSV, the joins at 1.7 to 2.1×.
- Memory: with no limit set, h2o's
gb10peaks at 7.7 GB over Parquet and 6.4 GB over CSV; Polars' highest is 5.0 GB. - A large top-k is slower in memory than under a limit: the best half of two million rows takes 1.35 s as a heap, and 0.42 s as spilled sorted runs.
v0.6.3 — 2026-10-12¶
Everything since v0.6.2 (2026-10-12): steps 213–232.
v0.6.3-scope.md set the scope: wall-time speed,
out-of-core, and features. Each step was timed against 0.6.2's code, alternated in
one session, and step 232 measured the whole.
Where it ends¶
- Against v0.6.2, both built alike and alternated over PDS-H at SF=1: 7.6% less wall time by geomean, 2.2% more CPU, the Parquet reader decoding ahead. q11 takes 30% less time, q4 22%, q15, q21 and q12 15 to 17%. Over h2o, gb1 takes 31% less, gb4 24%, gb5 8%.
- The scope's claims: PDS-H at least 7% faster by wall time, no query slower beyond about 5%, and q1's and gb4's peak memory within +100 MB, met; gb4 at least 35% faster, not met, at 24%: it is bound by this laptop's four cores.
- Against Polars, in one session, every answer validated against DuckDB's:
| suite | ursus | Polars | ratio | at v0.6.2 |
|---|---|---|---|---|
| PDS-H SF=1 | 148 ms | 76 ms | 1.94× | 2.07× |
| PDS-H SF=0.1 | 18 ms | 10 ms | 1.82× | 2.76× |
| h2o 10M, Parquet | 928 ms | 774 ms | 1.20× | 1.52× |
| h2o 10M, CSV | 2,535 ms | 1,434 ms | 1.77× | 1.65× |
The laptop was short of memory, and both engines ran h2o slower than in 0.6.2's
session, by different amounts. The h2o ratios measure that session, and the
alternated runs what 0.6.3 changed: faster than 0.6.2 over Parquet and CSV.
- Out of core: tail, hstack, a keyless WhereExists with a predicate, a median or
quantile over a few huge groups, and ewm and a reversed cum_count over one huge
partition, each past a limit that refused it before.
- Features: the list namespace's set operations, Gather, ToStruct,
NUnique, ArgMin and ArgMax; six horizontal reductions; Replace and its
strict forms; rolling median and quantile; eager Pivot and Transpose;
DaysInMonth, Combine, RenameFields and JsonEncode. Each answers as Polars
does, checked against its answers, except where its docs say otherwise.
Faster¶
- The Parquet reader reads ahead (214). A row group not at the head decoded three batches and waited, so most of each decoded only once it was the head, one at a time. Each now decodes ahead while what the workers hold stays under a read-ahead of a thirty-second of the memory limit, at most 256 MiB. h2o gb1 takes 28% less time, PDS-H q6 10% and q1 8%; peak memory grows by up to about 90 MB.
- A sum or mean of an integer of 32 bits or fewer adds in int64 (215), folding into its exact 128-bit sum every 2³⁰ rows and at the end, where every value was widened to 128 bits first. h2o gb4 takes 15% less time, gb5 8%.
Out of core¶
- Tail spills (217). The last rows it holds go to run files past the budget, the oldest dropped as later rows cover them, and come back in order a batch at a time. It refused.
- Hstack streams (218), pairing a batch of each frame at a time, sliced to the fewest rows any has left. It held every frame whole, and refused. Frames of different heights are still refused, after the rows paired before.
- A
WhereExistsorWhereNotExistsof a predicate alone spills (219): a keyless Semi or Anti join with a residual writes its build side to a file and reads it for each probe batch until each row is decided. It refused. - A median or quantile over a huge group spills its values (220): over a few hot keys, or over no key, past the budget its values go to sorted run files, which are merged as they gather and read back in one pass at the end. A group-by partitions keys, never the values of one, so it refused. n_unique and implode over a hot key still refuse.
- Ewm functions and a reversed cum_count stream over one huge partition (221), with no partition_by or for one key of a partition_by: an ewm carries its running state from batch to batch, and a reversed count counts down from the rows written. They refused.
Features¶
List().NUnique(),List().ArgMin()andList().ArgMax()(222), each the column aggregate over a list's elements: Uint64s, nulls skipped and NaN the largest, as the aggregates have them. A null list is null, an empty list counts 0 and has no position.List().SetUnion,SetIntersection,SetDifferenceandSetSymmetricDifference, andList().Gather(indices, nullOnOOB)(223), each taking a second List column, in Polars' order and with its equality: a null element is a value, NaN meets NaN, −0 meets +0. An Int64 list against a Float64 list is refused, as a join of the two is.List().ToStruct(names...)andStruct().RenameFields(names...)(224). A list's first elements become named fields, null where it is shorter; a null list is a null struct. Renaming must name every field, which Polars only warns about.MaxHorizontal,MinHorizontal,SumHorizontal,MeanHorizontal,AnyHorizontalandAllHorizontal(225): each row's reduction across expressions, max to mean answering as the column aggregates do over the row's values, any and all as Kleene's Or and And.Replace,ReplaceStrictandReplaceOr(226), a value map matching asIsIndoes. Replace keeps unmatched values and types as the matchingWhenchain would; ReplaceStrict refuses an unmatched value; ReplaceOr gives unmatched values and nulls a default.RollingMedian,RollingQuantile,RollingMedianByandRollingQuantileBy(227): a window's median and quantile, by rows or by time, centred or not, and streamed past the memory limit with no partition_by.- Eager
DataFramemethods (228) for fifteen lazy operations that had none:Slice,Limit,DropNulls,FillNull,FillNan,TopK,BottomK,HStack,VStack,JoinAsOf,MergeSorted,Explode,Unnest,WithRowIndexandRemove;GroupBy,GroupByDynamicandRollingstart the lazy builder. DataFrame.PivotandDataFrame.Transpose(229), eager only since their columns come from the data: a pivot in Polars' row and column order and naming, refusing a cell of two values without an aggregate; a transpose at the type every column meets at.Dt().DaysInMonth()andDt().Combine(t)(230): a month's length, and a day with a Time of day, read and written on the column's own wall clock.Struct().JsonEncode()(231): each struct as a JSON object, floats in their shortest form, NaN as null, strings escaped, temporals and Decimals as the text their String cast gives.
Known limitations¶
As v0.6.2's, except:
- Operators that do not spill, each failing under a budget with an error naming itself:
- a window, with or without a
partition_by, whose one partition is larger than the limit and that computes a rank, an interpolation, a reversed cumulative function other thancum_count, an unlimited backward fill or a rolling window by time, or that is ordered; - n_unique and implode over a few very large groups.
- What streams but holds its answer: a keyed
RollingorGroupByDynamic, which gives its answer per bucket. - Spilling a keyed window needs about 128 KiB for its partition writers, past any limit set below that.
- Speed: 1.94× Polars on PDS-H at SF=1. The widest gaps are q7 at 3.9×, q19 at 3.3×, q16 at 3.2× and q15 at 3.1×; over h2o, the joins at 1.6 to 2.0×. On this four-core laptop the queries the reader no longer waits on are bound by the CPU.
- Memory: with no limit set, h2o's
gb10peaks at 6.8 GB over Parquet and 8.2 GB over CSV; Polars at 4.7 and 5.1 GB. The read-ahead adds up to about 90 MB to a Parquet query's peak. - Pivot and Transpose are eager only, their columns coming from the data, which a lazy plan's schema cannot wait for.
v0.6.2 — 2026-10-12¶
Everything since v0.6.1 (2026-10-11): steps 201–212.
v0.6.2-scope.md set the scope: speed and
out-of-core, continued. Each step was timed against 0.6.1's code, alternated in one
session, and step 208 measured the whole.
Where it ends¶
- Against v0.6.1, both built alike and alternated over PDS-H at SF=1: 8.6% less CPU by geomean, 2.5% less wall time. q7, q21 and q4 take 23% less CPU and 11 to 12% less time, q17 19% and 10%.
- The scope's claims were q7 and q21 at least 20% faster, and PDS-H at SF=1 at least 5%: met by CPU, not by wall time. The work removed was per-row hash work that ran on every core at once.
- Against Polars, in one session, every answer validated against DuckDB's:
| suite | ursus | Polars | ratio | at v0.6.1 |
|---|---|---|---|---|
| PDS-H SF=1 | 159 ms | 77 ms | 2.07× | 2.16× |
| PDS-H SF=0.1 | 24 ms | 9 ms | 2.76× | 2.43× |
| h2o 10M, Parquet | 822 ms | 541 ms | 1.52× | 1.51× |
| h2o 10M, CSV | 1,764 ms | 1,071 ms | 1.65× | 1.70× |
At SF=0.1 ursus is as it was, 24.6 to 24.1 ms, and Polars ran faster, 10.1 to
8.7 ms.
- Out of core: reverse, an unkeyed Rolling or GroupByDynamic, and one
partition of a keyed window, each past a limit that refused or held its answer
before. Reverse passes under a tenth of its input, and the unkeyed Rolling too
at batches of 2,048: it holds a batch's worth, about 700 KB at 8,192 rows.
Faster¶
- A runtime filter tests a dense key against a bitmap (202), a bit for each value in the join keys' range, where it looked each probe row up in the join's hash tables. q8, q7, q5 and q21 take 10 to 19% less CPU.
- A join finds a dense integer key by index (203): a slot for each value in the build keys' range, holding its id, where the probe hashed every row. q17 takes 12% less CPU, q3 and q7 4% less.
- A join's dense build skips the hash tables (204). A deferred build whose integer keys are dense routes them by their place in the range, and fills that index directly, in parallel; it hashed every row into partitioned tables. q21 takes 18% less CPU and 13% less time, q4 20% and 11%.
- A group-by on one String or Binary key hashes the values themselves (205), read in place from the column, where it encoded each first. h2o gb1 takes 31% less CPU and 23% less time over Parquet.
- A short quoted CSV field is scanned once (211), where its line endings were counted and its carriage returns searched for by two more calls. h2o gb1 and gb3 over CSV take 7 to 9% less CPU and 6% less time.
Out of core¶
- Reverse spills (206). Past the budget it writes what it holds to a run file, and gives the runs back last to first, each read whole, so it holds one at a time. It refused. In memory it no longer concatenates its input before reversing it.
RollingandGroupByDynamicwith no key hand their windows out as they close (207), a batch's worth at a time, where they held their whole answer to the end. A 30-minute rolling group over 300,000 rows peaks at 0.73 MB, where it peaked at about 20 MB; over 200,000 it answers under a tenth of its input. With a key the answer is still held: it is given per bucket.- A window's one partition larger than the limit streams (209) when every
function in it needs only a bounded part of it: its rows go to one file and are
read back, as a window with no
partition_byhas since 197, and the other keys spill as before. It refused. A rank, an ewm or an ordered window over it still does.
Fixed¶
- A join's key tables are on the memory ledger while it probes (204). The account was rebased at the end of the build without them, so a join probing 200,000 keys under-reported by 6 to 10 MB.
- A spilled window keeps to its limit (210). Merging its partition files' answers, it held each file's whole answer, so the input: 971 KB under a 121 KB limit over 20,000 rows. It holds a batch across the files now.
Known limitations¶
As v0.6.1's, except:
- Operators that do not spill, each failing under a budget with an error naming itself:
- hstack and tail;
- a window, with or without a
partition_by, whose one partition is larger than the limit and that computes a rank, an ewm function, an interpolation, a reversed cumulative function, an unlimited backward fill or a rolling window by time, or that is ordered; - a join with no keys and a residual, a
WhereExistsof a predicate alone; - quantile-like aggregates over a few very large groups.
- What streams but holds its answer: a keyed
RollingorGroupByDynamic, which gives its answer per bucket. - Spilling a keyed window needs about 128 KiB for its partition writers, past any limit set below that.
- Speed: 2.07× Polars on PDS-H at SF=1. The widest gaps are q15 at 3.6×, q19 and q16 at 3.5×. A string group-by of many groups (h2o gb3) is bound by memory across the workers' tables.
- Memory: with no limit set, h2o's
gb10peaks at 5.3 to 9.1 GB, noisy by the collector's timing; Polars at 4.5 to 5.2 GB.
v0.6.1 — 2026-10-11¶
Everything since v0.6.0 (2026-10-11): steps 191–200.
v0.6.1-scope.md set the scope: speed where the
profile points, and out-of-core. Each step was timed against v0.6.0's code,
alternated in one session, and step 200 measured the whole.
Where it ends¶
- Against v0.6.0, both built alike and alternated over PDS-H at SF=1: 10.9% less wall time by geomean, 3.5% less CPU. q19 takes 45% less, q12 27%, q1 25%.
- Against Polars, in one session, every answer validated against DuckDB's:
| suite | ursus | Polars | ratio | at v0.6.0 |
|---|---|---|---|---|
| PDS-H SF=1 | 168 ms | 78 ms | 2.16× | 2.32× |
| PDS-H SF=0.1 | 25 ms | 10 ms | 2.43× | 2.56× |
| h2o 10M, Parquet | 796 ms | 528 ms | 1.51× | 1.61× |
| h2o 10M, CSV | 1,886 ms | 1,110 ms | 1.70× | 1.81× |
- Out of core: a forward or nearest
JoinAsOf, a cross join,GroupByDynamic,Rolling, and a window with nopartition_by, each past a limit that refused it before. h2o'sgb10andj5pass under 3 GB, peaking at 2.74 to 2.91 GB.
Faster¶
- The Parquet reader decodes its own pages (192) for a flat column of an integer, float or byte-array type, PLAIN or dictionary-encoded: definition levels as runs, and each dictionary string copied once, short ones as two words. q19 and q12 take about 22% less wall time, and h2o's j2, j3 and gb1 over Parquet 10 to 15% less. Every other chunk reads through arrow-go as before.
- The CSV reader cuts its blocks speculatively (199), after the first line ending past a block's size, and lets each block's parse prove the cut. A block that does not end cleanly sends the rest of the stream back to be cut exactly, as before, so answers and errors are unchanged. The splitter had been 60% of h2o gb1's read over CSV, on one goroutine; h2o over CSV takes 5 to 12% less wall time.
- A string conjunct runs once per dictionary value, in the Parquet reader (193).
l_shipmode IN ('AIR', 'AIR REG'),==,StartsWith, a regexp, any row-local expression of one String or Binary column, is evaluated on each row group's dictionary, and the rows it rejects are dropped before their strings are copied. The Filter above still decides. q19 takes 27% less wall time than at step 192, and q8, q10 and q12 3 to 8% less.
Out of core¶
- A forward or nearest
JoinAsOfstreams both sides (194), as a backward one has since step 183: it holds a row a by-key and the right rows it reads ahead to find a by-key's next one. Over a 300,000-row right side every strategy peaks under 1 MiB, where forward held the right side twice. A left by-key the right side lacks still reads that side to its end, and is charged for it. GroupByDynamicandRollingstream their input (196), where they buffered it whole: each row is given to its windows as it arrives, and a rolling group keeps only the rows a window yet to come reaches. Over 300,000 rows, a dynamic group's peak falls from 7.5 MB to 0.4 MB. A rolling group's answer is a row an input row, and it now holds that rather than its input as well: 10 MB from 17 MB.- A window with no
partition_byspills (197) when every function in it needs only a bounded part of the frame: aggregates broadcast to the rows, cumulative functions,ShiftandDiff, fills with a limit, a forward fill without one, and rolling windows of rows. Its rows go to one file and are read back a batch at a time. Rank, ewm, interpolation and an ordered window still refuse. - A cross join spills (195). Past its limit its build side goes to disk whole,
and each probe row reads it back, in the in-memory order. It refused before, and
so did
JoinWhere, a cross join under a filter. A join with no keys and a residual, aWhereExistsof a predicate alone, still refuses.
Fixed¶
- A Parquet page whose RLE run holds a value wider than its bit width is refused as corrupt (192), found by fuzzing the new decoder.
Known limitations¶
As v0.6.0's, except:
- Operators that do not spill, each failing under a budget with an error naming itself:
- reverse, hstack and tail;
- a window with no
partition_bythat computes a rank, an ewm function, an interpolation, a reversed cumulative function, an unlimited backward fill or a rolling window by time, or that is ordered; - one partition of a keyed window larger than the limit;
- a join with no keys and a residual, a
WhereExistsof a predicate alone; - quantile-like aggregates over a few very large groups.
- What streams but holds its answer:
RollingandGroupByDynamichold their windows' state, which is their answer, until the end. - What streams but may read ahead: a forward or nearest
JoinAsOfwhose left by-key the right side lacks reads the right side to its end. - Speed: 2.16× Polars on PDS-H at SF=1. The widest gaps are q7 at 3.6×, q16 and q17 at 3.4×, and q15 at 3.3×. ZSTD decompression is 17 to 48% of the widest, and has no faster pure Go decoder.
- Memory: with no limit set, h2o's
gb10peaks at 6.76 GB over CSV, Polars at 5.13 GB.
v0.6.0 — 2026-10-11¶
Everything since v0.5.0 (2026-10-09): steps 156–190.
v0.6-scope.md set the scope: speed, the features
0.5 left, and robustness. audit-0.6-midpoint.md
took stock after step 165. Each step below was timed against the one before it, and
step 189 measured the whole against v0.5.0 and against Polars.
Where it ends¶
The scope claimed ursus within 3× of Polars on PDS-H at SF=1, and at least 20% faster
than v0.5.0 on it. Both are met. Measured on 2026-10-11, at c6aa77d (step 189):
- Against Polars, in one session, with every answer validated against DuckDB's:
| suite | ursus | Polars | ratio | at v0.5.0 |
|---|---|---|---|---|
| PDS-H SF=1 | 180 ms | 78 ms | 2.32× | 3.56× |
| PDS-H SF=0.1 | 24 ms | 9 ms | 2.56× | 3.09× |
| h2o 10M, Parquet | 832 ms | 519 ms | 1.61× | 1.73× |
| h2o 10M, CSV | 2,034 ms | 1,123 ms | 1.81× | 1.97× |
| PDS-H SF=1, peak memory | 0.85 GB | 0.86 GB | 0.99× | 1.31× |
- Against v0.5.0, both built alike and alternated in one session: PDS-H SF=1 takes 27% less wall time and 32% less CPU. h2o takes about 9% less of each. Its group-bys by one integer key moved by 12 to 29%, and most of the others, by strings or several keys, did not.
- Under a 3 GB container, h2o's
gb10andj5pass over Parquet and CSV, peaking at 2.77 to 2.92 GB.
Faster¶
- A probe row that misses does no work when nothing spilled and nothing validates (158): q17 −19% CPU.
- A key of one integer column is never encoded, in a join's partitioned build and probe (159) and in a group-by, nulls included (160): q17 −29%, q7 and q16 −15%; q18 −11%; h2o's integer group-bys about −20%.
- Semi and Anti joins build in parallel, keeping their keys alone until the build ends (161): q4 and q22 about −45% wall clock.
- A join's sides are exchanged by what survives (162). The planner still chooses from its estimates, but a join whose build runs in parallel, and whose probe side turns out the smaller by half, builds from that side instead: q12 −24% CPU.
- The Parquet reader writes each value once (163): a column takes the slices it was decoded into, and reading allocates 1.2× what it produces, from 1.7×. q19 −7%.
- Kleene logic runs a word at a time, a filter's
Ands are applied one after another, andIsInon an integer column compares its values directly (164): q6 and q15 −23 to −24%, q14 −14%, q7 −11%. - What a predicate above a join implies about one side goes below it (165), with the predicate kept above: q19's part side keeps one part in a hundred, its CPU −12%.
- Runtime filters (167): an inner or semi join's built integer keys drop the rows below the joins between it and its probe key's source. q21 −56% CPU, q2 −35%, q11 −30%. A filter that keeps three quarters of what it sees retires.
FirstandLastover many groups (168): about 25 times faster over 182,000 groups, from 0.8 and 1.2 s to 34 and 45 ms.Containswith a regexp of literals joined by.*, asLIKE '%a%b%'is written, searches for the literals rather than backtracking (169): q13 −15%.- A join shares its probe batch's columns when the rows it takes from it are a run, rather than copying them (170).
MergeSortedstreams (182): a batch of each side at a time, where it held both inputs whole.- A backward
JoinAsOfstreams (183), the default: it holds a row a by-key and a few right batches, where it held the right side. Forward and nearest still hold it. ArgMinandArgMaxover many groups (176): over 200,000 groups, from 7.8 s to 0.1 s. They keep a typed key a group, asMinBydoes, not a one-row column.- The CSV reader allocates a third of what it did on several threads (188): a block's builders are sized from its counted rows, its columns take their slices, and the block is scanned in place. Reading h2o's 2M-row CSV on four threads allocates 315 MB, from 1,041 MB, for 255 MB of columns.
New¶
Struct().WithFields(exprs...)replaces the fields its expressions name, in place, and adds the rest after (171). Polars'struct.with_fields, with fields read throughFieldrather thanpl.field().Interpolate(InterpolateNearest())fills a run of nulls with the nearer value, keeping the type, andRollingCenter()labels a rolling window at its middle row (172): Polars'method="nearest"andcenter=True.- The mean and median of instants: a Datetime's or Time's, its own type; a Date's, a Datetime(us) keeping the fraction of a day; and a Duration's median (173). Each exact and truncated toward zero, as Polars answers.
- Parquet UUID and JSON columns read, as Binary and String (173).
Dt().ReplaceTimeZone(tz, opts...)keeps each value's wall clock and reads it in another zone, or none (174): Polars'dt.replace_time_zone. A clock the zone reads twice or never refuses the query by default, as Polars raises;AmbiguousEarliest,AmbiguousLatest,AmbiguousNullandNonExistentNullanswer it instead.Corr(a, b),Cov(a, b, ddof),MinBy(by)andMaxBy(by)(175): Polars'pl.corr,pl.cov,min_byandmax_by, in a group-by or a window. Corr and Cov skip a pair with a null and run on every worker; MinBy and MaxBy order by any ordered type, a tie going to the earliest row.RollingSumBy,RollingMeanBy,RollingMinBy,RollingMaxBy,RollingVarByandRollingStdBy(by, period)(177): Polars'rolling_*_by, each row's window the rows whose Date, Datetime or Timebylies withinperiodof its own, closed asWindowClosedsays.byneed not be sorted; a calendar period follows by's zone.DataFrame.Rowsdecodes a List column into a slice,[][]Tand[]*Ttoo, and a Binary one into a[]byte(178).DynamicOptions.Label,StartByandIncludeBoundaries(187), which were declared only to be refused: a window labelled by its start, its end or its first row; a grid started at the first row, or on a weekday for windows in weeks; and_lower_boundaryand_upper_boundarycolumns. Polars'label,start_byandinclude_boundaries.- A default memory budget on macOS and Windows (181): half the machine's RAM,
read from sysctl's
hw.memsizeandGlobalMemoryStatusEx, as Linux's has been.
What a user of v0.5.0 can trip on¶
- Weekly windows move to Mondays (184):
GroupByDynamicevery"1w", andTruncateandRoundby weeks, answer Polars' windows, Monday to Sunday, where they ran Thursday to Wednesday. Write"7d"for windows from the epoch. Every("7d")renders as "7d" (184), where it read "1w"; an interval renders in weeks only when it was written in weeks alone.-
EwmMean,EwmStdandEwmVarare null at a null row (184). -
MergeSortedrefuses an unsorted side when it reaches the first row that goes back (182), after emitting the rows before it, where it read both sides whole first. A streaming consumer such asCollectBatchescan see those rows. -
On macOS and Windows a query now has a default budget, half the machine's RAM, and spills, or refuses where it cannot spill, past it (181). Under it ursus also sets the Go runtime's soft memory limit to nine tenths of the RAM, as on Linux.
WithMemoryLimit(0)is unbudgeted, as before;GOMEMLIMITorLeaveProcessMemoryLimit()keeps a program's own soft limit. -
A type whose Enum category or struct field name holds a comma, a colon, a bracket, a quote or an edge space renders it quoted (179):
Enum("a, b"), where it readEnum(a, b). Ordinary names render as before. -
A Parquet file from a writer outside the known list is not pruned by null counts or by a number's bounds (179); its strings' bounds still prune.
-
A field tagged
ursus:"name"now reads that column (178). One whose tag named a column but whose own name matched another read the other before. -
Filter(a.And(b))evaluates b only on the rows a keeps (164), asFilter(a, b)always has. An error b would have raised only on rows a drops is no longer raised. - An inner join's row order may follow either input, chosen at run time by the
inputs' sizes (162). It is exchanged only where the planner already allowed it:
nothing above the join depends on its order,
JoinMaintainOrderis not set, and the left input was not sorted by the caller. Explainshows more:swap_at_runtimeon a join that may be exchanged (162), the predicates a filter above a join implies, below it (165), andRUNTIME FILTERnodes with thepublish #nof the join that fills each (167).- A deferred Semi or Anti join holds its build side's key columns until its build ends (161), where it held only the distinct keys, as other joins hold their build rows. Past half the memory budget it streams, as before.
Fixed¶
- A grid of weeks starts on a Monday, as Polars' does (184):
Truncate,RoundandGroupByDynamicwithEvery("1w")gave weeks from Thursday, the epoch's day, because "1w" was read as seven days."7d"still counts from the epoch. Found by the Polars oracle. - An ewm function answers null at a null row, as Polars' does (184), where it answered the running value, as pandas does. The rows around it are unchanged.
- A spilled join refuses only on evidence, and says what is over the limit (180). It refused at a fifth level of partitioning on the depth alone, so two keys that shared every bucket to that level were refused though the next divides them. And under a limit smaller than its own spill writers, 8 KiB each, it blamed "a single join key" for keys of two rows; it now names the writers.
- Two types that rendered alike are no longer one computation (179). An Enum's
category or a struct's field name holding a comma, a colon, a bracket or a quote
made two types render alike, and aggregates and window functions are
deduplicated by rendering:
Enum("a, b")andEnum("a", "b")casts of one column failed as an ursus bug in an aggregate, and silently shared an answer in a window. - Parquet statistics from an unknown writer (179): a null count, or a number's
bounds, now prunes only in a file whose writer always writes them (Arrow's C++, Go
and Rust writers, parquet-mr, DuckDB, Polars). Another writer's absent null count
read as zero, and
IsNullcould skip groups that held nulls. x OR trueandx AND falsekeep an error x raises (179), as Polars does: a failing strict cast in x fails the query, rather than being folded away.DataFrame.Rowsreadsursus:"name"tags again (178). The module's rename had rewritten the tag key to the module path, so since then a field matched a column only by its own name.ToUpperandToLowermap case fully, as Polars does (178):"ß"is"SS", ligatures expand, and Σ is ς at the end of a word.VarandStdof large near-equal values (176) lost about eight digits: 1e9+1, 1e9+2 and 1e9+4 gave a variance of 2.333333283662796, where it is 7/3. Each group's values are now shifted by its first, and the answer is Polars' to the last digits.- The group-by's partitioned fold routed each worker's groups on a goroutine with no panic guard, so an internal panic there ended the process rather than failing the query (166).
- The record: v0.5.0's PDS-H SF=0.1 figure, below, read DuckDB-Go's column (166).
Breaks¶
These break code written against v0.5.0, each for a better API (186). Each took or returned a type in an internal package, which no caller outside the module could name or build:
Scan(plan.Source)is gone. Scan a file or frame withScanParquet,ScanCSV,ScanParquetFrom,ScanCSVFrom,FrameorFromArrow.FromPlan(plan.Node)andLazyFrame.Plan()are gone;Explainshows a plan.FromPlanwas kept for a SQL frontend, and SQL is out by decision.DataFrame.Batch()is gone.ColumnAt(i)gives a column by position,Column[T](name)a typed one, andRecord()the whole frame as Arrow.WithOptFlagstakesOptFlags, a public struct with the same fields, where it tookplan.Flags;DefaultOptFlags()andNoOptFlags()replaceplan.DefaultFlags()andplan.NoFlags().DataFrame.Column[T]returns*Series[T], the public name of the type it returned. Code that used the result compiles unchanged.
Known limitations¶
- Object stores and SQL: none, by decision.
ScanParquetFromandScanCSVFromare the seam. - Operators that do not spill each fail under a budget with an error naming themselves:
- reverse, hstack and tail;
JoinAsOfforward and nearest, which hold the right side. Backward streams, asMergeSorteddoes;RollingandGroupByDynamic;- a window with no partition key, and one partition larger than the limit, the rolling and ewm functions included;
- a cross join;
- quantile-like aggregates over a few very large groups.
- Not built:
Pivot,MapGroups,UpsampleandRollingMap;- list set operations,
SampleandList().Eval; - a rolling median, quantile or rank, by rows or by time, and a window by an
integer
by; - Spearman's correlation;
- Arrow IPC files and NDJSON.
v0.6-scope.md's Tier 2 and Tier 3, and v0.5-scope.md's, give each one's
reason. 0.6's Tier 2, its speed items among them, moves to 0.7, as the midpoint
audit planned.
- Types: as in v0.5.0, except the mean and median of instants, which are built:
- nesting deeper than a List of a primitive or a Struct of primitives is refused
by name in Parquet;
- Array has no column representation, and CSV refuses nested columns;
- Enum is not written to Parquet or Arrow, and Duration is not written to Parquet;
- a temporal column's Quantile, Var and Std are refused;
- Cut and QCut answer a String, not Polars' Categorical.
- Open audit rows: none. Each of audit.md's is fixed or closed with a decision
(178–180).
- Where ursus differs from Polars on purpose: as in v0.5.0, and, each in the doc
of what answers it:
- MaxBy over a NaN answers the NaN's row, as Max orders NaN greatest, where
Polars skips it;
- Cov of one pair at ddof 1 is null, where Polars answers 0.0 when there were no
nulls;
- a rolling window by time that is empty, or holds only nulls, is null, where
Polars' sum answers 0.0;
- a rolling min or max by time takes a String, which Polars refuses;
- ReplaceTimeZone's policy is the whole column's, where Polars also takes one a
row;
- a dynamic group keeps its empty windows, which Polars drops;
- a weekday start is refused on a grid not in weeks, where Polars answers by no rule
its documentation states.
- Speed: ursus is slower than Polars and DuckDB. It is 2.32× Polars on PDS-H at
SF=1, within the 3× the scope set. The widest gaps are q19 at 5.8×, q7 at 4.1× and
q15 at 3.9×. h2o's group-bys by strings and by several keys are where v0.5.0 had
them.
- Memory: with no limit set, h2o's gb10 peaks at 7.77 GB over CSV and j5 at
6.87 GB, where Polars' highest is 5.18 GB. Under 3 GB both pass. PDS-H at SF=1
peaks at 0.85 GB, and Polars at 0.86.
v0.5.0 — 2026-10-09¶
Everything since v0.4.0 (2026-10-09): steps 140–155.
v0.5-scope.md set the scope: speed, and the
features users reach for first. Object stores stayed out, by decision. Its
"Where it stands" list records each step; its Tier 2 and Tier 3 say what moved to
0.6 and why.
Highlights¶
- Faster. The report, ursus and Polars re-run together on this release's code (154), puts ursus against Polars at:
- PDS-H SF=1: 3.6×, from 4.4× at v0.4.0. The scope's target was 3×, with q15 and q2 under 4×; q15 is at 4.3× and q2 at 4.9×. Not met: the Parquet reader and the join probe, where most of the rest is, were no 0.5 item's.
- About half of that move was Polars, not ursus. Polars' SF=1 geomean was 70 ms in step 127's session and 78 ms in step 154's. Against 70 ms, ursus's 276 ms is 3.9×, from its own 310 ms. On h2o over Parquet, ursus went from 914 to 947 ms, and only Polars' 475 to 547 ms moved that ratio. This reading was added on master after the tag (step 156).
- PDS-H SF=0.1: 3.1×, from 4.0×. This read 2.6× until step 166, which found it had been taken from the report's DuckDB-Go column: ursus was 29 ms.
- h2o, 10 million rows: 1.7× over Parquet, from 1.9×; 2.0× over CSV, from 3.3×.
- Peak memory at SF=1: 1.10 GB against Polars' 0.84, from 1.02.
Each step was also measured on its own queries, against the step before:
- A subtree a query uses twice runs once (143): PDS-H q15 48% faster at SF=1,
q11 42%, q2 39%, with the same peak memory.
- The CSV reader parses on every core (144): h2o gb1 40% and gb4 38%
faster over CSV at ten million rows.
- A filter copies its rows once (142): q19, q12, q7 and q15 6 to 9% faster.
- A group-by of many groups folds on every core (150): 33% faster over two
million groups.
- A join's build side is built on every core (151): q12 about 10 to 17%
faster; 22 to 30% on a join of two million build keys.
- The calendar (145): OffsetBy, Round, MonthStart, MonthEnd,
IsLeapYear and ConvertTimeZone, each reading the column in its own zone.
- Windows (148, 149, 153):
- RollingSum, RollingMean, RollingMin, RollingMax, RollingVar and
RollingStd over a fixed number of rows;
- EwmMean, EwmStd and EwmVar, and Interpolate;
- Diff, PctChange and FillNull(Mean) inside .Over(g), and any window body
built around an aggregate, such as (x - x.Mean()).Over(g).
- Everyday expressions (146, 147): ConcatStr, Struct, Str().Join,
List().Join, Cut, QCut, ValueCounts, Describe, and the six bit counts.
Behaviour changes a v0.4 user can trip on¶
Truncateby a day or a month, andGroupByDynamic's day windows, where a zone's midnight does not exist (145). Santiago's 2024-09-08 began at 01:00. It used to begin, by ursus, at 23:00 the day before, a whole day early; it now begins at 01:00, as in Polars. A wall clock that falls in a gap now reads as the instant the gap ends, and one read twice in a fold at the input's own offset, at every interval size, as sub-day intervals always did.- A large unordered group-by comes out in another order (150). Past 65,536 groups
on several threads, its rows are partition-major. The order was already unspecified
and already depended on the thread count;
MaintainOrderkeeps first appearance. SinkParquetrefuses a String that is not UTF-8 (152), naming the column. It used to write a file Polars and DuckDB refuse to read. Cast to Binary to write the bytes as they are.- A window refusal comes while the query is planned, not at
Collect:Col("x").Over(g), and an ordered window over an aggregate written inside a larger body (148). Explain refuses them too. - A failing CSV read through
CollectBatchesmay deliver fewer rows before its error (144): the good rows of the block that holds the bad record.Collectdiscards them either way. - Explain shows
CACHE #nwhere a subtree is shared (143).
API¶
Every addition is in the root package. Nothing was removed or changed its parameters since v0.4.0.
- Calendar:
DtExpr.OffsetBy,Round,MonthStart,MonthEnd,IsLeapYearandConvertTimeZone. - Windows:
Expr.RollingSum,RollingMean,RollingMin,RollingMax,RollingVar,RollingStd,EwmMean,EwmStd,EwmVarandInterpolate;WindowOption,MinSamples,Ddof,EwmAdjust,IgnoreNullsandBiased;EwmDecay, built byEwmAlpha,EwmSpan,EwmComandEwmHalfLife. - Expressions:
ConcatStrandStruct;Expr.Cut,QCutandQCutN, withCutOption,CutLabelsandCutLeftClosed;Expr.BitwiseCountOnes,BitwiseCountZeros,BitwiseLeadingOnes,BitwiseLeadingZeros,BitwiseTrailingOnesandBitwiseTrailingZeros. - Namespaces:
StrExpr.JoinandListExpr.Join. - Frames:
LazyFrame.ValueCountsandDescribe, and theirDataFramemirrors.
Everything, by step¶
Newest first. The numbers are steps, each with an as-built record in
context_files/.
- v0.5.0 is tagged (155).
gb10andj5were run once more under 3 GB first, and pass. - The report (154), ursus and Polars together on
fb7788b, every query validated.gb10andj5pass under a 3 GB container over Parquet and CSV, peaking at 2.73 to 2.86 GB. - EWM and
Interpolate(153), as ordered window functions on pandas' recurrences, which Polars' follow. Checked against pandas' answers and against a closed form. - A String that is not UTF-8 is refused by the Parquet writer (152), audit I20. Each Parquet footer read once was measured at 0.11 to 0.83 ms a file, under 0.5% of any PDS-H query, and declined.
- A join's build side is inserted partitioned, in parallel (151). The build
waits for
freeze, which inserts each partition's keys into its own table on its own goroutine, in row order, so a key's rows stay ascending. Past half the default budget it streams, the path that can spill. - A group-by of many groups folds its workers partitioned (150), with a sparse remap; the serial fold was about 60% of a two-million-group query.
- The fixed-size rolling functions (149): a monotonic deque for min and max, an exact Int128 for an integer sum, and a compensated running state for floats, recomputed every N removals so rounding cannot build up.
- The sugar inside
Over, and a window body around an aggregate (148), audit A8: an outerOver's keys are handed down to the windows inside its body. Cut,QCut,ValueCountsandDescribe(147).CutandQCutshare one kernel whose breaks are operands, soQCut's can be quantile windows.ConcatStr,Struct,List().Join,Str().Joinand the bit counts (146). ursus had no string concatenation. A family of calls whose every argument is an operand needed no new expression node.- The calendar, and a day whose midnight does not exist (145). One rule now reads every wall clock back into its zone.
- The CSV reader parses a stream on several goroutines (144). A splitter cuts whole records by the scanner's own quote rule, and hands what it does not follow to the serial path.
- A subtree a query uses at more than one place runs once (143), as a
Cacheplan node, its result held once on the budget or spilled. - A filter copies its rows once, and a temporal literal folds (142), so a date reaches the Parquet pruner.
- The profile (141): CPU profiles of the widest gaps re-ranked the speed items; reading Parquet was 29 to 57% of every PDS-H query profiled, ZSTD 13 to 29%.
- The scope (140).
Known limitations¶
- Object stores: there is no built-in client, by decision for 0.5 as for 0.4.
ScanParquetFromandScanCSVFromare the seam. - Operators that do not spill each fail under a budget with an error naming
themselves: reverse, hstack, tail,
JoinAsOfandMergeSorted;RollingandGroupByDynamic; a window with no partition key, and one partition larger than the limit, the rolling and ewm functions included; a cross join; quantile-like aggregates over a few very large groups. - Not built: SQL,
Pivot,MapGroups,Upsample, rolling windows by time,Corr,Cov,MinBy,MaxBy, list set operations,Sample,WithFields,List().Eval,ReplaceTimeZone, andInterpolate's nearest method; andRollingMap, Arrow IPC files and NDJSON, which only the README named until step 166 found the two lists differing.v0.5-scope.md's Tier 2 and Tier 3 give each one's reason. - Types: as in v0.4.0. Nesting deeper than a List of a primitive or a Struct of
primitives is refused by name in Parquet; Array has no column representation;
CSV refuses nested columns; Enum is not written to Parquet or Arrow; Duration is
not written to Parquet; temporal means, medians, quantiles and variances are
refused.
CutandQCutanswer a String, not Polars' Categorical. - Open audit rows: I23, O13, J8's remainder, S21, S26, J11 and I26, as in v0.4.0, and O12, the optimizer turning a runtime error into a result. A8 and I20 are fixed.
- The default budget is Linux's only. On macOS and Windows a query is
unbudgeted unless
WithMemoryLimitsets one, and no Go soft limit is set. Added on master after the tag (step 156). - Where ursus differs from Polars on purpose: as in v0.4.0, and:
- a NaN is a value to the ewm functions, where pandas skips it;
- an ewm variance of one value is null, where pandas answers NaN.
- Speed: ursus is slower than Polars and DuckDB: 3.6× Polars on PDS-H at SF=1, by geomean, short of the 3× the scope set. The widest gaps are q7 at 7.2×, q19 at 5.9× and q17 at 5.2×, mostly reading Parquet and probing joins.
- Memory: a shared subtree's result, a group-by's partitioned fold and a join's
partitioned build each hold more at once than before, which the budget counts:
PDS-H at SF=1 peaks at 1.10 GB, from 1.02. h2o
gb10over CSV peaks at 7.28 GB under an 8 GB cap;gb10andj5pass under 3 GB.
v0.4.0 — 2026-10-09¶
Everything since v0.3.1 (2026-10-06): steps 92–138.
v0.4-scope.md set the scope: fast where it is
slow, and safe inside a service. Its §7 records what was done, and what moved to
0.5 and why. Step 92 was also meant to be v0.3.2, which is not tagged; 0.4.0
contains it.
Highlights¶
- Faster where ursus was slowest. The full report, re-run with every engine in one session on an otherwise idle machine (127), puts ursus against Polars at:
- PDS-H SF=1: 4.4×, from 10.8× at v0.3.0, with a peak memory of 1.02 GB against Polars' 0.91 GB, where it was twice Polars';
- PDS-H SF=0.1: 4.0×, from 7.5×;
- h2o, 10 million rows: 1.9× over Parquet, from 3.3×; 3.3× over CSV, from 3.6×.
gb10over CSV, killed under the 8 GB cap at v0.3.0, passes.
Each step was also measured on its own queries, against the step before:
- Parquet row groups decode in parallel: PDS-H q6 at SF=1 is 40% faster, and
q7 31% (102).
- An inner join hashes its smaller input: q8 72% faster with a quarter of the
memory, q9 54%, q5 37% (104).
- h2o: gb8 80% faster with TopK (106); gb6 30% with median by selection
(105); j3 22% from a String concatenation that copies bytes, not rows (103).
- Comparisons are about 3.5× faster (107).
The report predates the memory work below and has not been re-run since.
- Holds under a container's memory limit (128–138). At ten million rows, h2o
j5 was killed under a 4 GB container; it and gb10 now pass under 3 GB.
- The Go soft limit is set with the default budget (128).
- Collect refuses a result too large to hold, with an error naming what streams
(130).
- A join, a group-by and Collect no longer hold their input or answer twice
while they assemble it (128, 131).
- What they allocate fell, measured under 3 GB from step 131 to step 138: gb10
38–47% less, j5 12–37% (133–138).
- Safe inside a long-running service:
- compiled regexes and is_in sets are freed with their expression (94);
- a query that fails while being planned closes what it opened (95);
- Rolling and GroupByDynamic hold a bounded working set, charged to the
budget (96);
- SetProcessMemoryLimit sets Go's soft limit near the container's (97);
- a null in a non-nullable column is caught in production (98);
- a List column past 2^31-1 elements is refused, not wrapped (99).
- New:
- TopK and BottomK aggregates (106);
- Str().Strptime and Dt().Strftime, with strftime formats (113);
- the trigonometric and hyperbolic functions (114);
- Int128 *, // and %, so Col("x").Sum().Mul(2) works (100);
- JoinCoalesce(true) on a full join (110);
- JoinAsOf and MergeSorted on every integer, float and temporal key, and
MergeSorted on String (109);
- Parquet INT96 timestamps and unannotated fixed-length byte arrays (112);
- a Null-typed column that concatenates, and writes to Parquet and CSV (108).
- Errors name what you wrote (111, 115). audit.md's list of false refusals and
misleading errors is closed, but for three rows (§ Known limitations).
Behaviour changes a v0.3 user can trip on¶
- The Go runtime's soft memory limit is set by default (128). The first query
that runs under the default budget sets it to nine tenths of the ceiling the
budget is derived from, as
SetProcessMemoryLimitdoes. The limit is the whole process's, so it changes how the program around ursus collects garbage too.GOMEMLIMITin the environment,offincluded, a limit already set withdebug.SetMemoryLimit, a limit given withWithMemoryLimit, andLeaveProcessMemoryLimiteach keep ursus from setting it. -
Collectrefuses a result too large to hold, under the default budget (130). Once the query holds more, its result and its operators' state together, than three quarters of the ceiling,Collectfails with a resource error namingcollectand pointing toCollectBatches,SinkParquetandSinkCSV, which stream. It used to grow until the kernel killed the process. Under a limit given withWithMemoryLimit, or none,Collectholds whatever it is asked to, as before. -
An inner join's row order is no longer promised to follow the left frame: the smaller input is hashed, whichever side it is.
JoinMaintainOrder(true)keeps the old order. Left, right, semi, anti and full joins are unchanged (104). - Int128 arithmetic is exact or refused.
+and-used to wrap, so Max + Max gave a plausible wrong number. A result past ±1.7e38 is now an error naming the row. Int64 arithmetic still wraps, as Polars' does (100). - A null in a column declared non-nullable is an error in production, not only in the test suite (98).
- A median or an implode over heavily overlapping rolling windows is refused under the budget, naming the operator, where it used to grow past it (96).
- A List column past 2^31-1 elements is refused, naming
CollectBatchesandSinkParquet(99). - A group-by under a limit you set is serial again, as in v0.3.0, so a spilling group-by gives the same order on every run. In v0.3.1 it was parallel until half the budget (92).
- Parquet: a file with an INT96 column opens, where it failed; a column of the NULL logical type reads as Null, not Int32 (108, 112).
- Peak memory of a Parquet scan rises by the few batches each worker decodes
ahead.
WithThreads(1)reads serially, as before (102). - Error kinds that changed, for code that tests them with
errors.Is: - a panic in a
MapNamefunction isErrValue, and a panic in theRecordReaderthatScanArrow's factory returns isErrIO; both wereErrInternal(115); - a dynamic group-by over a row at the last instant a
time.Timeholds isErrValue, notErrInternal(115); - a failed spill write is
ErrIO; it had no kind (115). - Error text changed for
FillNan,FillNull,CumSum, a Duration scaled by a fraction, a failing udf (111), a window insideAgg, an ordering over a windowed aggregate, andAny(115).
API¶
Every addition is in the root package unless named. Nothing was removed or changed its parameters since v0.3.1.
- Expressions:
Expr.TopKandBottomK;Expr.Sin,Cos,Tan,ArcSin,ArcCos,ArcTan,Sinh,Cosh,Tanh,ArcSinh,ArcCosh,ArcTanh,DegreesandRadians. - Namespaces:
StrExpr.StrptimeandDtExpr.Strftime. - Joins:
JoinMaintainOrder. - Process:
SetProcessMemoryLimitandLeaveProcessMemoryLimit. i128:Int128.AddChecked,SubCheckedandDivMod.
Everything, by step¶
Newest first. The numbers are steps, each with an as-built record in
context_files/.
- The Parquet reader keeps its values slice too, and a join's per-row arrays
double (138), from an allocation profile of h2o
j5(137). - The Parquet reader's fixed-width and List-element columns made step 136's mistake: the slice was dropped every batch, on the same comment. They allocated 2.1 times what they produced, now 1.1.
- A join's
rowKeyandcountsgrew byappend; a build of a million keys allocated 2.3 times what it held, now 1.8. - Measured after, under a 3 GB container at ten million rows,
j5allocates 6% less over Parquet and 2.5% less over CSV; CPU within the noise. - The CSV reader allocates about what it produces (136): 4.1 times it, now 1.3, counting every batch.
- Each fixed-width column dropped its values slice every batch. A comment claimed
the column wrapped it, but the column has copied it since the first commit. So
each batch's values were allocated twice, once by
append's quarter steps. - The slice is now kept, as the String builder's buffers always were. A test checks an earlier batch is intact after the later ones.
- Measured after, under a 3 GB container at ten million rows over CSV,
gb10allocates 15% less andj530% less, with 13% less CPU forj5. - The memory test's spilling join reads the heap exactly (135), at each bucket's concatenation: 14.3 MB in every run under a 16 MB budget. Its sampled reading, which it was bounded on, once read 128 MB in a gate where every package competes for the CPU.
- Accumulators grow by doubling (134). Each allocated about five times what it
held, growing a group at a time; now about twice. The memory budget now counts
each per-group array's capacity, which doubling can leave up to half unused, not
only its length. Measured after, under a 3 GB container,
gb10allocates 1.0 GB less, 5 to 6%, with its CPU within the run-to-run noise. - The key table allocates about what it holds (133). Its key arena is chunks that never move, and its per-key arrays double. A million distinct keys allocated 4.1 times what the table held, and now 1.6.
appendgrows a large slice a quarter at a time. That made the table half of everything h2ogb10allocated, in an allocation profile at ten million rows (132).- It also held the old arena beside the new while it copied. A six-key group-by's live heap while aggregating fell from 1.31 of what it counts to about 1.1.
- Measured after, under a 3 GB container at ten million rows,
gb10allocates a third less and uses 19–28% less CPU. Over Parquet its peak fell from the whole 3 GiB to 2.88 GB, under the soft limit.j5barely moved. - A spilled full or right join no longer panics on its last flush (133). Where a join both kept build rows in memory and spilled others, it released its table and then emitted the last unmatched build rows from it. Reachable since v0.1 at limits that depend on the data; a sweep of limits now covers it.
- A group-by with as many groups as rows holds its keys once while it assembles
its answer (131). It held them twice, uncounted, with its key table beside them:
517 MB to assemble a 159 MB answer, measured on two million rows of
gb10's shape. It now holds 172 MB. - The cause was found in the budget's ledger. It is keyed by pointers into the buffers it counts, so an account keeps alive what it holds, and the group-by's released its key parts only after the answer was built.
- The same kept step 130's
Collectholding its result twice under the default budget, a regression from step 128's fix; that is fixed too. Collectcounts its result under the default budget (130), and refuses one too large to hold (see the behaviour changes).- The result is counted apart from what the operators hold, so they spill as they
would if it were streamed, and
MemoryStats.Peakis unchanged. - The limit is three quarters of the ceiling, not the budget's half: at half, the
j5runs that passed under 3 GB and 4 GB containers would have been refused. - A test fails when a query holds memory its budget does not count (129). It
measures the live heap of a join, a group-by, a spilling join and two
Collects on a few million rows, in seconds, and fails if a result or a build side is held twice, as step 128 found them. - The default memory budget holds (128). Under a 4 GB container a join of two ten-million-row tables was killed: the heap ran to about twice the 2 GB budget.
- Under the default budget, the Go soft memory limit is now set as well (see the behaviour changes).
Collectcloses the query's operators before assembling the result, so a join's build side is no longer alive alongside it.- The result, and a join's build side, are concatenated column by column, each column's pieces let go once copied, instead of being held twice.
- Measured after: under a 4 GB container
j5andgb10pass over CSV and Parquet, peaking at nine tenths of the cap, as fast as under 8 GB. Under 3 GB both pass too, butgb10over Parquet reaches the cap. That is most likely its group-by holding its keys twice while it assembles its answer (130), notCollect. - The 0.4 report, re-run after the performance round (127): PDS-H SF=1 at 4.4× Polars, SF=0.1 4.0×, h2o 1.9× over Parquet and 3.3× over CSV. Step 117's report, taken while this session's reviewers were grepping, had understated ursus.
- The key table waits on memory once per chunk of keys, not once per key (126). Each slot holds a tag of its key's hash, and joins and group-bys look up and insert 64 keys at a time, reading their slots together. PDS-H q4 is 28% faster, q12, q9 and q21 about 20%; h2o gb3 13%, gb10 6%. Peak memory rises about 5%.
IsInon a String column compares strings instead of building each row's grouping key (125): its CPU in q12 fell from 1.34 s to 0.51 s. q19 is 6% faster; q12's wall time is bound by its join build and did not move.- String columns are read with one copy, not two (124): q1 −11%, h2o gb1 −14%, j4 about −10%.
- The Parquet scan and the filter are cheaper (123). PDS-H q6 at SF=1 is about 45% faster, q1 36%, q3 44% and q14 41%; h2o gb4 38%, gb1 about 28%, j1 15%.
- A column stored as Parquet stores it is read straight into its buffer.
- Validity is built only once a null arrives, so a batch with none carries no bitmap, and the kernels downstream take their fast paths.
- A filter's mask becomes a selection a word at a time;
Takebuilds no validity for a column without nulls, and gathers strings by their bytes. - Strptime and Strftime, from the same audit (122):
- A strict parse into a zoned Datetime refuses a wall-clock time the clocks skip or pass twice, naming the zone; a lenient one gives null. It returned an instant an hour away, or either of two.
- A format that names no whole date (
%a,%d/%m,%Y-%m) is refused while the query is planned, as%Zis for parsing. - Behaviour change:
%yreads 70–99 as 1970–1999, as chrono and Polars do; it read every year as 20xx. %zreads "+05:30" as well as "+0530", and a year past 9999 is written with its sign, "+10000", and reads back.- Expressions, from the same audit (121):
- A conditional's branch that fails on a row's value — Int128 past its range, a
strict cast — is evaluated again over only the rows it is taken for, so a guard
such as
When(x.Lt(limit)).Then(x.Mul(2))works. It failed on the rows the guard excluded. - Int128
NegandAbsof the minimum are refused, as0 - xis; they wrapped. TopKandBottomKrank +0.0 above -0.0, so the answer does not depend on arrival order.- Service safety, from the same audit (120):
- A CSV column a
WithSchemadeclares non-nullable, with an empty cell in the file, is a value error naming the row. Since step 98 it failed as ursus's bug. - A finished group-by, rolling or dynamic group-by drops its aggregates' state, which stayed alive, uncounted, until the query ended.
- A CSV scan whose stream fails on its header closes the stream.
- A panic while a query is planned closes what was already opened, as an error did since step 95.
- Parquet, from the same audit (119):
- A struct whose first field is Null reads again, and so does
List(Null), which PyArrow infers for a field or list that only ever holds None. Step 108 made both fail, the first as ursus's bug. - When a later file fails to open, the rows before it are delivered first, as the
serial reader always did;
Head(n)that the first file answers succeeds. - An INT96 timestamp on 1677-09-21 reads exactly; it came back in 2262.
- An integer column under BYTE_STREAM_SPLIT reads; a FIXED_LEN_BYTE_ARRAY under a delta encoding is refused by name, where it reached a decoder panic.
- Joins, from an audit of 0.4's own work (118):
- An as-of join whose keys meet at Int128 or a Decimal is refused while planned.
It planned, then failed at
Collectas ursus's bug. - A nearest as-of join on a float key no longer picks a NaN, and breaks a tie on an infinity backward, as on every other key.
- An inner join keeps its left input's order when the caller sorted it, or when an
as-of join or
MergeSortedabove needs it sorted. The build-side swap of step 104 had made such a query fail, or.Head(n)return other rows, depending on the tables' sizes. - A join on a float key is not swapped, so a merged -0.0 key stays -0.0.
- The last misleading refusals of
audit.md's list (115): - A window inside
Agggets a hint that works: move the window, not the aggregate around it.Col("x").Sum().Over(g).Max()was refused as "not an aggregate". - An ordering over
First().Over(...)no longer callsfirstorder-independent. Any's hint names.Any().- Temporal
Median,Quantile,Std,VarandMeanare documented as refused, and their refusal names the cast to ticks. - A panic in a
MapNamefunction, or in theRecordReaderthatScanArrow's factory returns, is the caller's error, not "a bug in ursus". - So is a dynamic group-by over a row at the last instant a
time.Timeholds. -
A spill write that fails says it was spilling, and to which file.
-
Trigonometric and hyperbolic functions:
Sin,Cos,Tan, their inversesArcSin,ArcCos,ArcTan, the hyperbolic six, andDegreesandRadians. They widen to a float asSqrtdoes (114). Str().Strptime(dtype, format, strict)andDt().Strftime(format): parse and format dates, datetimes and times with strftime directives (%d/%m/%Y,%b %e, %Y,%H:%M:%S%.f %z, …), as Polars does.- A format that cannot produce the type is refused while the query is planned.
- A strict parse names the value that does not match; a lenient one gives null (113).
- Parquet INT96 timestamps read, as a naive
Datetime(ns), as PyArrow reads them. Spark writes INT96 by default, and such a file failed to open. - An unannotated fixed-length byte array reads as Binary. Its schema promised
Binary and its reader refused it (
audit.mdI16) (112). - Refusals name what you wrote.
FillNan,FillNullWithandFillNullname themselves, notis_not_nan(),whenormean().CumSumnamescum_sum, notsum.- A Duration scaled by a fraction no longer quotes a factor of 2.5 you did not write.
- A failing udf names the input value it failed on (111).
- A full join merges its keys with
JoinCoalesce(true): the left key where the row has a left side, the right key where it does not. It was refused, with a hint that ursus had no coalesce expression (audit.mdJ9) (110). JoinAsOfandMergeSortedtake any integer, float or temporal key, andMergeSorteda String one.- An as-of join on a Float, Int8 or UInt64 key used to plan and then fail at
Collect. MergeSortedrefused Float64 with a hint that the key must be numeric.- What neither supports, Decimal for instance, is refused while the query is
planned (
audit.mdJ7) (109). - A Null-typed column concatenates, and writes to Parquet and CSV.
- It failed every
Collectthat concatenated batches (audit.mdJ12). - Parquet stores it as the NULL logical type, as PyArrow does, and reads that back as Null, not Int32 (I18) (108).
- Comparisons are about 3.5× faster: a literal is no longer copied to the column's length, and results are written 64 at a time (107).
TopK(k)andBottomK(k)aggregates: a group's k largest or smallest values, as a List.- Each holds k values a group, so the group-by stays parallel and bounded.
- h2o gb8 uses it now, as Polars does: 80% faster and a third of the memory (106).
medianandquantileselect instead of sorting each group. h2o gb6 is 30% faster (105).- An inner join hashes its smaller input. The inputs are swapped when the right is estimated at more than twice the left, from Parquet footers and in-memory frames.
- PDS-H at SF=1: q8 is 72% faster with a quarter of the memory, q9 54% and q5 37%.
- Behaviour change: an inner join's row order is no longer promised to
follow the left frame.
JoinMaintainOrder(true)keeps it. Left, right, semi, anti and full joins are unchanged (104). - Collecting String and Binary columns is faster: the final concatenation copies each batch's characters once, instead of building a string per row. h2o j3 is 22% faster (103).
- Parquet row groups are decoded in parallel, in file order.
- PDS-H q6 at SF=1 is 40% faster and q7 31%; every SF=0.1 query measured is 21–31% faster.
WithThreads(1)and a limit read serially, as before.- Peak memory rises by the few batches each worker holds ahead (102).
- Int128 has
*,//and%, soCol("x").Sum().Mul(2)and Int64 × UInt64 work. Every integerSumis an Int128, and only+and-were implemented. //and%floor, as Int64's do, and a zero divisor is null.- Behaviour change: Int128 arithmetic is exact or refused.
+and-used to wrap: Max + Max gave a plausible wrong number. A result past ±1.7e38 is now an error naming the row. Int64 arithmetic still wraps, as Polars' does. - Added:
Int128.AddChecked,SubCheckedandDivMod(100). - A List column past 2^31-1 elements is refused, naming
CollectBatchesandSinkParquet, as a String column past 2 GiB already was. - Its 32-bit offsets used to wrap silently. Two one-row lists of 2^30 elements concatenated to a second row spanning a negative range (99).
- A null in a column declared non-nullable is an error in production too, not
only in the test suite (
audit.mdI24). Measured: 76 ns per such column per batch (98). SetProcessMemoryLimitsets the Go runtime's soft memory limit to nine tenths of the container's or the machine's memory.- It is opt-in, because the limit is the whole process's.
- A limit already chosen, through
GOMEMLIMITordebug.SetMemoryLimit, is kept. - Under a container's limit, it keeps the Go heap's own slack inside the ceiling. The query budget bounds only what is live (97).
- A long-running service no longer leaks through the engine:
- Compiled regexes and
is_insets are freed with the expression they came from. They were cached for the life of the process, so a service building queries per request grew without bound (94). - A query that fails while being planned closes what it opened. A CSV
stream, a file or an HTTP body, was left open when a later part of the query
failed to plan: the left side of a join whose right could not open, the inputs
of a
Concatbefore a failing one (95). RollingandGroupByDynamichold a bounded working set, and what they must keep is charged to the budget.- Overlapping windows were expanded whole and uncharged: a 4,000-row rolling sum held 244 MB.
- Behaviour change: a median or an implode over heavily overlapping windows is now refused under the budget, naming the operator, where it used to grow past it.
- The expansion is now built a batch at a time, which also made the rolling sum about 30% faster (96).
- A group-by under a limit you set is serial again, so a group-by that spills gives its rows in the same order on every run, as in v0.3.0.
- In v0.3.1 it ran parallel until half of any budget, and where it switched depended on thread scheduling. The contents were always right, but the order of an unordered group-by could change from run to run.
- CI caught it:
TestGroupBySpillIsDeterministicis flaky in the v0.3.1 tag. - Parallel-then-serial now applies only under the default budget, where a switch needs half the machine or the container (92).
Known limitations¶
- Object stores: there is no built-in client, by decision for 0.4
(
v0.4-scope.md§4).ScanParquetFromandScanCSVFromare the seam, through anyio.ReaderAtor stream. - Operators that do not spill each fail under a budget with an error naming themselves:
- reverse, hstack, tail,
JoinAsOfandMergeSorted; RollingandGroupByDynamic, which since step 96 are bounded and charged, so they are refused rather than killed;- a window with no partition key, and one partition larger than the limit;
- a cross join;
- quantile-like aggregates over a few very large groups.
- Not built: SQL,
Pivot,MapGroups, common subexpression elimination,Expr.Rolling*, EWM,UpsampleandInterpolate.v0.4-scope.md§7 lists what moved to 0.5: calendar offsets and the rest of.dt,ValueCounts,Describe,Sample,Cut, the string and list joins, Arrow IPC, NDJSON, hive partitioning, and the radix group-by, among others. - Types:
- Nesting deeper than a List of a primitive, or a Struct of primitives, is refused by name in Parquet, both ways.
- Array has no column representation.
- CSV refuses nested columns.
- Enum is not written to Parquet or Arrow.
- Duration is not written to Parquet; cast it to Int64.
- The mean of a Date, Datetime or Time, and the median, quantile, variance and
standard deviation of any temporal type, are refused;
Cast(Int64)gives the ticks. - Open audit rows:
- I23: a third-party Parquet file written without null counts can be pruned
wrongly by an
IsNullfilter. - O13, unmeasured: two Enum or Struct types can render alike.
- J8, a remainder: an Int64 compared with a float literal past 2^53 meets at Float64.
- S26:
MinInt // -1wraps. - J11: a spilled join can refuse with "a single join key has more build rows than the limit" when no key has. Its cause has not been found.
- I26:
DataFrame.Rowsdoes not decode a List column. - A8:
Diff,PctChangeandFillNull(Mean)inside.Over()are refused. - Where ursus differs from Polars on purpose:
Roundrounds half away from zero, which is Polars'mode="half_away_from_zero", not its default, half to even.- UInt64
Diffis an exact Int128, where Polars gives Int64 and nulls. - The minimum Duration's
Absis refused, where Polars wraps. - Speed: ursus is slower than Polars and DuckDB: 4.4× Polars on PDS-H at SF=1, by geomean, just short of the 4× the scope set as its target. The README's table gives the rest, from the report step 127 ran.
- Memory: h2o
gb10over CSV peaked at 7.60 GB under an 8 GB cap, in step 127's report. The query budget was 4 GB; the rest is the Go heap's slack, which ursus bounds by default since step 128, after that report. - Re-measured after step 128,
gb10andj5pass under 4 GB and 3 GB caps. - Under 3 GB,
gb10over Parquet reaches the cap. A group-by with as many groups as rows held its keys twice, uncounted, while it assembled its answer: a six-key group-by of two million rows held 517 MB against the 375 MB it counted (130). Fixed in step 131, where the same assembly holds 172 MB. - Re-run under 3 GB after step 131, all four pass, and the peaks are unchanged.
The heap fills to the cap whatever the live peak:
gb10over Parquet allocated 25.6 GB in 4.7 s, and its heap in use peaked at 3.19 GB against the 1.54 GB the budget counted. -
After steps 133–138, as tagged:
gb10over Parquet peaks at 2.72 GB, under the soft limit, and allocates 16.0 GB;- over CSV, it peaks at 2.75 GB and allocates 14.9 GB;
- the heap in use still sits near the soft limit while allocation outpaces the collector.
This item was corrected on master after the tag: as tagged, it ended at the figures above it.
v0.3.1 — 2026-10-06¶
Steps 89–91: what benchmarking v0.3.0 found, fixed.
- A query is budgeted by default, so it spills under a container's limit.
- With no
WithMemoryLimit, the budget is half the smaller of the cgroup's memory limit (the least over the process's cgroup and its ancestors) and the machine's RAM, on Linux. - v0.3.0 spilled only under an explicit limit, and was killed by the kernel under a cgroup's instead: h2o gb10 over CSV, at the benchmark's 8 GB.
WithMemoryLimit(0)is unlimited, the old default. Off Linux nothing changes.- Behaviour change: an operator that cannot spill now fails at that point, with an error naming itself, rather than running into swap (90).
- A group-by stays parallel under a budget.
- It runs on several workers until the query holds half its budget, then folds them into one and finishes serially, spilling as before.
- In v0.3.0 any limit made it serial outright, which with a default budget would have made every group-by serial: about 1.5× slower on h2o's small ones, measured (91).
- PDS-H q7 is fixed: 1.55 s and 0.59 GB, against 3.9 s and 1.68 GB in v0.3.0. A temporal cast that cannot fail, such as a nanosecond Datetime narrowed to a Date, no longer keeps a filter above the joins it should be pushed into (89).
v0.3.0 — 2026-10-06¶
Everything since v0.2.0 (2026-09-04): steps 22–88, about 270 commits.
v0.3-scope.md set the scope: the types and the
I/O are honest. Most of the release is correctness work. A measured audit,
audit.md, found 78 defects, about 40 of them silent
wrong answers. Steps 70–85 fixed nearly all of them, each alongside the instrument
that would have caught it.
Highlights¶
- Nested data, end to end.
- List and Struct columns read from Parquet (steps 28–34) and Arrow (60), and write to Parquet (81). pyarrow and Polars read ursus's nested files value for value.
- A file holding a nested column ursus cannot read no longer refuses as a whole (28).
Explode(30),Unnest(35),Implode(46) andUnpivot(47).- A
.listnamespace of seventeen methods (32, 33), and.Struct().Field()(34). Str().Split,SplitNandExtractAll, which build lists (45).- Decimal you can compute with.
sumis an exact Decimal(38, s);mean,var,std,median,quantileandproductare Float64 (69).+ - *are exact and refused past 38 digits;/is the nearest Float64 (80).- Every cast between Decimal and a number is exact or refused (69).
- Decimals of different scales compare, join and concatenate exactly (80).
- Enum you can build: cast from String, strict or lossy. Sort, group-by, join, unique and windows run in category order, and CSV reads and writes it (79).
- Joins:
JoinWherefor non-equi joins (43);WhereExistsandWhereNotExists(44), which made PDS-H q21 1.4× faster with 1.75× less memory;- a parallel join probe (36);
- keys of different types meeting at a type that holds both (74).
- UDFs:
MapElementsper value andMapBatchesper column, Go functions that take part in the plan (50). - Arrow both ways:
df.Record(),df.ArrowSchema()andlf.CollectRecords()export without copying (58);ScanArrowandScanArrowRecordsimport (60). - Spilling that covers what people hit.
Uniqueand windows with a partition key spill, and keep their order exactly.- Sort, group-by and join spill frames that carry nested columns.
- Every operator that cannot spill fails with an error naming itself (82).
- Reading from anywhere:
ScanParquetFromreads Parquet through anyio.ReaderAt, andScanCSVFromCSV through any stream, each Open given the query's context (83).- A ranged GET reaches an object store, and ursus fetches only the footer and the column chunks a query needs.
- Performance:
- The CSV reader makes 126× fewer allocations, and is about 2× faster (22).
- Groups are found through an open-addressed key table (23).
- The radix sort is 2.4–4.1× faster (24).
- In-memory frames honour the batch size, so the join is 3.7× faster at eight threads (36, 37).
- CSV columns convert in parallel (42).
- PDS-H SF=1's peak memory fell from 3.98 to 2.26 GB (38–41).
Behaviour changes a v0.2 user can trip on¶
Most of these were wrong answers. A few were answers that are now refused, because the right one could not be given.
Types and casts
- A literal keeps its type:
i8 + int8(100)wraps in Int8, andi8 + int64(100)widens. The two used to collide and give one answer (72). - String → integer is exact at every width, not through a float64:
"9007199254740993"stays itself, and an out-of-range value is refused byCast(73). - To a float: a cast rounds to the nearest. 0.1 → Float32 used to be refused or null. Float32 maths returns the nearest float32 instead of null (73).
- Between integers: Int64↔Uint64 and Int128→Int64 casts are exact.
Sum().Cast(Int64)is exact above 2^53. A strict float → Int128 refuses a fraction (69). - Strings that are not numbers:
1_000and0x1p3are no longer parsed as numbers, inCast, in CSV inference, or in float schemas (77). - Datetime → Time takes the time of day. It used to reinterpret the epoch ticks (57).
- Int64 → Time refuses a value outside the day when strict, and is null when lossy, as Polars does; it used to fold 90000 seconds to 01:00 (86).
- A Duration cast to a coarser unit truncates toward zero, as Polars and
TotalSecondsdo: −1.5 s is −1 s, where it was −2 s. An instant still floors (86). - Instants outside 1678–2262: they are null in
Values([]time.Time), and refused byLitand by casts from String. They used to wrap: 2300 became 1715 (61). - Datetime → Date errors when strict and is null when lossy (49).
- Refused at plan time: Bool ↔ temporal casts (52), and
==on a List or a Struct (64). - Enum:
- Enum → number goes through its text;
- number → Enum is refused;
- an Enum and a String meet at String in Concat, joins and conditionals (79).
Arithmetic and functions
- Decimal results have new types (80):
+ -give Decimal(max integer digits + max scale + 1, max scale), so Decimal(10,2) + Decimal(10,2) is Decimal(11,2);*gives Decimal(p1+p2, s1+s2);- each is refused past 38 digits;
/returns Float64;- Decimal with a float is Float64, and Decimal with an Int128 is refused.
- Temporal arithmetic is checked, not wrapped.
- An overflowing Datetime ± Duration, Datetime − Datetime or Duration arithmetic is a KindValue refusal naming the row (61).
- Plain integer arithmetic still wraps, as it does in Polars.
- Date − Date is a Duration, and Date ± Duration a Datetime. Naive minus UTC is refused (52).
- Time ± Duration wraps around midnight (57).
AbsandNegof the minimum Duration are refused; they wrapped (86).FloorDivfloors andModtakes the divisor's sign, for signed integers, floats and Durations (54, 76). A float%follows Python, with ±Inf and the signed zero.Diffon an unsigned column widens: UInt8→Int16, UInt16→Int32, UInt32→Int64 and UInt64→Int128. It used to wrap: UInt8 3 then 1 was 254 (85).Dt().Epoch()floors, and it andTotal*are exact past 2262 (61, 76).TruncateandGroupByDynamicfloor the wall clock, not the UTC grid, which moves answers in zones like +05:30. A truncated instant outside the type is refused (76).IsInandlist.containsanswer whatEqanswers. A type==would refuse is refused at plan time (76).- String functions (76):
- a first-match regex
Replaceexpands$1and${name}; SplitN(0)is unlimited;CountMatches("")counts;- the one-sided strips trim Unicode whitespace.
- Intervals:
- an
Everythat overflows setsErr(); IntervalOfrefuses mixed signs (66);- an interval finer than its index's resolution is refused (68).
PadStart,PadEndandZFillrefuse a width whose output a String column cannot hold. It used to be a fatal out-of-memory (85).- A String column past 2 GiB of characters is refused, with
ErrResource. Its 32-bit offsets used to wrap silently, andCollectreaches that size at around 30 million rows of 100-byte strings.CollectBatchesand the sinks never build one column of all of it (87). - A group-by or join over more than 2 GiB of distinct keys groups correctly. Its key table's offsets wrapped too (87).
Aggregations and windows
- Integer
meandivides an exact sum (75). - Int128
sumis refused when the total does not fit (69). - Duration
sum,meanandcum_sumare exact (62). quantileat an exact rank is that value (75).- A variance is never negative (75).
PctChangecomputes in float (75).- A Bool
sumcounts its trues (75). - A global
GroupBy().Agg()with nothing to aggregate is one row with no columns, where it was shape (0, 0) (75). - Undeclared enum values —
Rank(99),Interpolation(99),Closed(99)— are KindValue errors. They used to crash, or quietly act as a default (72, 75). - A window reads the columns defined before it in the same
WithColumns(75). - A dynamic group-by's grid covers every row, or refuses at a named ceiling. It used to answer short (67).
- Two UDFs sharing a name are refused. They used to merge into one computation (65).
Joins (74)
- Keys of different types are cast to a type that holds both, strictly. A 64-bit integer key against a float key is refused, and so is Concat or Unpivot of such columns.
AsOfBykeys of different integer widths match now; a null by-key matches nothing.Validate1:m and 1:1 refuse a duplicate key even when it has no match.- The as-of tolerance compares in the key's own unit (63).
I/O
- Several Parquet or CSV files are matched by column name. A file that differs is refused, naming both. Before, a different column order swapped values silently (71).
- Row groups are pruned only on what their statistics prove, so some files may read more row groups (71).
- CSV (77):
- an unquoted empty field in a String column is null, and a quoted
""is the empty string; the writer quotes every empty string; - floats are written so they read back as floats (
1.0,inf,NaN); - an integer past Int64 infers as Int128;
- Int128 and Decimal read exactly;
- an offset with seconds is written in UTC;
- a blank line in a one-column file is a null;
- the writers refuse a frame with rows and no columns.
- Parquet:
- lists of every element type ursus writes are read (72, 81);
WithCompression(Lz4)is refused (useLz4Raw);- a corrupt file is
ErrIOrather than a crash or a hang (72). - A file that fails to open or read is named in the error (83).
The optimizer (70, 78)
- No rule changes an answer any more; an on/off differential over 1629 generated queries agrees.
- A filter that can fail — a strict cast, a UDF, temporal arithmetic — is no longer moved ahead of a guard, so such a query may read more rows.
- The planner refuses what
Collectwould refuse, so some errors arrive at plan time.
Memory limits (82)
- A window and an as-of join are charged for the second copy they make.
- A window now spills at about half the input it used to.
- An as-of join, or a window with no partition key, now refuses at about half its old size.
- A group-by is charged for its key table, so it may freeze sooner. Under a limit, the row order of an unordered group-by moves, as documented.
Errors and testing
- A panic never kills the host (72).
- A panicking UDF is the caller's
ErrValue. - A corrupt Parquet file, or a failing
ScanArrowfactory, isErrIO. - A UDF that calls
runtime.Goexit— ast.FailNowdoes — is an error. It used to end the stream early, silently (85). ursustest.AssertFrameEqualnow compares List and Struct values, and Float32 under a tolerance. Tests that passed may now fail: before, any two nested frames compared equal (60).
API¶
Every addition is in the root package unless named.
- Frames:
DataFrameandLazyFramegainJoinWhere,WhereExists,WhereNotExistsandUnpivot(withUnpivotOptions).LazyFramealso gainsExplode,UnnestandCollectRecords.DataFramealso gainsRecordandArrowSchema.- Expressions:
Expr.Explode,Expr.Implode,Expr.Unnest,Expr.MapElementsandExpr.MapBatches. - Namespaces:
Expr.Listreturns aListExpr, withLen,Get,First,Last,Contains,Min,Max,Sum,Mean,Reverse,Head,Tail,Slice,Sort,SortDesc,UniqueandDropNulls.Expr.Structreturns aStructExpr, withField.StrExprgainsSplit,SplitN,ExtractAll,PadStart,PadEnd,ZFill,StripCharsStart,StripCharsEndandEscapeRegex.- Sources:
ScanArrow,ScanArrowRecords,ScanParquetFromwithParquetFile, andScanCSVFromwithCSVFile. dtype:- Added:
MaxDecimalPrecision,ValidDecimal,DecimalDigits,DecimalMeet,PromoteExact,ExactMismatch,MeetHint,ParseFloat,TicksPerDayandDataType.FromTimeBound. - Removed:
DaysIntervalandMonthsInterval, which built intervals nothing validated (66). UseIntervalOf. - Changed behaviour:
Every,IntervalOfandFromDurationvalidate. i128:- Added:
ParseDecimal,Int128.MulChecked,Int128.DivUint64andInt128.Float32. - Changed behaviour:
Parsechecks its range, andFloat64rounds correctly. - Nothing else was removed or changed its parameters. Some methods gained named results, which callers cannot see.
Known limitations¶
- Object stores: there is no built-in client. S3, GCS and Azure each want a
vendor SDK, and ursus is pure Go with one dependency.
ScanParquetFromandScanCSVFromare the seam. The stores were planned for 0.4, and have since been deferred past it (v0.4-scope.md§4). - Operators that do not spill each fail under
WithMemoryLimitwith an error naming themselves: - reverse, hstack, tail,
JoinAsOf,MergeSorted,RollingandGroupByDynamic; - a window with no partition key, and one partition larger than the limit;
- a cross join;
- quantile-like aggregates over a few very large groups.
- Not built: SQL,
Pivot,MapGroups, common subexpression elimination,Expr.Rolling*,Upsample,Interpolate, and the trigonometric functions. - Types:
- Nesting deeper than a List of a primitive, or a Struct of primitives, is refused by name in Parquet, both ways.
- Array has no column representation.
- CSV refuses nested columns.
- Enum is not written to Parquet or Arrow.
- Duration is not written to Parquet; cast it to Int64.
- Still-open audit rows that can answer wrongly, each recorded in
audit.md: - I23: a third-party Parquet file written without null counts can be pruned
wrongly by an
IsNullfilter. - O13, unmeasured: two Enum or Struct types can render alike.
- J8, a remainder: an Int64 compared with a float literal past 2^53 meets at Float64.
- S26:
MinInt // -1wraps. - I24: a null in a non-null column is checked only in test builds.
- Where ursus differs from Polars on purpose:
Roundrounds half away from zero, which is Polars'mode="half_away_from_zero", not its default, half to even.- UInt64
Diffis an exact Int128, where Polars gives Int64 and nulls. - The minimum Duration's
Absis refused, where Polars wraps. - Speed: ursus is slower than Polars and DuckDB, and the README's table, measured at v0.3.0, says by how much.
- PDS-H q7 regressed to 3.9 s from 2.4 s, because its date filter is no longer pushed below its joins. Fixed in v0.3.1.
- ursus spills only under an explicit
WithMemoryLimit. Under a cgroup's limit it can be killed instead: h2o gb10 over CSV was, at 8 GB. Fixed in v0.3.1, which budgets every query by default.
v0.2.0 — 2026-09-04¶
The module renamed to github.com/advenn/ursus, and everything before step 22: the
engine, Parquet and CSV, the optimizer, spilling sort, group-by and join, and
parallel execution.