Skip to content

Go values in and out

Into a frame

Values(name, slice) makes a column from a Go slice, its type the slice's, and ValuesNullable(name, slice, valid) one with nulls where valid is false. A slice of structs becomes a frame a field at a time:

type order struct {
    ID     int64
    Placed time.Time
    Total  float64
    Note   *string // nil: no note
}
note := "gift wrap"
in := []order{
    {1, time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC), 15, nil},
    {2, time.Date(2026, 1, 4, 14, 30, 0, 0, time.UTC), 27, &note},
}

ids, totals := make([]int64, len(in)), make([]float64, len(in))
placed := make([]time.Time, len(in))
notes, hasNote := make([]string, len(in)), make([]bool, len(in))
for i, o := range in {
    ids[i], placed[i], totals[i] = o.ID, o.Placed, o.Total
    if o.Note != nil {
        notes[i], hasNote[i] = *o.Note, true
    }
}
show(ursus.Frame(
    ursus.Values("id", ids),
    ursus.Values("placed", placed),
    ursus.Values("total", totals),
    ursus.ValuesNullable("note", notes, hasNote),
))
Output
shape: (2, 4)
┌───────┬────────────────────────────────┬─────────┬─────────────┐
│ id    │ placed                         │ total   │ note        │
│ Int64 │ Datetime(ns, UTC)              │ Float64 │ String      │
├───────┼────────────────────────────────┼─────────┼─────────────┤
│ 1     │ 2026-01-03T09:00:00.000000000Z │ 15      │ null        │
│ 2     │ 2026-01-04T14:30:00.000000000Z │ 27      │ "gift wrap" │
└───────┴────────────────────────────────┴─────────┴─────────────┘

New in 0.6.7: FromRows(rows) does that in one call, as Polars builds a frame from dataclasses, a column a field named as Rows reads it, its ursus tag or its own name. A pointer field is nullable, a slice a List, and a struct a Struct:

type order struct {
    ID     int64     `ursus:"id"`
    Placed time.Time `ursus:"placed"`
    Total  float64   `ursus:"total"`
    Note   *string   `ursus:"note"` // nil: no note
}
note := "gift wrap"
show(ursus.FromRows([]order{
    {1, time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC), 15, nil},
    {2, time.Date(2026, 1, 4, 14, 30, 0, 0, time.UTC), 27, &note},
}))
Output
shape: (2, 4)
┌───────┬────────────────────────────────┬─────────┬─────────────┐
│ id    │ placed                         │ total   │ note        │
│ Int64 │ Datetime(ns, UTC)              │ Float64 │ String      │
├───────┼────────────────────────────────┼─────────┼─────────────┤
│ 1     │ 2026-01-03T09:00:00.000000000Z │ 15      │ null        │
│ 2     │ 2026-01-04T14:30:00.000000000Z │ 27      │ "gift wrap" │
└───────┴────────────────────────────────┴─────────┴─────────────┘
Go slice Column
[]bool Bool
[]int8 … []int64, []int Int8 … Int64, Int64
[]uint8 … []uint64, []uint Uint8 … Uint64, Uint64
[]float32, []float64 Float32, Float64
[]string, [][]byte String, Binary
[]time.Time Datetime(ns, "UTC"); cast to Date for days
[]time.Duration Duration(ns)

Arrow records come in through ScanArrowRecords; see CSV, Parquet and Arrow.

A column out: Series

df.Column[T](name) is a column as a Series[T], typed by a generic method, so no assertion is needed at the call site. A Series answers in Go:

df, err := sales().Collect(context.Background())
if err != nil {
    log.Fatal(err)
}
qty, err := df.Column[int64]("qty")
if err != nil {
    log.Fatal(err)
}
fmt.Println(qty.Name(), qty.Len(), qty.NullCount())
fmt.Println(qty.Values())

total := qty.Fold(int64(0), func(acc, v int64) int64 { return acc + v })
fmt.Println("total:", total)

kinds := qty.Map[string](func(v int64) string {
    if v >= 8 {
        return "bulk"
    }
    return "single"
})
fmt.Println(kinds)
Output
qty 8 0
[10 4 7 2 9 12 3 6]
total: 53
[bulk single single single bulk bulk single single]
  • Values() is the values that are not null, as a slice;
  • Map[U](fn) maps each value to a Go slice of another type, and MapErr with a function that can fail;
  • Fold(init, fn) reduces the values left to right;
  • Name, DType, Len and NullCount.

