A frame is a small data table — named
columns of values you can total, sort, and group, the way you would a
spreadsheet or a database query. It's built for the everyday question
"given this data, what's the answer?" — and because SCUA has an exact decimal
number, a column of money totals exactly, where a
float-first tool would drift.
#Building a frame
Pass a table of { column-name = array } — every column
the same length:
let sales = frame({
region = ["EU", "US", "EU", "US"],
amount = [10.50d, 20.00d, 5.25d, 7.75d], -- exact decimal money
qty = [3, 1, 4, 2],
})
A frame prints as an aligned table, so a tool can show someone the answer directly:
$ scua sales.scua
region amount qty
EU 10.50 3
US 20.00 1
EU 5.25 4
US 7.75 2
(4 rows × 3 cols)
For a crisper rendering you can print or copy elsewhere — a report, a
chat message, a code comment — to_table() returns the frame
as a box-drawing table string (the way DuckDB prints), with numeric
columns right-aligned:
print(sales.to_table())
┌────────┬────────┬─────┐
│ region │ amount │ qty │
├────────┼────────┼─────┤
│ EU │ 10.50 │ 3 │
│ US │ 20.00 │ 1 │
│ EU │ 5.25 │ 4 │
│ US │ 7.75 │ 2 │
└────────┴────────┴─────┘
(4 rows × 3 cols)
Unlike the default print (which caps a wide preview at
20 rows), to_table() renders every row — it's an explicit
export helper.
to_csv() is the same frame for a machine rather than a
person, and it round-trips: csv(f.to_csv()) gives you back
an equal frame. Quoting follows RFC 4180 — a field containing a comma, a
double quote, CR or LF is quoted and an inner quote is doubled; a nil
cell is an empty field. That is worth having in the language rather than
hand-rolled, because joining values with commas works perfectly until
one field contains a comma, and then writes a file that isn't obviously
broken — just silently wrong by one column from that row on.
Reading types is lossless or it doesn't happen:
csv() infers a number only when that number renders back to
exactly the text in the cell. 42 becomes an int and
1.50 an exact decimal, but 007,
+7 and 1_000 stay strings — turning them into
7, 7 and 1000 would destroy what
the cell said, and nothing downstream could get it back. That is what
keeps a zip code, a product code or a zero-padded order id intact.
#Querying it
Operations read on the frame and chain, left to right:
print(sales.rows()) -- 4
print(sales.column_names()) -- [region, amount, qty]
print(sales.total("amount")) -- 43.50 — EXACT (a decimal column stays exact)
print(sales.total("qty")) -- 10 — an int column totals to an int
print(sales.mean("qty")) -- 2.5
-- biggest sales: sort descending, keep the first rows and a couple of columns
print(sales.sort_by("amount", true).head(2).pick(["region", "amount"]))
-- the everyday analytical question: group by one column, sum another (exact for money)
print(sales.group_sum("region", "amount")) -- EU 15.75, US 27.75
| Operation | Result |
|---|---|
f.rows() |
the number of rows |
f.column_names() |
the column names, in order |
f.column(name) |
one column as an array |
f.head(n) |
a new frame with the first n rows |
f.take(indices) |
a new frame with the rows at the given indices, in that order |
f.pick(names) |
a new frame with only the named columns, in that order |
f.sort_by(name) /
f.sort_by(name, true) |
rows ordered by a column (ascending / descending) |
f.total(name) |
the sum of a column — exact for a
decimal column; int→int, float→float |
f.mean(name) |
the average of a column (as a float) |
f.filter(predicate) |
keep the rows where the predicate holds — column names are written bare |
f.search(needle[, opts]) |
rows whose string cells match needle (fixed-string by
default; see column search) |
f.with(name, expr) |
a new frame with column name computed per row from
expr (bare column names) |
f.with_column(name, values) |
a new frame with column name set to a pre-computed
values array (the lower-level form of
with) |
f.group_sum(key, value) |
group by the key column and sum the value
column per group → a 2-column frame |
f.join(other, key[, kind]) |
join two frames on a shared key column —
"inner" (default) or "left" |
f.pivot(index, columns, values) |
a pivot table: a row per index value, a column per
columns value, each cell the sum of
values |
f.unpivot(ids, values[, name_col, value_col]) |
melt: keep ids, stack the values columns
into a long name/value pair |
f.row(i) |
one row (0-based) as a table keyed by column name |
f.to_table() |
the frame rendered as a box-drawing table string (for printing or copying elsewhere) |
f.to_csv() |
the frame as RFC 4180 CSV — the machine-readable
counterpart, round-trips through csv() |
A frame owns its data. Building one copies the
arrays you pass in, and column / column_names
hand back copies — so changing an array after you have built a frame
from it does not change the frame, and writing into a column you read
out does not either:
let c = [1, 2, 3]
let f = frame({ x = c })
c.push(4)
print(f.rows(), f.total("x")) -- 3 6 (not 4 rows, not 10)
That is what makes the operations above safe to reason about: every one of them returns a new frame, and no frame's answers can change under it. The cost is that building a frame from a large array you already hold means two copies of that data until you drop the original.
Every operation returns a new frame (or a value) —
nothing is mutated in place, and there's no hidden row index to trip
over. Empty cells (nil) are skipped by
total/mean.
#Column names are checked
When you build a frame from a frame({ … }) literal, its
column names are known at compile time, so a mistyped column is caught
before the program runs — with a suggestion:
let sales = frame({ region = ["EU", "US"], amount = [10.50d, 20.00d] })
sales.total("amont") -- error: frame has no column 'amont' — did you mean 'amount'?
sales.filter(regon == "EU") -- error: frame has no column 'regon' — did you mean 'region'?
The column types are checked too: a reducer that
sums or averages — total, mean,
group_sum's value column, pivot's values
column — must name a number column, so summing a text column is caught
up front rather than faulting mid-run:
sales.total("region") -- error: can't `total` column 'region' — it holds string values, not numbers
The schema follows the chain —
sales.filter(…).total("amont") is checked too — and
pick, with, and group_sum carry
the right columns forward. Frames whose columns aren't known up front (a
frame you took as a function argument, or one from
csv, join, pivot, or an embedding
host) stay flexible: their column names and types are checked at runtime
instead, so nothing in your way when the shape is genuinely dynamic.
#Filtering and computed columns
In filter and with, column names
are written bare — amount, region —
and read as columns of the row; a variable of your own that isn't a
column (like a threshold you defined) keeps its normal meaning:
let cutoff = 100.00d
sales
.filter(region == "EU" and amount > cutoff) -- amount/region are columns; cutoff is the local
.with("net", amount - fee) -- a new column computed per row
The rules for a bare name, in order:
- A column is the column, even when a built-in has the same
name. A table with
count,yearortotalcolumns filters on them as you'd expect:f.filter(year == 2024). Where there's no such column, the built-in keeps its meaning, and a name you call, likeabs(delta), is always the function. - One of your own variables that is also a column is an
error. With
let region = "EU"and aregioncolumn,f.filter(region == "EU")could mean either, so it stops and says so rather than quietly picking one. Rename the variable, or write the predicate as a function (below). This includes a function of your own, and a name you have taken from a built-in: with your ownfn year(d), ayearcolumn is an error too, not silently the column, wherever in the file thefn yearis declared. rowis the current row. Use it to spell a column out, including a name that can't be written bare:f.filter(row.amount > 100.00d),f.filter(row["unit price"] > 5). A column named like a reserved word is written after the dot too:f.filter(row.end > row.start).
For logic the bare form can't express, or to use a variable and a column that share a name, pass a function of the row instead:
sales.filter(fn(row) return row.amount > row.fee * 2.00d end)
#Search a text column
Filter free-text columns with the same engine as regex /
fs.grep:
import regex
let tickets = frame({
id = [1, 2, 3],
note = ["refund request", "shipped OK", "CHARGEBACK dispute"],
})
-- Fixed-string: `col.contains(lit)` (substring) inside filter.
print(tickets.filter(note.contains("ship")))
-- Regex: compile once, then `col.matches(re)`.
match regex.compile("(?i)refund|chargeback")
Ok(re) -> print(tickets.filter(note.matches(re)))
Error(why) -> print(why)
end
-- Multi-column shortcut: keep rows where any (or named) string cell matches.
print(tickets.search("chargeback", { ignore_case = true, columns = ["note"] }))
print(tickets.search("ship|other", { regex = true })) -- string pattern
search options: columns (array of names;
default all string cells), ignore_case, regex
(string needle is a pattern; a compiled re is always
regex). Non-string cells are skipped unless you name the column
explicitly, in which case a non-string value faults.
#Summarising: the three calls you make first
Before you know what a table holds, you ask three things: what does
each column look like, which values does a text column actually contain,
and what happens when I break it down by group. Those are
describe, value_counts and
group_by.
let orders = frame({
region = ["north", "south", "north", "east", "south", "north"],
amount = [120.50d, 80.00d, 310.00d, 45.25d, 200.00d, 60.00d],
rep = ["ana", "bo", "ana", "cy", "bo", "dee"],
})
print(orders.describe())
print(orders.value_counts("region"))
print(orders.group_by("region", {
revenue = "sum:amount",
orders = "count",
typical = "median:amount",
reps = "count_distinct:rep",
}))
$ scua summarise.scua
column count mean std min p25 median p75 max
amount 6 135.958 101.745 45.25 65.0 100.25 180.125 310.00
(1 rows × 9 cols)
region count
north 3
south 2
east 1
(3 rows × 2 cols)
region revenue orders typical reps
north 490.50 3 120.50 2
south 280.00 2 140.00 1
east 45.25 1 45.25 1
(3 rows × 5 cols)
Three things worth noticing.
describe skips the text columns.
region and rep have no mean, so they get no
row — a table of nils would be noise. Those columns answer
value_counts and mode instead.
group_by computes everything in one
pass. Each entry of the spec is
"<reducer>:<column>", and you get one output
column per entry. The reducers are sum, mean,
median, min, max,
std, variance, count,
count_distinct, first, last,
product and mode. A bare "count"
counts the rows in the group; "count:amount"
counts the rows whose amount is not nil, which is a
different question.
The exact columns stay exact. revenue
and typical are decimals, not floats — 490.50,
not 490.5000000000001. That holds through grouping, and it
is the reason to keep money in a decimal column rather than
a float one.
A printed table shortens long floats; the values themselves
are untouched. The mean above shows as
135.958, but the number is still
135.95833333333334 — ask for it on its own and you get all
of it:
let orders = frame({ amount = [120.50d, 80.00d, 310.00d, 45.25d, 200.00d, 60.00d] })
print(orders.describe())
print(orders.mean("amount"))
$ scua mean.scua
column count mean std min p25 median p75 max
amount 6 135.958 101.745 45.25 65.0 100.25 180.125 310.00
(1 rows × 9 cols)
135.95833333333334
A printed table keeps six meaningful digits, which is enough to see a
small rate without a wall of digits swamping the row. Two things it
never does: it never shortens an exact number, so a
money column prints in full however long it is, and it never
adds digits — 0.5 stays 0.5. If you
want every digit of everything, to_csv() is the exact
counterpart to the printed table.
#The column reducers on their own
Every reducer also works on a whole column:
orders.median("amount"), orders.std("amount"),
orders.count_distinct("rep"). They are the same functions
group_by uses, so a grouped median and median
agree by construction.
Two groups behave differently, deliberately:
min,max,first,lastandmodehand back an actual cell, so they keep the column's kind — themodeof a text column is text, and themaxof a money column is money.minandmaxare the same globals you use on numbers:min(3, 1, 4)for scalars,min(f, "amount")for a column.stdandvarianceare always floats, because a standard deviation of money is not a money amount. Both use the sample convention (dividing by n-1), the same as a spreadsheet'sSTDEVand pandas'.std(), and both arenilfor fewer than two values — no spread has been observed, which is not the same claim as a spread of zero.
median and quantile sit between the two:
they return an actual cell when the answer lands on one, and an exact
midpoint otherwise. The midpoint of 20.00 and
35.00 is 27.50 — exact, and no wider than it
needs to be.
Missing values, running totals, and how two columns relate
Real data has gaps, and a gap is not a zero — it is "we do not know". Four verbs deal with what follows from that.
dropna removes incomplete rows. With no
column named, a row goes if anything in it is missing. Name a
column and a row goes only when that column is missing, which is usually
the question you actually have — you care that the amount is there, not
that every note field was filled in.
fillna puts a value into the gaps of one
column, and it will not let you make an exact column
approximate. fillna("amount", 0) fills a money column,
because zero is exactly zero. fillna("amount", 0.1) is
refused, because a rough 0.1 is not one tenth and writing
it into an exact column would quietly stop the column being exact.
cumsum adds a running total as a new
column. You name the new column, because a running total is one
value per row — it has to land somewhere, and replacing the
column you are totalling would destroy it.
corr says how strongly two numeric columns move
together, from -1 (one rises as the other falls) through 0 (no
straight-line relationship) to 1. Only rows where both values are
present count.
let sales = frame({
region = ["north", "south", "north", "north", "south"],
quarter = ["Q1", "Q1", "Q2", "Q1", "Q2"],
amount = [100.00d, 50.00d, 200.00d, nil, 75.00d],
})
print(sales.dropna("amount").group_by(["region", "quarter"], { revenue = "sum:amount", orders = "count" }))
print(sales.fillna("amount", 0))
print(sales.dropna("amount").cumsum("amount", "running"))
print(frame({ x = [1, 2, 3, 4], y = [2, 4, 7, 8] }).corr("x", "y"))
$ scua gaps.scua
region quarter revenue orders
north Q1 100.00 1
south Q1 50.00 1
north Q2 200.00 1
south Q2 75.00 1
(4 rows × 4 cols)
region quarter amount
north Q1 100.00
south Q1 50.00
north Q2 200.00
north Q1 0.00
south Q2 75.00
(5 rows × 3 cols)
region quarter amount running
north Q1 100.00 100.00
south Q1 50.00 150.00
north Q2 200.00 350.00
south Q2 75.00 425.00
(4 rows × 4 cols)
0.9844951849708403
group_by takes several keys.
group_by(["region", "quarter"], …) breaks down by both, and
each key comes back as its own column — so the answer is a frame you can
filter, sort and group again, rather than a joined
"north|Q1" string you would have to take apart first.
Two details worth knowing. The filled 0 printed as
0.00, matching the column rather than sitting in it at a
different precision. And cumsum keeps the column exact, so
the last running total equals total("amount") — they cannot
drift, because both go through the same addition.
#Loading from CSV
csv(text) parses CSV text straight into a frame — the
first line names the columns, each later line is a row. Cells are typed
per value: a whole number becomes an int,
a decimal-point number an exact decimal (so a money column
stays exact), anything else a string, and an empty cell is
nil. Quoted fields with embedded commas are handled.
let f = csv("city,revenue
Paris,1200.50
Tokyo,980.00
Paris,300.25")
print(f.group_sum("city", "revenue")) -- Paris 1500.75, Tokyo 980.00 — exact
(A quoted field may not span multiple lines in this version.)
#Combining and reshaping
join combines two frames on a shared key column —
"inner" (only matching rows, the default) or
"left" (every left row, with the right columns
nil where there's no match). A right column whose name
clashes with a left one is suffixed _right:
let tax = frame({ region = ["EU", "US"], rate = [0.20d, 0.08d] })
sales.join(tax, "region") -- adds a `rate` column to each sales row
pivot makes a pivot table — one row per distinct
index value, one column per distinct columns
value, each cell the sum of the values
column for that pair (exact for a decimal column; an empty
cell is nil):
sales.pivot("region", "product", "amount") -- region down the side, product across the top
unpivot is the inverse — melt a wide
table into a long one. Keep the ids columns, and stack the
named values columns into two columns: a name
column saying which column each value came from, and a
value column holding it. The rows come out variable-major
(all rows of the first value column, then the second, …); the two new
column names default to "name"/"value":
let wide = frame({ region = ["EU", "US"], pen = [10.50d, 20.00d], ink = [5.25d, 7.75d] })
wide.unpivot(["region"], ["pen", "ink"], "product", "amount")
-- region | product | amount
-- EU | pen | 10.50
-- US | pen | 20.00
-- EU | ink | 5.25
-- US | ink | 7.75
#Why exact decimal columns matter
Totalling money is the thing data tools get asked most, and it's
where float-based tools quietly mislead: 0.1 + 0.2 is not
0.3 in floating point. A decimal column (write
the literal with a d suffix — 10.50d) sums and
groups exactly, so a column of prices or invoice lines
adds up to the cent.
See examples/frames.scua
for a runnable tour.