Column-oriented data frames: load, select, filter, sort, group, join, and time handling.
Import with use frame. Column-oriented DataFrames for tabular data. See docs/frame-module.md for design details.
Column references: strings for names, expressions for computation. Naming a column is a string; computing on a column is a normal Crow expression over df.col. Every line is plain Crow — there is no special evaluation inside frame calls.
| Function | Description | Example |
|---|---|---|
new[], new[col = values, ...] | The empty frame (frame.new[], since new reads best qualified), or a frame written down: each named argument a column, in order — frame.new[sym = ['AAPL', 'MSFT'], qty = [100, 50.5]] — its values a list, a tensor or one value repeated, as with takes them (it is frame.new[sym = …, qty = …], typed column by column; until 2026-10-09 only that chain could write a frame down) | Frames: use frame · Project: Analyzing Data · Reactive State and Events · Charts and Styling · The Element Vocabulary |
with[frame, col, values] | A frame with one more (or replaced) column: values is an array, a tensor, or a scalar repeated for every row; the length must match the rows (error-value otherwise). Ints held as a tensor ([1, 2]) make a column of ints | Frames: use frame · Project: Analyzing Data · Reactive State and Events · Charts and Styling · The Element Vocabulary |
load[path] | Load a table → frame, in the format its extension names — CSV, JSON records (columns in name order), Parquet — from a file, a URL or an S3 object (error-value on failure; Files, URLs and S3). A CSV column of whole numbers is ints, of numbers floats, of true/false (or True/FALSE, as pandas writes them) bools, of ISO 8601 moments a moment column, anything else text; an empty cell is missing; a cell in quotes may hold a comma or a quote written twice (RFC 4180), though not a line break | Databases: use db · Frames: use frame · Web: responses and submodules · Project: Analyzing Data · Reactive State and Events |
save[frame, path] | Write frame → a file or an S3 object, as CSV, JSON records or Parquet by its extension (CSV otherwise); a text cell holding a comma, a quote or a line break is written in quotes, as load reads it; a failure is an error value of kind 'frame', as load's is (compiled, it had stopped the program) | Frames: use frame · Project: Analyzing Data · Reactive State and Events · The Element Vocabulary |
select[frame, cols] | Pick columns: select[df, ['name', 'age']] | Frames: use frame · Project: Analyzing Data |
filter[frame, mask] | Keep rows where mask is true: filter[df, df.age gt 50] | Frames: use frame · Project: Analyzing Data · Views and Live Updates |
sort[frame, col], sort[frame, [cols]] | Sort ascending: sort[df, 'age']. Descending: sort[df, 'age', desc = true]. By several keys, sort[df, ['symbol', 'qty']]: by the first, ties by the next; desc = true turns every key, desc = [false, true] one each. Stable — rows whose cells are equal keep their order — with a missing cell, or a float that is not a number, last either way | Arrays · Frames: use frame · Math: reductions and linear algebra · Project: Analyzing Data · Views and Live Updates |
group[frame, col, aggs...] | Group + aggregate: group[df, 'dept', ['salary', 'mean'], ['age', 'max']]; by several keys, group[df, ['dept', 'city'], ['salary', 'mean']] — one row per combination of the keys' cells, in the order first seen, each key column as it was (a row with a missing key cell is in no group). Each aggregate is named col_how and is in the kind it has: count the present cells, an int; sum an int over ints or bools and a float over floats, 0 where a group has no present cell; mean a float; min and max a cell of the column's own kind (text by character code, moments by their instant), missing where a group has no present cell. A missing cell is skipped, as SQL's aggregates skip NULL; a sum or mean over text stops the program | Frames: use frame · Project: Analyzing Data · The Database Layer · Views and Live Updates |
pivot[frame, rows, cols, values] | The long frame made wide: a row per cell of rows, a column per cell of cols — named by the cell's text, in ascending order — holding the values cell of the row that has the pair, and a missing cell where no row does. A pair two rows share stops the program: aggregate with group first | Frames: use frame · Project: Analyzing Data |
window[frame, col, how, n = 0, by = [], span = 0, on = ''] | A column computed over each row's partition — the rows sharing the by columns' cells (one name or a list; the whole frame without) — in the frame's order: an aggregate (every word group takes) over the trailing n rows, over every row so far when n is 0, or, with span = seconds and on = column, over the rows whose on cell lies within the span before the row's own (a column of moments, or of numbers in the span's units; the partition in ascending order of it, or the program stops: sort first) — (t - span, t], as pandas and polars close a time window; a sum, min or max over ints is an int, a mean, median, std, var or percentile a float, a count or distinct an int, first and last the column's own cells; 'lag' and 'lead' the cell n rows back or ahead, missing where there is none; 'rank' the cell's rank in its partition, ascending, equal cells sharing a rank and the next rank skipping (none of the three takes a span). Add it to the frame with with: with[df, 'avg3', window[df, 'close', 'mean', n = 3, by = 'symbol']], with[df, 'vwap_5m', window[df, 'px', 'mean', span = 300, on = 'at', by = 'symbol']] | Frames: use frame · Project: Analyzing Data |
join[f1, f2, key], join[f1, f2, [keys]] | Join on a key column, or on several: join[df1, df2, 'id'], join[df1, df2, ['symbol', 'day']]. Its kind: join[df1, df2, 'id', 'left'] — 'inner' (the default), 'left', 'right', 'outer'; 'semi' the left's rows that have a match and 'anti' those that have none, the left's columns alone; 'asof' each left row with the right row whose last key is the latest at or before the left's, the other keys matching exactly — a column of moments, or of numbers — missing where there is none (a left join by time; the right's clock is the frame's own order, sorted as needed) | Frames: use frame · Strings · Strings and Interpolation |
stack[f1, f2] | Stack frames vertically | Frames: use frame · Math: tensors · Views: the whole element vocabulary · View Blocks · Reactive State and Events · Slow Work and the Live Loop · Testing a Live App · Charts and Styling · The Element Vocabulary · Derivations |
head[frame, n] / tail[frame, n] | First/last n rows (returns a frame) | Frames: use frame · The standard library's layer: use sys · Views: the whole element vocabulary |
fill[frame, col, value], fill[frame, col, how = 'forward' | 'backward'] | The column's missing cells given value, of the column's kind; or each given the nearest present cell before it ('forward') or after it ('backward'), one with none staying missing | Frames: use frame |
unique[frame, col], unique[frame, [cols]] | The column's distinct present cells, first seen first, in the column's own kind (an int column's are ints): unique[df, 'symbol']. With a list of columns, a frame: the first row of each distinct combination of their cells, every column as it was, a missing cell a value of its own | Frames: use frame · Math: reductions and linear algebra |
rename[frame, old, new] | Rename a column: rename[df, 'salary', 'pay'] | Frames: use frame |
unpivot[frame, keys, cols] | The wide frame made long, the reverse of pivot: a row per key row and column, the key columns (one name or a list, [] for none) repeated, variable the column's name and value its cell — the columns' cells one kind, ints beside floats floats | Frames: use frame · Project: Analyzing Data |
complete[frame], complete[frame, cols] | The rows whose every cell is present, or every cell of the named columns (one name or a list): filter by .ok over all of them | Frames: use frame · Project: Analyzing Data |
sample[frame, n] | n of the rows, drawn without replacement by the seeded generator (see Random Numbers), kept in the frame's order; n beyond the rows stops the program | Frames: use frame |
where[mask, a, b] | A column that takes a's cell where the mask is true and b's elsewhere — each a column, a list or one value for every row, both of one kind (ints beside floats floats); a missing cell stays missing. (math.where is the same word over tensors.) | Frames: use frame · Math: reductions and linear algebra · Math: tensors · Project: Analyzing Data |
bucket[column, seconds] | Each moment's bucket start (see Moments, bucket[t, seconds]), a column of moments: group[with[df, 'hour', bucket[df.at, 3600]], ['symbol', 'hour'], ['px', 'first'], ['px', 'max'], ['px', 'min'], ['px', 'last'], ['qty', 'sum']] is the hourly bars | Frames: use frame · Project: Analyzing Data |
stats[frame] | Each numeric column's count, mean, population standard deviation, min, lower quartile, median, upper quartile and max, as text | Frames: use frame |
A datetime column holds moments (see Moments): with makes one from a list of moments or one repeated, fill fills one with a moment, a cell read one at a time is a moment, a mask compares the column with a moment, and load reads one from ISO 8601 text and save writes it back as such.
Properties: .shape, .columns, .dtypes. Column access: df.age. len[df] returns the row count. Aggregation names for group and window: 'sum', 'mean', 'min', 'max', 'count', 'median', 'std', 'var', 'first', 'last', 'distinct' and a percentile 'p1' to 'p99' ('p90'). A median and a percentile are interpolated between the two cells they fall between (as pandas, numpy and Julia interpolate), a float; std and var the population's (as stats and std of a tensor answer), floats; first and last the first and the last present cell, in the column's kind; distinct the count of distinct present cells, an int. Join kinds: 'inner' (the default), 'left', 'right', 'outer', 'semi', 'anti', 'asof'.
Text, casts and a moment's fields over a column. lower, upper, trim, replace and sub of a frame's column answer a column of text, each present cell as the text function answers it, a missing cell staying missing (a sub pattern that is none stops the program: a cell cannot carry the reason); contains, starts and ends of a column answer a mask, a missing cell false. int, float, str and moment of a column convert every cell as the one-value form does — text read as a number or a moment, a number as seconds, a moment as its seconds, anything as the text it shows — a cell that cannot be converted missing, which the column's .ok says (int[df.price] cuts toward zero; str[df.day] is text that load would read back as ints). df.at.hour, .year, .month, .day, .minute, .second, .weekday and .offset of a column of moments are a column of ints, each read in its own zone. (len[df.col] stays the column's length: a cell's length is len[df.col[i]].)
A column the frame lacks, an aggregation or a join it does not know, a mask of another length than the rows, a negative count for head or tail, a fill value of another kind than the column's (an int fills a float column), and a count of arguments the function does not take stop the program with the reason (see When a Program Stops), in both modes, as df.col of a missing column does. A column copied with with keeps its missing cells.
Several keys, pivots and windows. group[df, ['symbol', 'day'], ['qty', 'sum']] has a row per symbol and day, symbol and day as they were and qty_sum an int, as qty is. pivot[df, 'symbol', 'day', 'qty'] lays the days out as columns, one row per symbol, with qty in the cells — the columns are the days' text ('1', '2'), so they show as the program runs and their cells are read as a loaded file's are, with int[…], float[…] or str[…]; a symbol and day two rows share stops the program (pivot: 'b' and '1' appear 2 times — one row per pair: aggregate first, group[frame, ['sym', 'day'], ['qty', 'mean']]). window[df, 'qty', 'sum', by = 'symbol'] is each row's running total within its symbol, window[df, 'px', 'lag', n = 1, by = 'symbol'] the symbol's previous price, missing on its first row, and window[df, 'px', 'rank'] each price's rank over the whole frame; the trailing forms (n = 3) take the rows there are at a partition's start. A window's cells are typed by what it computes — a sum, min or max in the column's kind, a float for mean, an int for count and rank, the column's own cells for lag and lead, which can be missing — and a column a window or a pivot names that the frame lacks is refused before the program runs, as a key is. A column handed to sum, mean, min or max holds numbers, and a missing cell stops the program as it does where numbers are taken. The same in both modes.
Design Note: pivot reshapes, group aggregates. Considered: pivot[df, rows, cols, values, how = 'sum'], a pivot table that aggregates the rows sharing a pair, as a spreadsheet's does and pandas's pivot_table. Decided (2026-10-02, with the frame work): one way to aggregate — group — and one way to reshape; a pair two rows share stops the program, naming the group that aggregates it. Rows and columns come in ascending order of their cells, where group keeps the order first seen: a pivot is read as a table, and its days and symbols are looked for by their order.
Design Note: percentiles are words, keys are lists, and the other names are the old ones. Considered, for a percentile, ['px', 'quantile', 0.9] — a list holds one kind, and 'quantile 0.9' as text — a name with a space in it. Decided: 'p90', one word for the 90th percentile, as a column is named by it (px_p90); 'median' is 'p50' by its own name. Considered, for a join on several keys with an as-of clock, pandas's and polars's by = beside on =. Decided: one list of keys, the last the clock — join[bars, signals, ['symbol', 'at'], 'asof'] reads as the match it is. Considered, for a conditional column, a new word (pick, choose); decided: where, math's word for the same operation over tensors — one name, the kinds picking the row. Considered, for the reverse of pivot, pandas's melt and polars's unpivot; decided: unpivot, naming its two new columns variable and value as both do, so a program ported from either keeps its names. Considered, for rows without a missing cell, dropna; decided: complete, the word the masks section already used for them. A bucket's edge is the local midnight of the moment's own offset (a day in New York starts at 04:00Z), where pandas floors a zone-aware moment in UTC: a bar is drawn on the exchange's clock, and a moment carries the offset it was written in for this. A fill by direction is fill[df, col, how = 'forward'], since fill[df, col, 'forward'] would be the text in a column of text. A text function over a column keeps its name, as the arithmetic operators keep theirs; len of a column stays its row count.
Design Note: the frame module is held to pandas. tests/parity/185_frames_held_to_pandas.co computes 59 cases over four tables (tests/frames/data/) and compares each with what pandas 3.0 answered (tests/frames/expected/, written once by tests/frames/reference.py): loading and its kinds, select, six filters, five sorts, head and tail, four groups, a pivot, running, trailing, shifted and ranked windows, the four joins, stack, three fills, unique, rename, a computed column, reductions, moments moved between offsets, and quoted cells read and written back — every column, kind and cell the same, in both modes (2026-10-06); and, since 2026-10-07, the other aggregates (a median, percentiles, the deviation, first and last, distinct), windows of them trailing and running and over a span of six hours, sorts on two and three keys, joins on two keys of every kind, distinct rows, an as-of join, six-hour buckets and the bars over them, text and a moment's fields over a column, an unpivot, fills by direction, complete rows, a conditional column and casts. What that round found: pandas's rolling kernels compile to fused multiply-adds, so a deviation or a percentile agreed to twelve digits and not the last — the runtime's mul_add now computes the same rounding, and a window whose squared sum collapsed is computed afresh as pandas's is. What it found and changed: a CSV column of true/false had loaded as text (so save then load lost the kind) and a quoted cell had been split at its comma; the sort was unstable and a missing int cell sorted as 0 — now stable, missing cells last either way, as pandas sorts them (polars puts them first, Postgres first or last by direction: one rule was needed, and the one that keeps the present cells together was taken); every aggregate of group was a float and a sum over text silently 0; a window's sum over ints was a float; unique answered text for every column, and gave up scanning after a long stretch without a new cell; a right or outer join's unmatched row had lost its key; a sum over floats differed from pandas and numpy in the last digit — a group or window total is now compensated as pandas keeps one (Kahan, with pandas's separate compensations for the cells added and taken off, and its exact answer for a window whose cells are all one value), and sum[t] adds as numpy does, pairwise; and, in the compiled program alone, a moment cell read one at a time was seconds where the interpreter read its text, a field of a moment that can fail (tz's answer) did not build, [1, 2] given to with made a column of floats, a list a function started empty and pushed onto did not build, and a list of a loaded file's cells, read one at a time, did not join. And in crow test alone, window[…, by = …] and request[…, timeout = …] were refused in every test block: the runner desugared the program twice, and the options filled in once were too many arguments the second time. Where pandas has a choice, the spec's is taken, and the script says so: groups in the order first seen (sort=False), a trailing window over the rows there are (min_periods=1), rank sharing a rank between equal cells (method='min'), the population deviation (ddof=0), a right frame's duplicate column named _right, and a right or outer join compared sorted, since pandas orders those rows by the right frame or by key where Crow keeps the left frame's order and appends the unmatched. Not compared: stats, a display, and fill of a moment column, which neither side needed.
Design Note: a window is a frame's, an indicator a series's. Considered: leaving trailing aggregates to the market package's indicators (sma[bars.c, 3] is a trailing mean over one series), so that a frame has no second way to a moving average. Decided: window computes within a frame's partitions, in its order — one column holding a series per symbol, each windowed on its own — where an indicator takes one series; over one series, sma[df.close, 3] and window[df, 'close', 'mean', n = 3] agree. A trailing window's first rows are the rows there are, as SQL's window frames give them (pandas leaves them empty until the window fills).
A frame's type is its columns. A frame built in the program (frame.new[], with given a column's name), a query a database described (see db, Typed cells), and a frame derived from either through select, filter, sort, head, tail, fill, stack, join, rename, group or pivot carries its columns in its type, each with its cells' type (a column with copies keeps its cells' type; a pivot's rows column, and its others as they show). A column named by a variable (with[df, col, xs]) leaves the columns the type knows and makes the frame open: a column it cannot name may be there. A reassignment binds the frame anew: after df = with[df, 'x', xs], df has x from that line on, and before it the frame it was. Where branches, or a loop's rounds, meet, a frame has the columns they all give it; a column only some add may be there, and is read as the program runs. A function handed frames of different columns is a copy per set of columns, so it reads the columns each call hands it. Where a frame's columns are all known, a column it lacks — read as df.x, named where select, sort, fill, rename, group or join takes a column, or named by a table (key, mark, delta, hide) or a chart (candles, lines, bars_y) — is refused before the program runs, in both modes, with the columns the frame has (frame has no property or column 'cost' — its columns are 'price', and, read in a function, the parameter the frame was handed as); the stop above is for a frame whose columns show only as it runs (a file load reads then, a query no database described, a column a variable names).
A frame loaded from a file has the columns the program reads. frame.load cannot see a file's columns before the program runs, and neither can the program state them: the reads of the frame's cells say what they are. A cell walked and handed to upper, or to a function that reads it as text, is text; one met as a number — compared with one, added to one, halved — is a number, held as floats whichever the file wrote, so q gt 20 holds for a file's 20.5 and q / 2 of its 7 is 3.5, as a variable that may hold a float divides (§1); one handed to what takes nothing but an int — a position, xs[q], or head, repeat, slice — is an int; one decided on is a bool; one met beside a moment is a moment. A function the cell is handed to says what its own body reads the parameter as. The frame is held to that shape where it is bound, before any cell is read: a column missing, or holding another kind — numbers where the program reads text, text where it reads numbers, floats where a position reads ints — stops the program there with the column, what it holds, what the program reads and the read that asked (column 'slot' holds floats where the program reads ints (a position in labels at line 12)), the same words in both modes. A column of whole numbers the program reads as numbers is widened to floats at the load, exactly: a whole number beyond 2^53 stops the load there rather than lose digits (read it with int[…] where it is compared). From then on the frame's cells are typed, in both modes, and nothing is read first. A column no read says anything of stays as it comes, a parsed value read as its use implies where it is used (see JSON). A column read as two kinds — text here, a number there — is refused before the program runs, naming both reads. A page's data source (fetch[url], poll[url, seconds], ui) is such a frame too: its reads say its columns, the fact carries them to the session, and the answer is held to them where it arrives — its records a frame, a column of whole numbers read as numbers floats, an answer of no records the table with no rows yet — before the page shows; what is not what the page reads (no answer, no JSON, a column missing or of another kind) stops the session there, told to the reader as a handler's stop is, the reason in the server's log with the column, what it holds, what the program reads and the read that asked — the same in both modes.
fn label[t]
return upper[t]
df = frame.load['orders.csv'] --- sym is text, qty a number: the reads below say so
labels = for t in df.sym
label[t]
for q in df.qty
if q gt 20
log[q]
Design Note: a loaded frame's columns are what the program reads, not what it writes. Considered: the program stating a column's type (columns = map[sym = 'str'], in the words type answers) or showing it by a sample value (map[sym = '', qty = 0]). Rejected: each is a second way of saying what a value already says, and the sample asks the programmer to invent one. Considered: reading every cell first, as a parsed value's (the way until 2026-10-08). Rejected: the program did nothing wrong, and the compiled program had not held a function handed such a cell. Decided: the reads say the columns, the file is held to them where it is loaded, and a column no read types stays as it comes. Considered, and built for a day: a cell compared with an int held as ints, so a file's 20.5 stopped the load where the program wrote q gt 20. Rejected (Vijay, 2026-10-08: "q gt 20 when q = 20.5 should work. as a human being, it is obvious ... you are comparing two numbers. whether it is a float or int, it does not matter"): the program says a number, which kind the file's are is the file's business, and a program's meaning must not turn on whether the file wrote 7 or 7.0 — the language's own rule, that a variable which may hold a float is a float, says what the column is. Only a use that takes nothing but an int asks the file for ints, and the stop names that use. A page's data source is held the same way where its answer arrives, in the session both modes share (2026-10-08: the interpreter had answered an empty map and registered no source at all, and the compiled program had turned the records into a frame of its own at every render).
Design Note: a frame's reassignment is a new binding. Considered: one type per variable, as every other variable has, the frame's columns joined over its assignments — which loses exactly the columns a program adds (df = with[df, 'x', xs], then df.x); and a name per shape (df2 = with[df, …]), which asks the program to rename what it changes, and makes a loop that adds columns impossible to write. Decided (Vijay, 2026-09-29): a frame variable's type is its last assignment's — a new binding where its columns change, as a Rust let shadows one — and where ways through meet it has the columns they share.
Design Note: a frame operation's misuse stops the program. Considered: error values of kind 'frame', which the table of kinds listed for filter, sort, group and join, and which the interpreter answered. Rejected: the operations are typed as giving a frame, which cannot fail, and the compiled program stopped — so a program that checked .ok worked in one mode only — and the same column the frame lacks, read as df.col, stops the program. A misuse is the program's mistake, not something it reads: an error value would carry it to wherever a decision first met it. Reading a file, load, and values from elsewhere, with, stay error values.
Filtering uses ordinary Crow expressions that evaluate to boolean arrays. These operations are vectorized (element-wise):
eq ne gt lt gte lte — column vs scalar → boolean array (the scalar may come first: 50 lt df.age)'late' in df.note, starts[df.symbol, 'A'], ends[df.note, 'k'] — a text column tested cell by cell → boolean array, a missing cell falseand / or — two boolean arrays → boolean arraynot — boolean array → boolean arrayuse frame
df = load['people.csv']
old = filter[df, df.age gt 50]
alices = filter[df, df.name eq 'alice']
rich_old = filter[df, df.age gt 50 and df.salary gt 100000]
others = filter[df, not (df.name eq 'alice')]
Each cell is compared with the value as two values compare (see Comparison): numbers by value, exactly — NaN holds only ne — text by its characters and ordered by character code, bools equal or not, moments by instant. A value of another kind than the cells — df.name eq 2 of a column of text — and an order between kinds with none — gt on a column of bools — are refused where the column's type is known, and stop the program where it shows only as it runs (a file load reads). Missing values compare as false for every operator, ne included — they are excluded by any filter, and not (df.age gt 50) is what keeps them. A list compared with one value is a mask the same way. (Compiled, a missing or NaN cell had been false for ne, eq had been nearness within 2⁻⁵², a column of text had been parsed as numbers and a column of bools had compared as nothing; the interpreter had counted a missing cell as ne and stopped on an order of text; a list of values that can fail, compared with one value, had not built.)
A filter's predicate is computed in one pass. filter[df, df.age gt 50 and df.name ne 'bob'] — a predicate that compares the frame's own columns with values that only read, asks a column's .ok, and combines those with and, or and not — reads no column into a list and makes no mask per comparison: the rows are decided block by block, across the cores for a large frame, each cell as its comparison decides it. The rows kept are the mask's, and a column the frame lacks or an order between kinds with none stops with the operator's words at the first comparison that meets it. The same in both modes.
A column's .ok is its rows' validity — true where the cell is present, false where it is missing — so filter[df, df.age.ok] keeps the complete rows. (Every other value's .ok is its own status: a list that did not fail is ok.) A frame shows a missing cell as missing, and a column shown as a list shows it as the error it is read as, error: missing; a NaN a number column holds is a value, and shows as nan (until 2026-10-08 a missing cell had shown as nan too, in a text column as well). Read one at a time (df.age[3]), a cell of a column that can have missing cells is fallible: a missing one is an error value of reason missing, which computes and cannot be decided on (§10), so .ok guards it and a guard narrows it to its value. The columns that can are a database column that can hold NULL (see db, Typed cells) and the side of a join a row can have no match on — a left join's right columns, a right join's left ones, all of an outer join's. fill[df, 'age', 0] gives a column a value where it had none, and its cells are plain again, as are a column's that cannot have missing cells. A mask decides every row, so a list of values that can fail — built from such cells — is refused as one, in both modes: fill the column first, or guard each value with .ok. Handed whole to a function that takes numbers — mean[df.age], median, sma[bars.c, 3] — or used in arithmetic — df.price * 2, df.a + df.b — a column is its numbers, as a list of numbers is, and the answer is a tensor; a missing cell stops the program (row 3 of the column is missing — fill the column first: fill[frame, 'column', value]), and so does a column of text or of bools, in both modes. (Compiled, a missing cell had been read as 0 and a column of text parsed, where the interpreter stopped — or, for df.a + df.b, refused the sum.) A comparison is a mask, as above. A column copied with with keeps its kind and its missing cells, even when every cell is missing.
Design Note: a missing cell is an error, not a placeholder. Considered: reading a missing cell as its column's placeholder in both modes — NaN in a number column, 0 in an int one, false in a bool one, nan in text — as a filter reads it, which both modes did. Honest for a number, since NaN compares as false; for an int or a bool it is a guess the program cannot tell from a value, of the kind §10 refuses elsewhere (an unmatched row's count read as 0). Decided (Vijay, 2026-09-25, typed frames decision 3): where the schema says a column can have missing cells its cells are fallible, and where it says it cannot they are plain, so no cell is a guess.
Dynamic column names need no special syntax — a string variable works everywhere a column name does:
col = 'age'
sorted = sort[df, col]
What was considered (and originally shipped): Bare identifiers as column names — sort[df, age], group[df, dept, [salary, mean]] — where an identifier that isn't a defined variable is treated as a column-name string.
Why it was removed:
age silently changed sort[df, age] from a column reference into a value argument. Code broke at a distance with no error.What to use instead: Strings for column names, expressions for computation — as specified above. One mechanism, no sugar, dynamic columns free.
Generated by crow test --docs --write from the language specification, section 11 and 13. Edit the spec, not this page.