Quick start¶
This tour builds a frame, reads a file, and runs the queries most programs need. Every block of code on this site is a Go example that the test suite runs, and the output under it is what it printed.
The examples read sales.csv,
eight orders:
order_id,date,region,product,qty,price
1,2026-01-03,north,apple,10,1.50
2,2026-01-03,south,apple,4,1.50
3,2026-01-04,north,pear,7,2.25
4,2026-01-05,east,pear,2,2.25
5,2026-01-05,south,fig,9,3.00
6,2026-01-06,east,apple,12,1.50
7,2026-01-07,north,fig,3,3.00
8,2026-01-07,south,pear,6,2.25
A frame from Go values¶
Frame builds a frame from columns, and Values makes a column from a Go slice. Its
type follows the slice's: []int64 is an Int64 column, []string a String one.
df, err := ursus.Frame(
ursus.Values("city", []string{"Oslo", "Lima", "Pune"}),
ursus.Values("population", []int64{709_000, 10_151_000, 7_764_000}),
ursus.Values("coastal", []bool{true, true, false}),
).Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
shape: (3, 3)
┌────────┬────────────┬─────────┐
│ city │ population │ coastal │
│ String │ Int64 │ Bool │
├────────┼────────────┼─────────┤
│ "Oslo" │ 709000 │ true │
│ "Lima" │ 10151000 │ true │
│ "Pune" │ 7764000 │ false │
└────────┴────────────┴─────────┘
Frame gives a LazyFrame, a query. Collect runs it and gives a DataFrame, the
answer.
Reading a file¶
ScanCSV reads a CSV and infers its columns' types; an option overrides one. Here the
dates become a Date:
df, err := ursus.ScanCSV("testdata/sales.csv",
ursus.WithSchemaOverrides(map[string]ursus.DataType{"date": ursus.Date}),
).Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
shape: (8, 6)
┌──────────┬────────────┬─────────┬─────────┬───────┬─────────┐
│ order_id │ date │ region │ product │ qty │ price │
│ Int64 │ Date │ String │ String │ Int64 │ Float64 │
├──────────┼────────────┼─────────┼─────────┼───────┼─────────┤
│ 1 │ 2026-01-03 │ "north" │ "apple" │ 10 │ 1.5 │
│ 2 │ 2026-01-03 │ "south" │ "apple" │ 4 │ 1.5 │
│ 3 │ 2026-01-04 │ "north" │ "pear" │ 7 │ 2.25 │
│ 4 │ 2026-01-05 │ "east" │ "pear" │ 2 │ 2.25 │
│ 5 │ 2026-01-05 │ "south" │ "fig" │ 9 │ 3 │
│ 6 │ 2026-01-06 │ "east" │ "apple" │ 12 │ 1.5 │
│ 7 │ 2026-01-07 │ "north" │ "fig" │ 3 │ 3 │
│ 8 │ 2026-01-07 │ "south" │ "pear" │ 6 │ 2.25 │
└──────────┴────────────┴─────────┴─────────┴───────┴─────────┘
ScanParquet reads Parquet the same way, and ScanCSVReader, ScanParquetBytes,
ScanCSVFrom and ScanParquetFrom read from memory or from any reader you open, an
object store's included.
Selecting and filtering¶
Filter keeps the rows where an expression is true, and Select keeps or computes
columns:
df, err := ursus.ScanCSV("testdata/sales.csv").
Filter(ursus.Col("region").Eq("north")).
Select(ursus.Col("product"), ursus.Col("qty")).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
shape: (3, 2)
┌─────────┬───────┐
│ product │ qty │
│ String │ Int64 │
├─────────┼───────┤
│ "apple" │ 10 │
│ "pear" │ 7 │
│ "fig" │ 3 │
└─────────┴───────┘
Columns are named with Col, and an expression's methods build on it: Eq, Gt,
Ge, And, Or, IsNull. Methods have names because Go has no operator
overloading: Col("qty").Gt(3), not qty > 3.
Adding columns¶
WithColumns adds columns, or replaces those of the same name. When, Then and
Otherwise choose a value by a condition:
df, err := ursus.ScanCSV("testdata/sales.csv").
WithColumns(
ursus.Col("qty").Mul(ursus.Col("price")).Alias("revenue"),
ursus.When(ursus.Col("qty").Ge(int64(8))).
Then("bulk").
Otherwise("single").
Alias("kind"),
).
Select(ursus.Col("order_id"), ursus.Col("revenue"), ursus.Col("kind")).
Head(4).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
shape: (4, 3)
┌──────────┬─────────┬──────────┐
│ order_id │ revenue │ kind │
│ Int64 │ Float64 │ String │
├──────────┼─────────┼──────────┤
│ 1 │ 15 │ "bulk" │
│ 2 │ 6 │ "single" │
│ 3 │ 15.75 │ "single" │
│ 4 │ 4.5 │ "single" │
└──────────┴─────────┴──────────┘
Grouping¶
GroupBy takes the keys, and Agg the aggregates to compute for each group:
df, err := ursus.ScanCSV("testdata/sales.csv").
GroupBy(ursus.Col("product")).
Agg(
ursus.Col("qty").Sum().Alias("qty"),
ursus.Col("price").Max().Alias("price"),
ursus.Col("region").NUnique().Alias("regions"),
).
Sort(ursus.Asc(ursus.Col("product"))).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
shape: (3, 4)
┌─────────┬────────┬─────────┬─────────┐
│ product │ qty │ price │ regions │
│ String │ Int128 │ Float64 │ Uint64 │
├─────────┼────────┼─────────┼─────────┤
│ "apple" │ 26 │ 1.5 │ 3 │
│ "fig" │ 12 │ 3 │ 2 │
│ "pear" │ 15 │ 2.25 │ 3 │
└─────────┴────────┴─────────┴─────────┘
A sum of integers is an Int128, so it cannot overflow; cast it if you need an
Int64. Groups come out in no promised order: sort them, or call MaintainOrder()
for the order their keys first appear in.
Joining¶
Join matches rows by keys. JoinHow picks the kind: inner by default, and left,
right, full, semi, anti and cross. A row with no match on the other side takes nulls:
managers := ursus.Frame(
ursus.Values("region", []string{"north", "south"}),
ursus.Values("manager", []string{"Ada", "Grace"}),
)
df, err := ursus.ScanCSV("testdata/sales.csv").
Join(managers,
ursus.JoinOn(ursus.Col("region")),
ursus.JoinHow(ursus.JoinLeft)).
Select(ursus.Col("order_id"), ursus.Col("region"), ursus.Col("manager")).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
shape: (8, 3)
┌──────────┬─────────┬─────────┐
│ order_id │ region │ manager │
│ Int64 │ String │ String │
├──────────┼─────────┼─────────┤
│ 1 │ "north" │ "Ada" │
│ 2 │ "south" │ "Grace" │
│ 3 │ "north" │ "Ada" │
│ 4 │ "east" │ null │
│ 5 │ "south" │ "Grace" │
│ 6 │ "east" │ null │
│ 7 │ "north" │ "Ada" │
│ 8 │ "south" │ "Grace" │
└──────────┴─────────┴─────────┘
Back into Go¶
Rows decodes a frame into a slice of structs, matching fields to columns by an
ursus tag, by name, or by name ignoring case:
type sale struct {
ID int64 `ursus:"order_id"`
Product string `ursus:"product"`
Qty int64 `ursus:"qty"`
}
df, err := ursus.ScanCSV("testdata/sales.csv").
Filter(ursus.Col("qty").Ge(int64(9))).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
sales, err := df.Rows[sale]()
if err != nil {
log.Fatal(err)
}
for _, s := range sales {
fmt.Printf("%+v\n", s)
}
df.Column[T](name) gives one column as a typed Series[T], and df.At[T](row, name)
one value and whether it was null.
What will run¶
Explain shows the plan the optimizer made. Here the scan reads two of the file's six
columns, and the Select is gone, because the scan already gives just those two:
plan, err := ursus.ScanCSV("testdata/sales.csv").
Filter(ursus.Col("qty").Gt(int64(3))).
Select(ursus.Col("product"), ursus.Col("qty")).
Explain(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(plan)
FILTER [(col("qty") > lit(3))]
CSV SCAN testdata/sales.csv
projection: [product, qty] (2/6 cols)
Next¶
- Coming from Polars: the names you know, and where ursus differs.
- Lazy and eager: when a query runs, and how to stream its answer.
- Expressions: the same expression per row, per group and over a window.
- Memory and spilling: running a query bigger than memory.
- The user guide: twelve chapters, from selecting and joining to Go functions and Parquet.