New in 0.6.7: a Series has Polars' operations on one column: Sort, SortDesc, Unique, Reverse, DropNulls, FillNull, Cast[U], and the aggregates Min, Max, Sum, Mean, Median, Std and Var with their degrees of freedom, Count and NUnique, each run through the engine as a one-column frame; Head, Tail, Slice and Rename share the column; and ToDummies makes its dummy columns, as a frame's (see Reshaping).

A null is never a zero mistaken for a value. All() iterates each value with whether it is there, and Get(i) and df.At[T](row, name) answer one:

df, err := ursus.Frame(
    ursus.ValuesNullable("score", []int64{90, 0, 75}, []bool{true, false, true}),
).Collect(context.Background())
if err != nil {
    log.Fatal(err)
}
score, err := df.Column[int64]("score")
if err != nil {
    log.Fatal(err)
}
for v, present := range score.All() {
    fmt.Println(v, present)
}
v, ok := score.Get(1)
fmt.Println("row 1:", v, ok)
Output
90 true
0 false
75 true
row 1: 0 false

T must match the column: int64 for Int64, float64 for Float64, string for String, ursus.Int128Value for an Int128 sum. A mismatch is an error naming both. A Date is viewed as its int32 days and a Datetime as its int64 ticks; Rows, below, decodes either into a time.Time.

Rows out: structs

df.Rows[T]() decodes every row into a struct. A field takes the column its ursus tag names, else its own name, else its name ignoring case; ursus:"-" skips it:

type sale struct {
    ID      int64     `ursus:"order_id"`
    Day     time.Time `ursus:"date"`
    Product string
    Qty     int64
    Revenue float64 `ursus:"-"` // filled by the program, not read
}
df, err := sales().
    Filter(ursus.Col("region").Eq("east")).
    Collect(context.Background())
if err != nil {
    log.Fatal(err)
}
rows, err := df.Rows[sale]()
if err != nil {
    log.Fatal(err)
}
for _, r := range rows {
    fmt.Println(r.ID, r.Day.Format("Jan 2"), r.Product, r.Qty)
}
Output
4 Jan 5 pear 2
6 Jan 6 apple 12

A field with no column is an error, not a zero left behind: a typo would otherwise be a wrong number in a report. A pointer field is how a struct sees a null, nil:

type reading struct {
    Hour int64
    Temp *float64 // nil where the reading is null
}
df, err := readings().Head(3).Collect(context.Background())
if err != nil {
    log.Fatal(err)
}
rows, err := df.Rows[reading]()
if err != nil {
    log.Fatal(err)
}
for _, r := range rows {
    if r.Temp == nil {
        fmt.Println(r.Hour, "no reading")
        continue
    }
    fmt.Println(r.Hour, *r.Temp)
}
Output
0 10
1 no reading
2 no reading

The mapping of fields to columns is worked out once, so decoding costs a setter call a field a row, not a reflection lookup.

New in 0.6.7: RowsByKey[K, T](key) groups the rows by a column, as Polars' rows_by_key, a key's rows in the frame's order, and ToMaps() is each row a map[string]any, as Polars' to_dicts: an int64 for an Int64, a time.Time for an instant, a []any for a List, nil for a null. IterRows[T] streams a query's rows as structs, a batch at a time, as Polars' iter_rows, so an answer larger than memory can be read row by row.

A large answer, a batch at a time

CollectBatches streams an answer as frames of WithBatchSize rows, a Go iterator, so an answer larger than memory reaches Go without being held at once:

type sale struct {
    ID  int64 `ursus:"order_id"`
    Qty int64
}
ctx := context.Background()
total := int64(0)
for batch, err := range sales().CollectBatches(ctx, ursus.WithBatchSize(3)) {
    if err != nil {
        log.Fatal(err)
    }
    rows, err := batch.Rows[sale]()
    if err != nil {
        log.Fatal(err)
    }
    for _, r := range rows {
        total += r.Qty
    }
    fmt.Println("batch of", len(rows), "- running total", total)
}
Output
batch of 3 - running total 21
batch of 3 - running total 44
batch of 2 - running total 53

Breaking out of the loop stops the query and removes its spill files.