Skip to content

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)
Output
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)
Output
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)
Output
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)
Output
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)
Output
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)
Output
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)
}
Output
{ID:1 Product:apple Qty:10}
{ID:5 Product:fig Qty:9}
{ID:6 Product:apple Qty:12}

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)
Output
FILTER [(col("qty") > lit(3))]
  CSV SCAN testdata/sales.csv
    projection: [product, qty] (2/6 cols)

Next