Documentation
¶
Overview ¶
Package ursus is a dataframe library for Go modelled on Polars: an expression DSL, lazy execution with a query optimizer, Arrow memory, and SIMD kernels.
Runnable examples for the common tasks — filter, group-by, join, reading values back out — are in example_test.go and render beside each method on pkg.go.dev.
A tour ¶
df, err := ursus.ScanParquet("sales.parquet").
Filter(ursus.Col("price").Gt(5)).
Select(ursus.Col("id"), ursus.Col("price")).
Collect(ctx)
Nothing runs until Collect. In between, ursus resolves the schema, expands any multi-column selections, type-checks every expression, and pushes the projection down into the scan so only the columns actually used are ever read.
The design in ten lines ¶
- Lazy is the real API. Eager helpers are thin wrappers over it.
- Public types are concrete structs — Go forbids generic methods on interfaces, and generic methods are the point.
- Errors are deferred and sticky, surfaced at Collect, so chaining stays clean.
- context.Context appears at execution boundaries only, never in builders.
- Named methods, never operator tricks: a.Gt(5), not a > 5.
- Functional options where the parameter surface is wide.
- Generics at the boundary where Go values meet columns, not in the engine.
- Iterators for streaming output.
- No row index: row order is a property, not a label space.
- Null is not NaN, and both are handled explicitly everywhere.
Building ¶
ursus requires Go 1.27. GOEXPERIMENT=simd is optional: it switches on the SIMD kernels, and without it each kernel falls back to its scalar twin, which the CI runs the whole suite against.
Example ¶
The shortest useful thing: build a frame and look at it.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
df, err := exampleSales().Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (5, 4) ┌─────────┬─────────┬───────┬─────────┐ │ region │ product │ qty │ price │ │ String │ String │ Int64 │ Float64 │ ├─────────┼─────────┼───────┼─────────┤ │ "north" │ "apple" │ 10 │ 1.5 │ │ "south" │ "apple" │ 4 │ 1.5 │ │ "north" │ "pear" │ 7 │ 2.25 │ │ "east" │ "pear" │ 2 │ 2.25 │ │ "south" │ "fig" │ 9 │ 3 │ └─────────┴─────────┴───────┴─────────┘
Index ¶
- Constants
- Variables
- func LeaveProcessMemoryLimit()
- func SetProcessMemoryLimit() int64
- type Ambiguous
- type AsOfOption
- func AsOfAllowExactMatches(b bool) AsOfOption
- func AsOfBy(exprs ...Expr) AsOfOption
- func AsOfLeftBy(exprs ...Expr) AsOfOption
- func AsOfLeftOn(e Expr) AsOfOption
- func AsOfOn(e Expr) AsOfOption
- func AsOfRightBy(exprs ...Expr) AsOfOption
- func AsOfRightOn(e Expr) AsOfOption
- func AsOfStrategyOpt(s AsOfStrategy) AsOfOption
- func AsOfSuffix(s string) AsOfOption
- func AsOfTolerance(i Interval) AsOfOption
- type AsOfStrategy
- type CSVFile
- type CSVOption
- func WithColumnNames(names ...string) CSVOption
- func WithComment(prefix string) CSVOption
- func WithHasHeader(b bool) CSVOption
- func WithInferRows(n int) CSVOption
- func WithMaxRecordSize(n int) CSVOption
- func WithNullValues(vals ...string) CSVOption
- func WithQuote(c byte) CSVOption
- func WithSchema(s *Schema) CSVOption
- func WithSchemaOverrides(m map[string]dtype.DataType) CSVOption
- func WithSeparator(c byte) CSVOption
- func WithSkipRows(n int) CSVOption
- func WithTruncateRaggedLines(b bool) CSVOption
- type CSVSinkOption
- type CSVWriteOption
- type Closed
- type CollectOption
- type Column
- type ConcatMode
- type ConcatOption
- type CutOption
- type DataFrame
- func (df *DataFrame) ArrowSchema() (s *arrow.Schema, err error)
- func (df *DataFrame) At[T any](row int, name string) (T, bool, error)
- func (df *DataFrame) Column[T any](name string) (*Series[T], error)
- func (df *DataFrame) ColumnAt(i int) *Column
- func (df *DataFrame) Columns() []string
- func (df *DataFrame) Concat(ctx context.Context, others ...*DataFrame) (*DataFrame, error)
- func (df *DataFrame) Describe(ctx context.Context, percentiles ...float64) (*DataFrame, error)
- func (df *DataFrame) Drop(ctx context.Context, names ...string) (*DataFrame, error)
- func (df *DataFrame) Filter(ctx context.Context, preds ...Expr) (*DataFrame, error)
- func (df *DataFrame) Head(ctx context.Context, n int) (*DataFrame, error)
- func (df *DataFrame) Height() int
- func (df *DataFrame) Join(ctx context.Context, other *DataFrame, opts ...JoinOption) (*DataFrame, error)
- func (df *DataFrame) JoinWhere(ctx context.Context, other *DataFrame, preds ...Expr) (*DataFrame, error)
- func (df *DataFrame) Lazy() *LazyFrame
- func (df *DataFrame) Record() (rec arrow.Record, err error)
- func (df *DataFrame) Rename(ctx context.Context, names map[string]string) (*DataFrame, error)
- func (df *DataFrame) Reverse(ctx context.Context) (*DataFrame, error)
- func (df *DataFrame) Rows[T any]() (rows []T, err error)
- func (df *DataFrame) Schema() *Schema
- func (df *DataFrame) Select(ctx context.Context, exprs ...Expr) (*DataFrame, error)
- func (df *DataFrame) Shape() (int, int)
- func (df *DataFrame) Sort(ctx context.Context, keys ...SortKey) (*DataFrame, error)
- func (df *DataFrame) String() string
- func (df *DataFrame) Tail(ctx context.Context, n int) (*DataFrame, error)
- func (df *DataFrame) Unique(ctx context.Context, subset ...string) (*DataFrame, error)
- func (df *DataFrame) Unpivot(ctx context.Context, opts UnpivotOptions) (*DataFrame, error)
- func (df *DataFrame) ValueCounts(ctx context.Context, by ...Expr) (*DataFrame, error)
- func (df *DataFrame) WhereExists(ctx context.Context, other *DataFrame, preds ...Expr) (*DataFrame, error)
- func (df *DataFrame) WhereNotExists(ctx context.Context, other *DataFrame, preds ...Expr) (*DataFrame, error)
- func (df *DataFrame) Width() int
- func (df *DataFrame) WithColumns(ctx context.Context, exprs ...Expr) (*DataFrame, error)
- type DataType
- type DtExpr
- func (d DtExpr) ConvertTimeZone(tz string) Expr
- func (d DtExpr) Day() Expr
- func (d DtExpr) Epoch() Expr
- func (d DtExpr) Hour() Expr
- func (d DtExpr) IsLeapYear() Expr
- func (d DtExpr) Microsecond() Expr
- func (d DtExpr) Millisecond() Expr
- func (d DtExpr) Minute() Expr
- func (d DtExpr) Month() Expr
- func (d DtExpr) MonthEnd() Expr
- func (d DtExpr) MonthStart() Expr
- func (d DtExpr) Nanosecond() Expr
- func (d DtExpr) OffsetBy[T Span](by T) Expr
- func (d DtExpr) OrdinalDay() Expr
- func (d DtExpr) Quarter() Expr
- func (d DtExpr) ReplaceTimeZone(tz string, opts ...ZoneOption) Expr
- func (d DtExpr) Round[T Span](every T) Expr
- func (d DtExpr) Second() Expr
- func (d DtExpr) Strftime(format string) Expr
- func (d DtExpr) ToString() Expr
- func (d DtExpr) TotalDays() Expr
- func (d DtExpr) TotalHours() Expr
- func (d DtExpr) TotalMinutes() Expr
- func (d DtExpr) TotalSeconds() Expr
- func (d DtExpr) Truncate[T Span](every T) Expr
- func (d DtExpr) Week() Expr
- func (d DtExpr) Weekday() Expr
- func (d DtExpr) Year() Expr
- type DynamicOptions
- type EwmDecay
- type ExplainOption
- type Expr
- func All() Expr
- func Coalesce(exprs ...Expr) Expr
- func Col(names ...string) Expr
- func ColDType(types ...dtype.DataType) Expr
- func ColRegex(pattern string) Expr
- func ConcatStr(sep string, exprs ...Expr) Expr
- func Corr(a, b Expr) Expr
- func Cov(a, b Expr, ddof int) Expr
- func Exclude(names ...string) Expr
- func ExcludeRegex(pattern string) Expr
- func Len() Expr
- func Lit[T Literal](v T) Expr
- func Null(dt dtype.DataType) Expr
- func Struct(exprs ...Expr) Expr
- func (e Expr) Abs() Expr
- func (e Expr) Add[T Operand](v T) Expr
- func (e Expr) Alias(name string) Expr
- func (e Expr) AllTrue() Expr
- func (e Expr) And[T Operand](v T) Expr
- func (e Expr) Any() Expr
- func (e Expr) ArcCos() Expr
- func (e Expr) ArcCosh() Expr
- func (e Expr) ArcSin() Expr
- func (e Expr) ArcSinh() Expr
- func (e Expr) ArcTan() Expr
- func (e Expr) ArcTanh() Expr
- func (e Expr) ArgMax() Expr
- func (e Expr) ArgMin() Expr
- func (e Expr) BackwardFill(limit int) Expr
- func (e Expr) BitwiseCountOnes() Expr
- func (e Expr) BitwiseCountZeros() Expr
- func (e Expr) BitwiseLeadingOnes() Expr
- func (e Expr) BitwiseLeadingZeros() Expr
- func (e Expr) BitwiseTrailingOnes() Expr
- func (e Expr) BitwiseTrailingZeros() Expr
- func (e Expr) BottomK(k int) Expr
- func (e Expr) Cast(to dtype.DataType) Expr
- func (e Expr) CastLossy(to dtype.DataType) Expr
- func (e Expr) Cbrt() Expr
- func (e Expr) Ceil() Expr
- func (e Expr) Clip[L, H Operand](lo L, hi H) Expr
- func (e Expr) Cos() Expr
- func (e Expr) Cosh() Expr
- func (e Expr) Count() Expr
- func (e Expr) CumCount(reverse bool) Expr
- func (e Expr) CumMax(reverse bool) Expr
- func (e Expr) CumMin(reverse bool) Expr
- func (e Expr) CumProd(reverse bool) Expr
- func (e Expr) CumSum(reverse bool) Expr
- func (e Expr) Cut(breaks []float64, opts ...CutOption) Expr
- func (e Expr) Degrees() Expr
- func (e Expr) Diff(n int) Expr
- func (e Expr) Div[T Operand](v T) Expr
- func (e Expr) DropNans() Expr
- func (e Expr) DropNulls() Expr
- func (e Expr) Dt() DtExpr
- func (e Expr) Eq[T Operand](v T) Expr
- func (e Expr) EqMissing[T Operand](v T) Expr
- func (e Expr) Err() error
- func (e Expr) EwmMean(decay EwmDecay, opts ...WindowOption) Expr
- func (e Expr) EwmStd(decay EwmDecay, opts ...WindowOption) Expr
- func (e Expr) EwmVar(decay EwmDecay, opts ...WindowOption) Expr
- func (e Expr) Exp() Expr
- func (e Expr) Explode() Expr
- func (e Expr) FillNan[T Operand](v T) Expr
- func (e Expr) FillNull(s FillStrategy) Expr
- func (e Expr) FillNullWith[T Operand](v T) Expr
- func (e Expr) First() Expr
- func (e Expr) Floor() Expr
- func (e Expr) FloorDiv[T Operand](v T) Expr
- func (e Expr) ForwardFill(limit int) Expr
- func (e Expr) Ge[T Operand](v T) Expr
- func (e Expr) Gt[T Operand](v T) Expr
- func (e Expr) Implode() Expr
- func (e Expr) Interpolate(opts ...WindowOption) Expr
- func (e Expr) IsBetween[L, H Operand](lo L, hi H, closed ...Closed) Expr
- func (e Expr) IsClose(other Expr, relTol, absTol float64) Expr
- func (e Expr) IsDuplicated() Expr
- func (e Expr) IsFinite() Expr
- func (e Expr) IsFirstDistinct() Expr
- func (e Expr) IsIn[T Literal](vs ...T) Expr
- func (e Expr) IsInfinite() Expr
- func (e Expr) IsLastDistinct() Expr
- func (e Expr) IsNan() Expr
- func (e Expr) IsNotNan() Expr
- func (e Expr) IsNotNull() Expr
- func (e Expr) IsNull() Expr
- func (e Expr) IsUnique() Expr
- func (e Expr) Last() Expr
- func (e Expr) Le[T Operand](v T) Expr
- func (e Expr) Len() Expr
- func (e Expr) List() ListExpr
- func (e Expr) Ln() Expr
- func (e Expr) Log(base float64) Expr
- func (e Expr) Log1p() Expr
- func (e Expr) Log10() Expr
- func (e Expr) Lt[T Operand](v T) Expr
- func (e Expr) MapBatches[In, Out Literal](name string, out DataType, fn func([]In, []bool) ([]Out, []bool, error)) Expr
- func (e Expr) MapElements[In, Out Literal](name string, out DataType, fn func(In) (Out, error)) Expr
- func (e Expr) MapName(fn func(string) string) Expr
- func (e Expr) Max() Expr
- func (e Expr) MaxBy(by Expr) Expr
- func (e Expr) Mean() Expr
- func (e Expr) Median() Expr
- func (e Expr) Min() Expr
- func (e Expr) MinBy(by Expr) Expr
- func (e Expr) Mod[T Operand](v T) Expr
- func (e Expr) Mul[T Operand](v T) Expr
- func (e Expr) NUnique() Expr
- func (e Expr) Ne[T Operand](v T) Expr
- func (e Expr) NeMissing[T Operand](v T) Expr
- func (e Expr) Neg() Expr
- func (e Expr) Not() Expr
- func (e Expr) NullCount() Expr
- func (e Expr) Or[T Operand](v T) Expr
- func (e Expr) Over(partitionBy ...Expr) Expr
- func (e Expr) OverWith(spec WindowSpec) Expr
- func (e Expr) PctChange(n int) Expr
- func (e Expr) Pow[T Operand](v T) Expr
- func (e Expr) Prefix(p string) Expr
- func (e Expr) Product() Expr
- func (e Expr) QCut(quantiles []float64, opts ...CutOption) Expr
- func (e Expr) QCutN(n int, opts ...CutOption) Expr
- func (e Expr) Quantile(q float64, interp Interpolation) Expr
- func (e Expr) Radians() Expr
- func (e Expr) Rank(method RankMethod, descending bool) Expr
- func (e Expr) RollingMax(size int, opts ...WindowOption) Expr
- func (e Expr) RollingMaxBy(by Expr, period Interval, opts ...WindowOption) Expr
- func (e Expr) RollingMean(size int, opts ...WindowOption) Expr
- func (e Expr) RollingMeanBy(by Expr, period Interval, opts ...WindowOption) Expr
- func (e Expr) RollingMin(size int, opts ...WindowOption) Expr
- func (e Expr) RollingMinBy(by Expr, period Interval, opts ...WindowOption) Expr
- func (e Expr) RollingStd(size int, opts ...WindowOption) Expr
- func (e Expr) RollingStdBy(by Expr, period Interval, opts ...WindowOption) Expr
- func (e Expr) RollingSum(size int, opts ...WindowOption) Expr
- func (e Expr) RollingSumBy(by Expr, period Interval, opts ...WindowOption) Expr
- func (e Expr) RollingVar(size int, opts ...WindowOption) Expr
- func (e Expr) RollingVarBy(by Expr, period Interval, opts ...WindowOption) Expr
- func (e Expr) Round(decimals int) Expr
- func (e Expr) Shift(n int) Expr
- func (e Expr) ShiftFill[T Operand](n int, fill T) Expr
- func (e Expr) Sign() Expr
- func (e Expr) Sin() Expr
- func (e Expr) Sinh() Expr
- func (e Expr) Sqrt() Expr
- func (e Expr) Std(ddof int) Expr
- func (e Expr) Str() StrExpr
- func (e Expr) String() string
- func (e Expr) Struct() StructExpr
- func (e Expr) Sub[T Operand](v T) Expr
- func (e Expr) Suffix(s string) Expr
- func (e Expr) Sum() Expr
- func (e Expr) Tan() Expr
- func (e Expr) Tanh() Expr
- func (e Expr) TopK(k int) Expr
- func (e Expr) Unnest() Expr
- func (e Expr) Var(ddof int) Expr
- func (e Expr) Xor[T Operand](v T) Expr
- type Field
- type FillStrategy
- type GroupBy
- func (g *GroupBy) Agg(exprs ...Expr) *LazyFrame
- func (g *GroupBy) Count() *LazyFrame
- func (g *GroupBy) First() *LazyFrame
- func (g *GroupBy) Last() *LazyFrame
- func (g *GroupBy) Len(name string) *LazyFrame
- func (g *GroupBy) MaintainOrder() *GroupBy
- func (g *GroupBy) Max() *LazyFrame
- func (g *GroupBy) Mean() *LazyFrame
- func (g *GroupBy) Median() *LazyFrame
- func (g *GroupBy) Min() *LazyFrame
- func (g *GroupBy) NUnique() *LazyFrame
- func (g *GroupBy) Quantile(q float64, interp Interpolation) *LazyFrame
- func (g *GroupBy) Sum() *LazyFrame
- type Int128Value
- type Interpolation
- type Interval
- type JoinKind
- type JoinOption
- func JoinCoalesce(b bool) JoinOption
- func JoinHow(k JoinKind) JoinOption
- func JoinLeftOn(keys ...Expr) JoinOption
- func JoinMaintainOrder(b bool) JoinOption
- func JoinNullsEqual(b bool) JoinOption
- func JoinOn(keys ...Expr) JoinOption
- func JoinRightOn(keys ...Expr) JoinOption
- func JoinSuffix(s string) JoinOption
- func JoinValidate(v JoinValidation) JoinOption
- type JoinValidation
- type LazyFrame
- func Concat(frames []*LazyFrame, opts ...ConcatOption) *LazyFrame
- func Frame(cols ...*Column) *LazyFrame
- func FrameOf(cols ...*Column) (*LazyFrame, error)
- func ScanArrow(open func() (array.RecordReader, error)) *LazyFrame
- func ScanArrowRecords(recs ...arrow.RecordBatch) *LazyFrame
- func ScanCSV(path string, opts ...CSVOption) *LazyFrame
- func ScanCSVFiles(paths []string, opts ...CSVOption) *LazyFrame
- func ScanCSVFrom(files []CSVFile, opts ...CSVOption) *LazyFrame
- func ScanCSVGlob(pattern string, opts ...CSVOption) *LazyFrame
- func ScanCSVReader(b []byte, name string, opts ...CSVOption) *LazyFrame
- func ScanParquet(path string, opts ...ParquetOption) *LazyFrame
- func ScanParquetBytes(b []byte, name string, opts ...ParquetOption) *LazyFrame
- func ScanParquetFiles(paths []string, opts ...ParquetOption) *LazyFrame
- func ScanParquetFrom(files []ParquetFile, opts ...ParquetOption) *LazyFrame
- func ScanParquetGlob(pattern string, opts ...ParquetOption) *LazyFrame
- func (lf *LazyFrame) BottomK(k int, by ...SortKey) *LazyFrame
- func (lf *LazyFrame) Collect(ctx context.Context, opts ...CollectOption) (df *DataFrame, err error)
- func (lf *LazyFrame) CollectBatches(ctx context.Context, opts ...CollectOption) iter.Seq2[*DataFrame, error]
- func (lf *LazyFrame) CollectInto[T any](ctx context.Context, opts ...CollectOption) (rows []T, err error)
- func (lf *LazyFrame) CollectRecords(ctx context.Context, opts ...CollectOption) iter.Seq2[arrow.Record, error]
- func (lf *LazyFrame) CollectSchema(ctx context.Context) (s *Schema, err error)
- func (lf *LazyFrame) Concat(others ...*LazyFrame) *LazyFrame
- func (lf *LazyFrame) Count(ctx context.Context, opts ...CollectOption) (n int64, err error)
- func (lf *LazyFrame) Describe(ctx context.Context, percentiles ...float64) (*DataFrame, error)
- func (lf *LazyFrame) Drop(names ...string) *LazyFrame
- func (lf *LazyFrame) DropNulls(subset ...string) *LazyFrame
- func (lf *LazyFrame) Err() error
- func (lf *LazyFrame) Explain(ctx context.Context, opts ...ExplainOption) (out string, err error)
- func (lf *LazyFrame) Explode(names ...string) *LazyFrame
- func (lf *LazyFrame) FillNan[T Operand](v T, subset ...string) *LazyFrame
- func (lf *LazyFrame) FillNull[T Operand](v T, subset ...string) *LazyFrame
- func (lf *LazyFrame) Filter(preds ...Expr) *LazyFrame
- func (lf *LazyFrame) GroupBy(keys ...Expr) *GroupBy
- func (lf *LazyFrame) GroupByDynamic(index Expr, o DynamicOptions) *GroupBy
- func (lf *LazyFrame) HStack(others ...*LazyFrame) *LazyFrame
- func (lf *LazyFrame) Head(n int) *LazyFrame
- func (lf *LazyFrame) Join(other *LazyFrame, opts ...JoinOption) *LazyFrame
- func (lf *LazyFrame) JoinAsOf(other *LazyFrame, opts ...AsOfOption) *LazyFrame
- func (lf *LazyFrame) JoinWhere(other *LazyFrame, preds ...Expr) *LazyFrame
- func (lf *LazyFrame) Limit(n int) *LazyFrame
- func (lf *LazyFrame) MergeSorted(other *LazyFrame, key string) *LazyFrame
- func (lf *LazyFrame) Pipe(fn func(*LazyFrame) *LazyFrame) *LazyFrame
- func (lf *LazyFrame) Remove(preds ...Expr) *LazyFrame
- func (lf *LazyFrame) Rename(names map[string]string) *LazyFrame
- func (lf *LazyFrame) Reverse() *LazyFrame
- func (lf *LazyFrame) Rolling(index Expr, o RollingOptions) *GroupBy
- func (lf *LazyFrame) Select(exprs ...Expr) *LazyFrame
- func (lf *LazyFrame) SinkCSV(ctx context.Context, path string, opts ...CSVSinkOption) error
- func (lf *LazyFrame) SinkParquet(ctx context.Context, path string, opts ...ParquetSinkOption) error
- func (lf *LazyFrame) Slice(offset, length int) *LazyFrame
- func (lf *LazyFrame) Sort(keys ...SortKey) *LazyFrame
- func (lf *LazyFrame) Tail(n int) *LazyFrame
- func (lf *LazyFrame) TopK(k int, by ...SortKey) *LazyFrame
- func (lf *LazyFrame) Unique(subset ...string) *LazyFrame
- func (lf *LazyFrame) Unnest(names ...string) *LazyFrame
- func (lf *LazyFrame) Unpivot(opts UnpivotOptions) *LazyFrame
- func (lf *LazyFrame) VStack(other *LazyFrame) *LazyFrame
- func (lf *LazyFrame) ValueCounts(by ...Expr) *LazyFrame
- func (lf *LazyFrame) WhereExists(other *LazyFrame, preds ...Expr) *LazyFrame
- func (lf *LazyFrame) WhereNotExists(other *LazyFrame, preds ...Expr) *LazyFrame
- func (lf *LazyFrame) WithColumns(exprs ...Expr) *LazyFrame
- func (lf *LazyFrame) WithRowIndex(name string, offset uint32) *LazyFrame
- func (lf *LazyFrame) WriteCSV(ctx context.Context, w io.Writer, opts ...CSVSinkOption) (err error)
- func (lf *LazyFrame) WriteParquet(ctx context.Context, w io.Writer, opts ...ParquetSinkOption) (err error)
- type ListExpr
- func (l ListExpr) Contains(v any) Expr
- func (l ListExpr) DropNulls() Expr
- func (l ListExpr) First() Expr
- func (l ListExpr) Get(i int) Expr
- func (l ListExpr) Head(n int) Expr
- func (l ListExpr) Join(sep string) Expr
- func (l ListExpr) Last() Expr
- func (l ListExpr) Len() Expr
- func (l ListExpr) Max() Expr
- func (l ListExpr) Mean() Expr
- func (l ListExpr) Min() Expr
- func (l ListExpr) Reverse() Expr
- func (l ListExpr) Slice(offset, length int) Expr
- func (l ListExpr) Sort() Expr
- func (l ListExpr) SortDesc() Expr
- func (l ListExpr) Sum() Expr
- func (l ListExpr) Tail(n int) Expr
- func (l ListExpr) Unique() Expr
- type Literal
- type MappingStrategy
- type MemoryStats
- type NonExistent
- type Operand
- type OptFlags
- type ParquetFile
- type ParquetOption
- type ParquetSinkOption
- type ParquetWriteOption
- type RankMethod
- type RollingOptions
- type Schema
- type Series
- type SortKey
- type Span
- type StrExpr
- func (s StrExpr) Contains(pattern string, literal bool) Expr
- func (s StrExpr) CountMatches(pattern string, literal bool) Expr
- func (s StrExpr) EndsWith(suffix string) Expr
- func (s StrExpr) EscapeRegex() Expr
- func (s StrExpr) Extract(pattern string, group int) Expr
- func (s StrExpr) ExtractAll(pattern string) Expr
- func (s StrExpr) Find(pattern string, literal bool) Expr
- func (s StrExpr) Head(n int) Expr
- func (s StrExpr) Join(sep string) Expr
- func (s StrExpr) LenBytes() Expr
- func (s StrExpr) LenChars() Expr
- func (s StrExpr) PadEnd(width int, fill string) Expr
- func (s StrExpr) PadStart(width int, fill string) Expr
- func (s StrExpr) Replace(pattern, value string, literal bool) Expr
- func (s StrExpr) ReplaceAll(pattern, value string, literal bool) Expr
- func (s StrExpr) Reverse() Expr
- func (s StrExpr) Slice(offset, length int) Expr
- func (s StrExpr) Split(by string) Expr
- func (s StrExpr) SplitN(by string, n int) Expr
- func (s StrExpr) StartsWith(prefix string) Expr
- func (s StrExpr) StripChars(chars string) Expr
- func (s StrExpr) StripCharsEnd(chars string) Expr
- func (s StrExpr) StripCharsStart(chars string) Expr
- func (s StrExpr) StripPrefix(prefix string) Expr
- func (s StrExpr) StripSuffix(suffix string) Expr
- func (s StrExpr) Strptime(dt dtype.DataType, format string, strict bool) Expr
- func (s StrExpr) Tail(n int) Expr
- func (s StrExpr) ToDate() Expr
- func (s StrExpr) ToDatetime(unit dtype.TimeUnit, tz string) Expr
- func (s StrExpr) ToInteger() Expr
- func (s StrExpr) ToLower() Expr
- func (s StrExpr) ToUpper() Expr
- func (s StrExpr) ZFill(width int) Expr
- type StructExpr
- type ThenBuilder
- type TimeUnit
- type UnpivotOptions
- type WhenBuilder
- type WindowOption
- type WindowSpec
- type ZoneOption
Examples ¶
Constants ¶
const ( // ClosedDefault is the zero value and means "this operation's own convention": // both for IsBetween, left for GroupByDynamic, right for Rolling. ClosedDefault = expr.ClosedDefault ClosedLeft = expr.ClosedLeft ClosedRight = expr.ClosedRight ClosedBoth = expr.ClosedBoth ClosedNone = expr.ClosedNone )
The four window boundary conventions. ClosedLeft is the default for a window grid — an instant exactly on a boundary starts the new window — because it is the only one under which consecutive windows tile the line exactly.
const ( // InterpLinear interpolates proportionally between the neighbours. It is the // conventional default and the only option whose result need not be an input // value. InterpLinear = expr.InterpLinear // InterpLower and InterpHigher take the neighbour below or above the rank. InterpLower = expr.InterpLower InterpHigher = expr.InterpHigher // InterpNearest takes the closer neighbour, rounding halves upward. InterpNearest = expr.InterpNearest // InterpMidpoint averages the two neighbours regardless of the rank's position // between them. InterpMidpoint = expr.InterpMidpoint )
const ( // AsOfBackward is the default: the last right key at or before the left key. // "Attach the most recent quote to each trade." AsOfBackward = plan.AsOfBackward // AsOfForward is the first right key at or after the left key. AsOfForward = plan.AsOfForward // AsOfNearest is whichever is closer, ties going backward. AsOfNearest = plan.AsOfNearest )
const ( // ConcatStrict requires the same columns in the same order — the default. // // Types still promote and nullability still widens. Neither is a relaxation: // refusing to stack two frames that differ only in a nullability flag would be // absurd, because that is the ordinary result of filtering one of them. ConcatStrict = plan.ConcatStrict // ConcatDiagonal takes the union of the columns, filling each frame's missing // ones with nulls. This is what makes stacking heterogeneous files work. ConcatDiagonal = plan.ConcatDiagonal )
const ( // JoinInner keeps only matching pairs. The default. JoinInner = plan.JoinInner // JoinLeft keeps every left row, with right columns null where unmatched. JoinLeft = plan.JoinLeft // JoinRight is the mirror of JoinLeft. JoinRight = plan.JoinRight // JoinFull keeps every row from both sides. JoinFull = plan.JoinFull // JoinSemi keeps left rows that have at least one match. A filter: no right // column is added. JoinSemi = plan.JoinSemi // JoinAnti keeps left rows with no match. The inverse of JoinSemi. JoinAnti = plan.JoinAnti // JoinCross is the cartesian product. No keys. JoinCross = plan.JoinCross )
const ( ValidateNone = plan.ValidateNone // ValidateOneToOne requires unique keys on both sides. ValidateOneToOne = plan.ValidateOneToOne // ValidateOneToMany requires unique keys on the LEFT. ValidateOneToMany = plan.ValidateOneToMany // ValidateManyToOne requires unique keys on the RIGHT. This is the one that // catches accidental fan-out, which is the most common analytics bug there is. ValidateManyToOne = plan.ValidateManyToOne // ValidateManyToMany imposes no constraint. ValidateManyToMany = plan.ValidateManyToMany )
const ( Second = dtype.Second Milli = dtype.Milli Micro = dtype.Micro Nano = dtype.Nano )
Time resolutions.
const ( // MapGroupsToRows gives every row its own partition's value, in the original // row order. The default, and the only one that composes with other columns. MapGroupsToRows = expr.MapGroupsToRows // MapExplode leaves the output in partition order instead of permuting it back. // Cheaper, because it skips the inverse permutation — and it REORDERS the frame, // so it is only meaningful when every column is windowed the same way. MapExplode = expr.MapExplode // MapJoin would aggregate each partition into a List repeated across its rows. // Refused, because it is a synonym: Col(...).Implode().Over(...) is exactly // this under the default mapping. MapJoin = expr.MapJoin )
const ( // RankOrdinal gives every row a distinct rank, ties broken by input order. RankOrdinal = expr.RankOrdinal // RankDense gives tied rows the same rank and does not skip the next value: // [10,20,20,30] ranks as 1,2,2,3. RankDense = expr.RankDense // RankMin and RankMax give tied rows the lowest or highest of their positions: // [10,20,20,30] ranks as 1,2,2,4 and 1,3,3,4. RankMin = expr.RankMin RankMax = expr.RankMax // RankAverage gives tied rows the mean of their positions, so it returns // Float64 where every other method returns Uint32. RankAverage = expr.RankAverage )
Variables ¶
var ( Bool = dtype.Bool Int8 = dtype.Int8 Int16 = dtype.Int16 Int32 = dtype.Int32 Int64 = dtype.Int64 Uint8 = dtype.Uint8 Uint16 = dtype.Uint16 Uint32 = dtype.Uint32 Uint64 = dtype.Uint64 Float32 = dtype.Float32 Float64 = dtype.Float64 // Int128 is the accumulator and output type of integer Sum. Widening to 128 // bits is what makes an integer sum incapable of silently overflowing. Int128 = dtype.Int128 String = dtype.String Binary = dtype.Binary Date = dtype.Date NullT = dtype.Null )
Simple types.
var ( // ErrSchema is an unknown, duplicate or ambiguous column — or several files, // read as one, whose columns differ in anything but their order. ErrSchema = uerr.ErrSchema // ErrType is an operation not defined for the given types. ErrType = uerr.ErrType // ErrValue is a bad literal, an out-of-range cast, an unparseable pattern. ErrValue = uerr.ErrValue // ErrUnsupported is a well-formed request ursus cannot yet serve. ErrUnsupported = uerr.ErrUnsupported // ErrIO is a failure reading or writing a data source. ErrIO = uerr.ErrIO // ErrResource is a limit ursus refused to exceed: the query would have worked // with a larger WithMemoryLimit or a writable WithSpillDir. ErrResource = uerr.ErrResource // ErrInternal is a bug in ursus. Users should never legitimately see one. ErrInternal = uerr.ErrInternal )
Error kinds, for branching on a failure without matching on its message.
if errors.Is(err, ursus.ErrResource) { retry with a bigger memory limit }
These are re-exported because the sentinels themselves live under internal/, so no package outside this module could reach them — which made "a caller should be able to detect that without matching on a message" true only for callers inside the module.
var ( Datetime = dtype.Datetime Duration = dtype.Duration TimeOf = dtype.Time Decimal = dtype.Decimal List = dtype.List Array = dtype.Array Enum = dtype.Enum )
Parameterised type constructors.
var ( Of = dtype.Of NotNull = dtype.NotNull )
Of builds a nullable field; NotNull builds a non-nullable one.
Functions ¶
func LeaveProcessMemoryLimit ¶ added in v0.4.0
func LeaveProcessMemoryLimit()
LeaveProcessMemoryLimit keeps ursus from setting the Go runtime's soft memory limit when a query first runs under the default budget. Call it before the first query, in a program that manages the limit itself, or wants none and cannot set GOMEMLIMIT=off.
func SetProcessMemoryLimit ¶ added in v0.4.0
func SetProcessMemoryLimit() int64
SetProcessMemoryLimit sets the Go runtime's soft memory limit — the one GOMEMLIMIT sets — to nine tenths of the memory this process may use: the smaller of its cgroup's limit and the machine's RAM, as the default query budget reads them. It returns the limit in effect afterwards, in bytes; math.MaxInt64 means none.
ursus calls it itself, once, the first time a query runs under the default budget (step 128). Calling it at start-up does the same thing earlier.
Why ¶
The default budget is half the ceiling (WithMemoryLimit says why): ursus's buffers live on the Go heap, and with GOGC's default the heap grows to about twice what is live before the collector runs. Half leaves room for that, and it is still a race — a query holding its whole budget can take the heap to the ceiling. A soft limit near the ceiling makes the collector run harder as the heap approaches it, instead of letting the kernel kill the process. Measured (step 97): a 300 MB live set, with batches built and dropped around it, peaked at about 650 MB of runtime memory under the defaults and at about 440 MB under a 450 MB soft limit.
Why it is automatic ¶
The limit is the whole process's, not ursus's: it changes how the program around ursus collects garbage too, which is why step 97 left it to the caller. Measured since, that was the wrong trade. The budget counts what the operators hold, and the heap ran to about twice it: under an 8 GB cgroup, h2o's six-key group-by peaked at 7.6 GB against a 4 GB budget, and under a 4 GB cgroup a join of two ten-million- row tables was killed at the cap. A program that never called this, which is most of them, had a default budget that did not hold.
So it is set where ursus already decides how much memory the process may use: when a query runs under the default budget, which ursus derives from that same ceiling. Not on import, and not under a limit the caller gave WithMemoryLimit.
To keep ursus from setting it, set GOMEMLIMIT, "off" included, or call LeaveProcessMemoryLimit before the first query.
What it leaves alone ¶
A limit already chosen — GOMEMLIMIT in the environment, "off" included, or an earlier debug.SetMemoryLimit — is kept, and returned. Where no ceiling can be read (anywhere but Linux, macOS and Windows) nothing is set.
Types ¶
type Ambiguous ¶ added in v0.6.0
type Ambiguous string
Ambiguous answers a wall clock that ReplaceTimeZone's new zone reads twice, in the hour its clocks fall back: Polars' ambiguous.
const ( AmbiguousRaise Ambiguous = expr.ZoneRaise // refuse the query; the default AmbiguousEarliest Ambiguous = expr.ZoneEarliest // the first reading, before the clocks fell back AmbiguousLatest Ambiguous = expr.ZoneLatest // the second reading, after AmbiguousNull Ambiguous = expr.ZoneNull // a null )
type AsOfOption ¶
type AsOfOption func(*asOfCfg)
AsOfOption configures JoinAsOf.
func AsOfAllowExactMatches ¶
func AsOfAllowExactMatches(b bool) AsOfOption
AsOfAllowExactMatches decides whether a right key EQUAL to the left key may match. Default true.
func AsOfBy ¶
func AsOfBy(exprs ...Expr) AsOfOption
AsOfBy adds EXACT-match keys applied before the nearest-key search, so a trade matches only quotes for its own symbol.
Without it every left row searches one global run, which on any real fixture means matching the nearest row of the wrong instrument — a plausible number and the wrong one.
A null by-key matches nothing: a left row with one gets nulls, and a right row with one is never found — as in Polars and DuckDB, and as a null key does in every other ursus join unless NullsEqual says otherwise. By-keys of different types are compared at the type that holds both exactly, as a join's keys are.
func AsOfLeftBy ¶
func AsOfLeftBy(exprs ...Expr) AsOfOption
AsOfLeftBy and AsOfRightBy are AsOfBy for differently-named columns.
func AsOfLeftOn ¶
func AsOfLeftOn(e Expr) AsOfOption
AsOfLeftOn and AsOfRightOn name differently-named ordering keys. Used together.
func AsOfOn ¶
func AsOfOn(e Expr) AsOfOption
AsOfOn names the ordering key, present under the same name on both sides.
func AsOfRightBy ¶
func AsOfRightBy(exprs ...Expr) AsOfOption
func AsOfRightOn ¶
func AsOfRightOn(e Expr) AsOfOption
func AsOfStrategyOpt ¶
func AsOfStrategyOpt(s AsOfStrategy) AsOfOption
AsOfStrategyOpt picks backward (the default), forward or nearest.
func AsOfSuffix ¶
func AsOfSuffix(s string) AsOfOption
AsOfSuffix renames a colliding right column. Defaults to Join's suffix.
func AsOfTolerance ¶
func AsOfTolerance(i Interval) AsOfOption
AsOfTolerance bounds how far the search may reach. A candidate further than this from the left key does not match, and the left row comes back null-padded.
The bound is recomputed per row for a CALENDAR interval, so Every("1mo") is a different distance in February than in March — which is the whole reason Interval is not a duration.
type AsOfStrategy ¶
type AsOfStrategy = plan.AsOfStrategy
AsOfStrategy picks which neighbouring right row a left row matches.
type CSVFile ¶ added in v0.3.0
type CSVFile struct {
// Name identifies the file in errors and in Explain.
Name string
// Open returns the file's bytes from the start.
//
// It is called more than once per query — once to infer the schema, once for
// each execution — and must return a fresh stream each time. ctx is the query's
// and outlives the stream, which ursus closes when it is done with it.
Open func(ctx context.Context) (io.ReadCloser, error)
}
CSVFile is one CSV file, read as a stream from wherever it lives — an object store, an HTTP server, an archive — by code that knows how to reach it.
type CSVOption ¶
CSVOption configures ScanCSV.
func WithColumnNames ¶
WithColumnNames overrides the column names positionally.
func WithComment ¶
WithComment makes lines starting with prefix comments. Multi-byte prefixes such as "//" are supported.
func WithHasHeader ¶
WithHasHeader says whether the first record holds column names. Default true.
func WithInferRows ¶
WithInferRows bounds how many records inference reads. 0 reads the whole file, which is exact and, on a large file, expensive. Default 100.
func WithMaxRecordSize ¶
WithMaxRecordSize bounds a single record. Default 16 MiB. The bound exists so an unterminated quote is an error rather than an out-of-memory kill.
func WithNullValues ¶
WithNullValues adds texts that mean NULL. An empty field already means null for every type; in a String column a quoted "" is the empty string, as Polars reads it, so the two stay apart.
func WithSchema ¶
WithSchema supplies the schema and skips inference entirely. On a large file this is the option that matters: inference costs a read of the sample, and an explicit schema also removes the risk of a column being typed from rows that do not represent it.
func WithSchemaOverrides ¶
WithSchemaOverrides fixes individual columns while inferring the rest. The usual case is an identity column that looks numeric and must not be.
func WithSeparator ¶
WithSeparator sets the field delimiter. Default ','.
func WithSkipRows ¶
WithSkipRows discards n records before the header.
func WithTruncateRaggedLines ¶
WithTruncateRaggedLines accepts records with the wrong field count: extra fields are dropped and missing ones become null. Off by default, because a changed field count usually means the file is not what the reader thinks it is.
type CSVSinkOption ¶
type CSVSinkOption interface {
// contains filtered or unexported methods
}
CSVSinkOption is anything SinkCSV and WriteCSV accept: a writer option or an execution option. See ParquetSinkOption for why it is an interface.
type CSVWriteOption ¶
type CSVWriteOption func(*csv.WriteOptions)
CSVWriteOption configures the CSV writer.
func WithLineTerminator ¶
func WithLineTerminator(s string) CSVWriteOption
WithLineTerminator sets the record separator. Default "\n".
func WithNullValue ¶
func WithNullValue(s string) CSVWriteOption
WithNullValue sets the text written for a null. Default "".
The default reads back as null for every type. An empty String is written as a quoted "", which reads back as the empty string, so null and empty strings both round-trip — as Polars writes and reads them.
func WithWriteHeader ¶
func WithWriteHeader(b bool) CSVWriteOption
WithWriteHeader says whether to write column names first. Default true.
func WithWriteSeparator ¶
func WithWriteSeparator(c byte) CSVWriteOption
WithWriteSeparator sets the delimiter. Default ','.
type Closed ¶
Closed says which endpoints of an interval belong to it: [lo, hi), (lo, hi], [lo, hi] or (lo, hi). Used by IsBetween and by the temporal group-by's windows.
type CollectOption ¶
type CollectOption func(*collectCfg)
CollectOption configures execution.
func WithBatchSize ¶
func WithBatchSize(n int) CollectOption
WithBatchSize sets the rows per batch. Results must not depend on it; the test suite varies it precisely to check that.
func WithMemoryLimit ¶
func WithMemoryLimit(bytes int64) CollectOption
WithMemoryLimit caps what buffering operators may hold, in bytes.
df, err := ursus.ScanParquet("120gb/*.parquet").
Sort(ursus.Desc(ursus.Col("ts"))).
SinkParquet(ctx, "sorted.parquet",
ursus.WithMemoryLimit(512<<20),
ursus.WithSpillDir("/tmp/ursus"))
What it bounds ¶
Sort spills past the limit and merges the runs back, so a sort over more data than memory works. Group-by spills too, by radix-partitioning the keys it cannot hold: past the limit new keys are routed to one of sixteen files per level and re-aggregated afterwards. Join partitions BOTH sides on the join key and replays the pairs, so an equi-join over more data than memory works as well. Unique routes the rows whose keys it cannot hold the same way, and a window with a partition_by routes whole partitions; both stamp each row with its input position and merge back on it, so their answers keep their order exactly.
Reverse writes what it holds to a file past the limit, and gives the files back last to first, each read whole, so it holds one at a time.
Hstack and tail cannot spill: past the limit they FAIL, with an error naming the operator, which is a better outcome than being killed by the OS with no explanation. Neither can a window with no partition_by that computes a rank, an ewm function or an interpolation, which need the whole frame, nor one whose single partition is larger than the limit; with only aggregates, cumulative functions, shifts, limited fills and rolling windows of rows, such a frame or partition is written to one file and read back. JoinAsOf and MergeSorted stream both sides, holding a few batches; a forward or nearest JoinAsOf also holds the right rows it reads ahead, which for a left by-key the right side lacks is the rest of that side. Rolling and GroupByDynamic stream their input; with no key they hand their windows out as they close, a batch's worth at a time, and with a key they hold their answer, which they give per bucket.
A bounded sort — one under a Head or a TopK — never spills at all: past the limit it discards everything outside the current best k, which is exact.
The one thing the limit CAN change ¶
An unordered group-by's ROW ORDER depends on the limit, because the point at which it stops admitting new keys does. Past that point a new key goes to a partition file and comes back after every resident group, so the output is resident-first then partition-major. A spilling JOIN is the same, for the same reason: rows whose key stayed in memory come out in probe order, and the rest follow bucket by bucket.
The CONTENT is invariant: same rows, same values, at every limit and at none. Only the sequence moves. A group-by can opt out with GroupBy.MaintainOrder, which restores first-appearance order exactly, spilled or not.
A JOIN CANNOT, and that is a decision rather than an omission. Restoring probe order means holding the whole output to permute it, and a join's output can be larger than both of its inputs — so the flag would only work in the cases that did not need it. TestMemoryLimitIsNotASemanticKnob pins the content half for both, and TestJoinOrderIsUnspecifiedUnderALimit pins that the order really does move.
What a group-by's limit does NOT bound ¶
Partitioning divides the KEY SPACE, so it bounds a group-by whose problem is cardinality. It cannot divide a single key, and quantile, median and n_unique hold state per VALUE rather than per group — so a group-by over a few very large groups still fails, with an error that says which of the two situations it is in.
The join has the same wall in two places: a join with no key and a residual, a WhereExists of a predicate alone, has nothing to divide, and one join key with more build rows than the limit cannot be split however deep the recursion goes. Both refuse with a message naming which it is. A plain CROSS join has no key either, and needs none: past the limit its build side goes to disk whole, and each probe row reads it back. A window has the wall too: one partition with more rows than the limit refuses, saying so.
Without it ¶
A query with no WithMemoryLimit is budgeted at half the memory it can have: the smaller of its cgroup's limit — a container's, or a systemd slice's — and the machine's RAM, on Linux. Half, because ursus's buffers are on the Go heap, which with GOGC=100 grows to about twice what is live before it collects. So a query spills by default rather than growing until the kernel kills it, and an operator that cannot spill fails at that point with an error naming itself. Collect also refuses a result too large to hold: see Collect. WithMemoryLimit(0) turns the budget off; elsewhere than Linux there is no default and it is off already.
The heap's slack is the Go runtime's to bound, not the budget's. Under the default budget ursus sets the Go soft memory limit too, to nine tenths of the same ceiling, unless GOMEMLIMIT or an earlier debug.SetMemoryLimit chose one: see SetProcessMemoryLimit. Under a limit given here, it does not, and a program running under a container's limit should call SetProcessMemoryLimit itself.
A group-by under a limit you set is serial ¶
So that a group-by that spills gives its rows in the same order on every run.
Under the DEFAULT budget it runs on several workers until the query holds half the budget, then folds them into one and finishes serially, spilling as the serial path does. Half, because the fold builds the merged table while the workers' still exist. The batches the workers already had queued are aggregated first, so the switch can come a few megabytes late at the default batch size. Where it switches depends on scheduling, so an unordered group-by that gets that far can give its rows in a different order from one run to the next; their contents do not change. MaintainOrder, or a limit of your own, makes the order reproducible.
What it does not bound ¶
Collect materialises the whole result by definition, and under a limit you give, asking for a frame in memory is a request to hold it: Collect holds whatever it is asked to. The default budget checks it, which a limit of yours turns off. The larger-than-RAM story runs through SinkParquet and CollectBatches, both of which stream.
Example ¶
Under a memory limit, sort, group-by, join, unique and partitioned windows spill to disk and give the answer they give in memory; an operator that cannot spill fails with an error naming itself.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/advenn/ursus"
)
func main() {
keys := make([]int64, 40000)
for i := range keys {
keys[i] = int64(i * 7919 % 20000) // each of 20000 keys, twice
}
var stats ursus.MemoryStats
n, err := ursus.Frame(ursus.Values("k", keys)).Unique("k").
Count(context.Background(), ursus.WithBatchSize(1024),
ursus.WithMemoryLimit(16<<10), ursus.WithSpillDir(os.TempDir()),
ursus.WithMemoryStats(&stats))
if err != nil {
log.Fatal(err)
}
fmt.Println(n, "distinct keys; spilled:", stats.Spills > 0)
}
Output: 20000 distinct keys; spilled: true
func WithMemoryStats ¶
func WithMemoryStats(out *MemoryStats) CollectOption
WithMemoryStats stores the query's memory accounting into *out when the query finishes.
It is written at the end of Collect, Count, CollectInto, SinkParquet, SinkCSV, WriteParquet and WriteCSV, and after the last batch of CollectBatches — so reading it early gives a partial figure rather than a wrong one.
The sinks matter most and were the ones this list used to omit: they are the consumers that stream, so they are where a memory limit is worth setting.
func WithOptFlags ¶
func WithOptFlags(f OptFlags) CollectOption
WithOptFlags overrides which optimizer rules run. Use it to benchmark a rule's value or to bisect a wrong answer down to the rule that caused it. It took the internal plan.Flags until step 186, which no caller outside the module could build.
func WithSpillDir ¶
func WithSpillDir(dir string) CollectOption
WithSpillDir chooses where spill files are written. The default is the system temporary directory. Files are removed when the query finishes, including on error and on an early break out of CollectBatches.
func WithThreads ¶
func WithThreads(n int) CollectOption
WithThreads sets how many workers a query runs on. Default runtime.NumCPU().
Results do not depend on it. Parallelism over the PIPELINE — the scan and the stateless per-batch operators above it — is ORDER-PRESERVING: workers process batches concurrently and the results are read back in input order, so the same query returns byte-identical output at any thread count. That is worth stating because it is not what every engine does, and because five things in ursus quietly depend on batch order — stable sort, First and Last, distinct's first-row rule, limit, and first-appearance group ids.
WithThreads(1) reproduces the serial operator tree exactly, which is the knob to reach for when bisecting.
Group-by also runs N-ways, and only where that cannot change an answer ¶
Since step 17 a hash aggregation is drained by several sinks whose partial tables are merged. It is NOT order-preserving in the same structural way — the dispatcher is round-robin, so no worker holds a contiguous portion of the input — so it is used only where the result cannot depend on order at all: not under MaintainOrder, not with First, Last, ArgMin or ArgMax, and not under a memory limit, because a sink that has spilled cannot be merged. Every one of those falls back to the serial path silently and exactly.
Sort and join breakers remain serial. They still benefit, because their input arrives faster.
The trade, stated: a parallel group-by holds roughly N times the group table, because each worker builds its own. That is why the memory limit disables it rather than dividing the budget.
func WithVerify ¶
func WithVerify() CollectOption
WithVerify makes the optimizer assert schema preservation after every rule. On in tests, off by default because it costs a schema resolution per rule.
type Column ¶
Column is a named, typed, nullable run of values.
func Values ¶
Values builds a column from Go values, with no nulls.
The element type determines the column type: []int64 gives Int64, []string gives String, []time.Time gives Datetime(ns, UTC).
func ValuesNullable ¶
ValuesNullable builds a column with an explicit validity mask, where valid[i] false means row i is null.
Nulls are a parallel bitmap rather than a *T or an Option[T] because boxing would cost an allocation per row and make the values buffer non-contiguous — exactly what a columnar layout must not do.
type ConcatMode ¶
type ConcatMode = plan.ConcatMode
ConcatMode says how much Concat will reconcile differing schemas.
type ConcatOption ¶
type ConcatOption func(*concatCfg)
ConcatOption configures Concat.
func WithConcatMode ¶
func WithConcatMode(m ConcatMode) ConcatOption
WithConcatMode selects strict or diagonal reconciliation.
type CutOption ¶ added in v0.5.0
type CutOption func(*cutOptions)
CutOption configures Cut and QCut.
func CutLabels ¶ added in v0.5.0
CutLabels names the bins, lowest first: one label more than there are breaks. Without it, a bin is named by its interval, "(-inf, 1]", "(1, 2.5]", "(2.5, inf]".
func CutLeftClosed ¶ added in v0.5.0
func CutLeftClosed() CutOption
CutLeftClosed closes each bin on the left, [1, 2.5), where the default, Polars' too, closes it on the right, (1, 2.5].
type DataFrame ¶
type DataFrame struct {
// contains filtered or unexported fields
}
DataFrame is a materialised result: named, equal-length, typed columns.
There is no row index. Row order is a property of the data, not an addressable label space — the single most consequential thing Polars got right and pandas did not.
func (*DataFrame) ArrowSchema ¶ added in v0.3.0
ArrowSchema returns the frame's schema as an Arrow schema.
It can fail, because a few ursus types have no Arrow equivalent yet — see Record. Asking for the schema is how to find that out without running anything.
func (*DataFrame) Column ¶
Column returns a typed view of a column.
A generic METHOD — the thing Go 1.27 unlocked. Previously this had to be `ursus.Column[int64](df, "x")`, with the receiver demoted to an argument.
Example ¶
Reading values back out. Column gives a typed series; At gives one cell and whether it was null.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
df, err := exampleSales().Collect(context.Background())
if err != nil {
log.Fatal(err)
}
// Column and At are GENERIC METHODS — Go 1.27's headline feature, and the
// reason ursus can hand back typed data without a cast at the call site.
qty, err := df.Column[int64]("qty")
if err != nil {
log.Fatal(err)
}
fmt.Println("values:", qty.Values())
v, ok, err := df.At[string](0, "region")
if err != nil {
log.Fatal(err)
}
fmt.Printf("row 0 region: %q present=%v\n", v, ok)
}
Output: values: [10 4 7 2 9] row 0 region: "north" present=true
func (*DataFrame) ColumnAt ¶ added in v0.6.0
ColumnAt returns the i-th column, in order, untyped: what DataFrame.Batch gave before step 186, a column at a time, through the public Column rather than the internal batch.
func (*DataFrame) Join ¶
func (df *DataFrame) Join(ctx context.Context, other *DataFrame, opts ...JoinOption) (*DataFrame, error)
Join combines two frames on keys.
func (*DataFrame) JoinWhere ¶ added in v0.3.0
func (df *DataFrame) JoinWhere(ctx context.Context, other *DataFrame, preds ...Expr) (*DataFrame, error)
JoinWhere joins every pair of rows that satisfies the predicates.
func (*DataFrame) Lazy ¶
Lazy turns a materialised frame back into a query.
The obvious spelling is wrong in three specific ways ¶
`ursus.Frame(df.batch.Columns()...)` compiles and runs, because Column is a type alias — and it is wrong:
- It would re-derive nullability from the data. Field.Nullable is a static property of the schema, not a count of nulls actually present, so a nullable column that happens to hold none — the ordinary result of a filter — would come back non-nullable and every downstream join and cast would reason from the wrong schema.
- It would lose the row count of a frame with rows but no columns.
- It would alias the batch's own column slice, against Columns()' contract.
So this goes through the batch, which already carries the declared schema and the row count.
It is free: no copy, no re-derivation. The batch is immutable and shared.
func (*DataFrame) Record ¶ added in v0.3.0
Record exports the frame as an Arrow record, sharing its memory.
This is the zero-copy boundary to the rest of the Arrow ecosystem: hand the result to arrow-go's IPC writer, to Flight, to ADBC, or to any library that speaks arrow.Record, and no column data is copied on the way.
rec, err := df.Record()
if err != nil { return err }
defer rec.Release()
w := ipc.NewWriter(f, ipc.WithSchema(rec.Schema()))
w.Write(rec)
Release is optional here, and that is deliberate ¶
The record's buffers are re-wrapped so that Release on them cannot reach ursus's own memory. Releasing is still the right habit — every other arrow-go producer expects it, and the example above does it — but forgetting leaks nothing, and releasing twice cannot empty a frame you still hold. internal/arrowout's doc explains why that second guarantee needed arranging.
It can fail ¶
Array, Enum, Categorical and Uint128 are declared in ursus's type enum and are not exportable yet; a frame containing one is refused by name rather than exported wrongly. Everything else maps, including Decimal and the Int128 that every integer Sum produces.
func (*DataFrame) String ¶
String renders the frame as an aligned table.
Display quality is not cosmetic: this is what people look at all day, and a column of unaligned numbers with no visible types is the difference between a library that feels finished and one that does not.
func (*DataFrame) Unpivot ¶ added in v0.3.0
Unpivot turns several value columns into a variable/value pair.
func (*DataFrame) ValueCounts ¶ added in v0.5.0
ValueCounts is LazyFrame.ValueCounts, collected.
func (*DataFrame) WhereExists ¶ added in v0.3.0
func (df *DataFrame) WhereExists(ctx context.Context, other *DataFrame, preds ...Expr) (*DataFrame, error)
WhereExists keeps rows with at least one satisfying partner in other.
type DtExpr ¶
type DtExpr struct {
// contains filtered or unexported fields
}
DtExpr is the temporal namespace: `Col("ts").Dt().Year()`.
Components are read in the column's own timezone ¶
2024-01-01T00:00:00+09:00 is hour 0 in Tokyo and hour 15 in UTC. A Datetime carries its zone, and every component below is read in it — otherwise `hour` would be wrong for every row of any non-UTC column, silently.
Durations have no components ¶
`.Year()` on a Duration is refused at plan time. A duration is a length, not a point in time; "the year of 90 minutes" has no answer. Use the Total* family.
func (DtExpr) ConvertTimeZone ¶ added in v0.5.0
ConvertTimeZone reads a zoned Datetime's instants in another zone: the same instants, whose components, Strftime and rendering now follow tz. Polars' dt.convert_time_zone.
Col("ts").Dt().ConvertTimeZone("Asia/Tokyo") // 09:00 UTC reads as 18:00
No value changes, so it costs nothing per row. A naive Datetime names no instant to convert and is refused; if its wall clocks are UTC, cast it to Datetime(unit, "UTC") first. A zone the system does not know is refused while the query is planned.
func (DtExpr) Epoch ¶
Epoch is whole seconds since 1970-01-01T00:00:00Z, floored: the second an instant lies in, so half a second before the epoch is -1.
func (DtExpr) IsLeapYear ¶ added in v0.5.0
IsLeapYear reports whether a Date or Datetime falls in a year of 366 days, the year read in the column's own zone.
func (DtExpr) Microsecond ¶
func (DtExpr) Millisecond ¶
func (DtExpr) MonthEnd ¶ added in v0.5.0
MonthEnd moves a Date or Datetime to the last day of its month, keeping the time of day, as MonthStart does: Polars' dt.month_end. February 2024 ends on the 29th.
func (DtExpr) MonthStart ¶ added in v0.5.0
MonthStart moves a Date or Datetime to the first day of its month, keeping the time of day on its wall clock: Polars' dt.month_start. A Datetime's month is the one in its own zone.
func (DtExpr) Nanosecond ¶
func (DtExpr) OffsetBy ¶ added in v0.5.0
OffsetBy moves each Date or Datetime by an interval, keeping its type: Polars' dt.offset_by.
Col("due").Dt().OffsetBy(Every("1mo")) // the same day next month, clamped
Col("ts").Dt().OffsetBy(-90 * time.Minute) // ninety minutes earlier
Months and days move the WALL CLOCK in the column's own zone: one day after 09:00 is 09:00 the next day across a daylight-saving change, and January 31st plus a month is the last day of February. Nanoseconds, and a time.Duration, move elapsed time. A wall clock the zone skips gives the instant the gap ends; one it repeats, the reading at the input's own offset when that offset holds there.
An offset the type cannot hold is refused while the query is planned: an hour on a Date, a nanosecond on Datetime(us). An answer past the type's range is refused when it is computed. A Time has no date to move: add a Duration to it instead.
func (DtExpr) OrdinalDay ¶
OrdinalDay is the day of the year, 1-366.
func (DtExpr) ReplaceTimeZone ¶ added in v0.6.0
func (d DtExpr) ReplaceTimeZone(tz string, opts ...ZoneOption) Expr
ReplaceTimeZone keeps each value's wall clock and reads it in the zone tz, so the instant moves: Polars' dt.replace_time_zone. A naive Datetime gains a zone, a zoned one changes it, and tz "" makes it naive, keeping the clock as it read.
Col("ts").Dt().ReplaceTimeZone("Asia/Tokyo") // 09:00 naive reads 09:00 in Tokyo, 00:00 UTC
The new zone may read a wall clock twice, in the hour its clocks fall back, or never, in the hour they spring forward. By default either refuses the query, naming the clock; AmbiguousEarliest, AmbiguousLatest or AmbiguousNull, and NonExistentNull, answer them instead. A value already in tz is kept as it is, as Polars keeps it. A zone the system does not know is refused while the query is planned.
Where Polars differs: it also takes a column of policies, one per row. Here a policy is the whole column's.
func (DtExpr) Round ¶ added in v0.5.0
Round rounds a Date or Datetime to the nearer boundary of Truncate's grid, and a value exactly halfway up: Polars' dt.round.
Col("ts").Dt().Round(15 * time.Minute) // 10:07:30 → 10:15, 10:07:29 → 10:00
Col("ts").Dt().Round(Every("1mo")) // the nearer first of a month
The grid is Truncate's, in both spellings, and nearer is measured in elapsed time: the middle of February 2024 is 2024-02-15T12:00, and the middle of a 23-hour day 11:30 after its start. A Time is refused, because it can round up to 24:00, which it cannot hold.
func (DtExpr) Strftime ¶ added in v0.4.0
Strftime formats a Date, Datetime or Time with a strftime-style format, in a zoned Datetime's own wall clock — Polars' dt.strftime.
Col("day").Dt().Strftime("%A, %e %B %Y") // "Friday, 9 February 2024"
The directives are Strptime's. A format that asks for what the value does not have is refused while the query is planned: a date from a Time, or an offset or zone name (%z, %Z) from anything but a zoned Datetime.
func (DtExpr) ToString ¶
ToString formats using the same ISO 8601 layouts the CSV writer emits, so a frame printed to a terminal and a frame written to a file agree.
func (DtExpr) TotalDays ¶
The Total family converts a Duration to a whole number of units, truncating. They are the only temporal functions defined on a Duration.
func (DtExpr) TotalHours ¶
func (DtExpr) TotalMinutes ¶
func (DtExpr) TotalSeconds ¶
func (DtExpr) Truncate ¶
Truncate floors an instant to a multiple of every, toward negative infinity so instants before the epoch move backwards like every other instant.
A Duration floors ABSOLUTELY; an Interval floors on the CALENDAR ¶
The two spellings answer different questions and neither is a special case of the other:
Truncate(time.Hour) the instant, floored to a whole hour since the epoch
Truncate(Every("1d")) the start of the LOCAL day, in the column's own zone
Truncate(Every("1h")) the start of the LOCAL hour — 10:00 for 10:47 at +05:30,
where time.Hour gives 10:30, the hour since the epoch
An Interval floors the wall clock at every size. Where the floored wall clock falls into a spring-forward gap, the answer is the instant the gap ends; in a fall-back fold, the input keeps its own offset, so the second 01:30 floors to the second 01:00. Both are Polars' answers.
A Duration is a fixed span of elapsed time, so flooring by one is zone-independent by definition — `Truncate(24*time.Hour)` on a New York column lands on 00:00 UTC, which is 19:00 or 20:00 local, and that is the correct answer to the question a Duration asks. It is almost never the question a user grouping by day is asking, which is why Every("1d") exists and reads the column's timezone.
func (DtExpr) Week ¶
Week is the ISO 8601 week number, which is not "day of year / 7" — the first week of a year can begin in the previous one.
type DynamicOptions ¶
type DynamicOptions struct {
// Every is where windows START. Consecutive windows are Every apart.
Every Interval
// Period is a window's WIDTH. Zero means Every, which gives windows that tile
// the input exactly. A Period larger than Every gives OVERLAPPING windows, and a
// row then belongs to several of them — so a Len() over the result exceeds the
// input's row count, which is the correct answer and surprises everyone once.
Period Interval
// Offset shifts the whole grid. A day grid with Offset 9h starts its windows at
// 09:00 rather than at midnight.
Offset Interval
// Closed says which end of a window belongs to it. The zero value is
// ClosedLeft — [start, end) — which is the only convention under which a grid
// partitions its input exactly.
Closed Closed
// GroupBy adds CATEGORICAL keys. Windows are cut independently inside each
// distinct combination, so an hourly rollup per service is one call.
GroupBy []Expr
// Label is what each window's index column holds: "left", its start, the default;
// "right", its end, the start plus Period; or "datapoint", the instant of its
// first row, or its start for a window with no row. Polars' label (step 187).
Label string
// StartBy is where the grid begins: "window", Every's own grid, the default;
// "datapoint", the first row's instant, with Offset ignored, as Polars ignores it;
// or a weekday, "monday" to "sunday", that a grid of weeks starts on: the one at
// or before the grid's own first Monday, plus Offset. A weekday on a grid not in
// weeks is refused: Polars documents it for weeks alone. Polars' start_by.
StartBy string
// IncludeBoundaries adds _lower_boundary and _upper_boundary, each window's start
// and end, before the index column: Polars' include_boundaries.
IncludeBoundaries bool
}
DynamicOptions configures GroupByDynamic.
Five fields, and three that are refused rather than ignored ¶
Every, Period, Offset, Closed and GroupBy are implemented. Label, StartBy and IncludeBoundaries are named in the design docs and are NOT — and they produce an error saying so rather than being accepted and dropped.
That is deliberate and it is a lesson this library has already paid for once: plan.Aggregate.MaintainOrder sat assigned, rendered and unread for four steps, and its own doc had to say "the flag documents the guarantee rather than changing behaviour". A field that is accepted and does nothing is worse than one that does not exist, because the next reader believes it.
type EwmDecay ¶ added in v0.5.0
type EwmDecay struct {
// contains filtered or unexported fields
}
EwmDecay is how fast an exponentially weighted function forgets: the weight a row loses with each row after it. Build it from whichever quantity is to hand, as Polars' com, span, half_life and alpha are.
func EwmAlpha ¶ added in v0.5.0
EwmAlpha is the decay itself: each row after keeps 1-alpha of a row's weight. 0 < alpha <= 1.
func EwmCom ¶ added in v0.5.0
EwmCom is a decay by a centre of mass: alpha = 1/(1+com), for com >= 0.
func EwmHalfLife ¶ added in v0.5.0
EwmHalfLife is a decay by the rows over which a weight halves: alpha = 1 - exp(-ln 2 / halfLife), for halfLife > 0.
type ExplainOption ¶
type ExplainOption func(*explainCfg)
ExplainOption configures Explain.
func Optimized ¶
func Optimized(b bool) ExplainOption
Optimized selects the optimized plan (the default) or the plan as written.
func WithSchemas ¶
func WithSchemas() ExplainOption
WithSchemas annotates every plan node with its output schema.
type Expr ¶
type Expr struct {
// contains filtered or unexported fields
}
Expr is a lazy description of a column transformation.
It is a CONCRETE STRUCT rather than an interface, and that is forced by Go 1.27: interface methods may not declare type parameters, and a generic method can never satisfy an interface method. Since Gt[T Operand] — the thing that makes `Col("age").Gt(30)` work without wrapping 30 in Lit — must be a generic method, Expr cannot be an interface. Polymorphism lives in the unexported expr.Node instead.
func Coalesce ¶
Coalesce returns the first non-null value across the given expressions.
Built out of conditionals rather than its own kernel ¶
Coalesce(a, b, c) becomes when(a.is_not_null()).then(a).otherwise(coalesce(b, c)), which is correct and costs one thing worth knowing: each operand is MENTIONED twice, and there is no common-subexpression pass, so each is EVALUATED twice. For the ordinary Coalesce(Col("a"), Col("b")) that is two extra column reads; for an operand that is itself an expensive expression it is not free.
A dedicated n-ary null-merge kernel would remove that and change no semantics. It is deliberately not built yet: the desugaring needs no new IR node, no new kernel and no new arms in the five type switches a node type must reach.
func Col ¶
Col selects one or more columns by name.
With several names it becomes a multi-column selection that expands at plan time: `Col("a","b").Mul(2)` is two independent expressions.
func ColDType ¶
ColDType selects every column of one of the given types. How many columns that is is determined at plan time from the schema, so the same expression adapts as the schema changes.
func ColRegex ¶
ColRegex selects every column whose name matches pattern.
The pattern is explicit rather than inferred from leading ^ and trailing $ the way Polars does. That inference makes a column literally named "^total$" unselectable and taxes every plain Col call with an anchor scan.
func ConcatStr ¶ added in v0.5.0
ConcatStr joins its expressions' values, row by row, with sep between each two: Polars' concat_str.
ConcatStr(" ", Col("first"), Col("last")) // "Ada Lovelace"
ConcatStr("", Col("code"), Lit("-"), Col("n")) // "AB-7", n formatted
A value that is not a String is formatted as a Cast to String formats it. A row is null where any of its values is, as Polars' default is; to skip a null instead, fill it first, e.g. Col("middle").FillNull(""). The answer is named after the first expression.
func Corr ¶ added in v0.6.0
Corr is Pearson's correlation of a and b over the rows where both are non-null: Polars' pl.corr. It is a Float64, named after a, and NaN, never null, where it is undefined: no such rows, one, or a variable that does not vary. A NaN in either makes it NaN.
GroupBy(Col("g")).Agg(Corr(Col("x"), Col("y")))
It runs on every worker and merges by Chan's formulas, as Var does. Spearman's rank correlation, Polars' method="spearman", is not built.
func Cov ¶ added in v0.6.0
Cov is the covariance of a and b over the rows where both are non-null: Polars' pl.cov. ddof is Var's: 1 for the sample covariance, Polars' default, and 0 for the population's. A group of ddof or fewer such rows is null, as Var is.
Where Polars differs: over a single row with ddof 1, it answers 0.0 when neither input has a null, and null when nulls left the one row; here both are null.
func ExcludeRegex ¶
ExcludeRegex selects every column whose name does not match pattern.
func Len ¶
func Len() Expr
Len counts rows in the current group — SQL's COUNT(*) with no column argument.
It aggregates over a literal rather than a column, because counting rows must not depend on any column existing: `GroupBy(k).Agg(Len())` has to work on a frame whose only column is the key.
func Struct ¶ added in v0.5.0
Struct builds a Struct column whose fields are its expressions, each named as its expression is: Polars' pl.struct. Struct().Field reads a field back.
Struct(Col("lat"), Col("lon")).Alias("point") // {lat: 51.5, lon: -0.1}
Two expressions of one name would be two fields Field could not tell apart, so the query is refused; alias one. The struct itself is never null: a row whose values are all null is a struct of nulls, which StructExpr's doc tells apart from a null struct. The answer is named after the first expression.
func (Expr) Alias ¶
Alias renames the output column.
It sets ONE fixed name, so applying it to a multi-column selection is an error. Use Prefix or Suffix to rename an expansion.
func (Expr) Any ¶
Any and AllTrue reduce a Boolean column.
AllTrue rather than All, because All is already the top-level selector for every column and one name cannot mean both.
Nulls are SKIPPED, so a group with no non-null value gives NULL rather than false (for Any) or true (for AllTrue). Polars returns the vacuous answer; ursus returns null, because every other aggregate here does and an inconsistent one is a permanent trap. FillNull makes either convention available.
func (Expr) ArgMin ¶
ArgMin and ArgMax give the POSITION of the smallest or largest value within the group, as a Uint64.
The index counts every row of the group, nulls included, so it lines up with anything else gathered from the same group — but nulls can never win, and a group with no non-null value has no position and returns NULL.
Ties go to the earliest row, which is what makes the answer independent of batch size and thread count.
func (Expr) BackwardFill ¶
BackwardFill replaces each null with the next non-null value after it.
It is ForwardFill walked in the other direction, which costs nothing: the ordered walk flips its direction rather than building a second permutation.
func (Expr) BitwiseCountOnes ¶ added in v0.5.0
The bit counts of an integer of 8 to 64 bits, each a Uint32, in the type's own width: Polars' bitwise_count_ones and its siblings. An Int8 -1 has eight ones and no zeros, and an Int8 1 has seven leading zeros where an Int64 1 has 63. Zero's trailing zeros are its width.
func (Expr) BitwiseCountZeros ¶ added in v0.5.0
func (Expr) BitwiseLeadingOnes ¶ added in v0.5.0
func (Expr) BitwiseLeadingZeros ¶ added in v0.5.0
func (Expr) BitwiseTrailingOnes ¶ added in v0.5.0
func (Expr) BitwiseTrailingZeros ¶ added in v0.5.0
func (Expr) BottomK ¶ added in v0.4.0
BottomK is the k smallest values of the group, smallest first, as a List. See TopK, whose rules it follows.
func (Expr) Cast ¶
Cast converts to another type, failing the query on the first unrepresentable value.
What is representable depends on the target:
- An integer, a Decimal: exactly the value, in range. 3.5 is not an Int64, and 0.123 is not a Decimal(10, 2).
- A float: the nearest value of its width, so 0.1 cast to Float32 is the float32 nearest 0.1, and an Int64 above 2^53 is the float64 nearest it. Only a finite value past the type's range — one that would round to an infinity — is unrepresentable.
- From a String: the text parsed at the target's own width, by the same rules. "256" is not a Uint8, and text that is no number cannot be parsed at all.
func (Expr) CastLossy ¶
CastLossy converts to another type, turning unrepresentable values into nulls. The result is always nullable as a consequence.
func (Expr) Clip ¶
Clip bounds each value to [lo, hi].
Sugar, and that is what lets the bounds be expressions ¶
When(e.Lt(lo)).Then(lo).When(e.Gt(hi)).Then(hi).Otherwise(e)
A parameterised Call could not do this: CallArgs requires every argument to be a literal and rejects a column-valued one by name. As a conditional, Clip takes Expr bounds naturally — Col("x").Clip(Col("floor"), Col("cap")) works.
The bounds are WEAK literals, like a fill value ¶
So Col("i32").Clip(0, 100) stays Int32. It used to become Int64, because the When/Then builder lifts strongly — and Col("u64").Clip(0, 100) became Int128, which is a startling type for clipping an unsigned column to a small range. That is exactly the inconsistency weak literals were built to remove: both spellings look like "a Go scalar bound on a column", and only one of them preserved the type. A bound that does not fit falls back to ordinary promotion, the same as a fill value that does not fit.
An Expr bound is used as written, so Clip(Lit(0), Lit(100)) still widens.
Edge cases, each of which falls out rather than being coded ¶
e is null → the comparisons are null → neither branch → null e is NaN → NaN < lo and NaN > hi are both false → e, unchanged a bound is null → every row's mask is null → the whole column is null lo > hi → lo wins, because the chain is right-nested in written order
The third is the one to watch: a null bound is not "no bound", it is a null result. Use two separate clips if only one side should apply.
func (Expr) CumCount ¶
CumCount numbers rows within the window, starting at 1.
With reverse it counts from the end, so the last row is 1. That is what makes IsLastDistinct expressible as a window rather than a second pass.
func (Expr) CumSum ¶
CumSum, CumProd, CumMin and CumMax are running reductions over the window.
Nulls are SKIPPED by the running value and PRESERVED in place: cum_sum of [1, null, 3] is [1, null, 4], not [1, 1, 4] and not [1, null, null]. The running total ignores what is not there; the output still says the value was missing.
CumSum uses the same accumulator width as Sum, so the last row of `cum_sum(x)` equals `sum(x)` rather than differing by an overflow. CumProd returns Float64 for the reason Product does: i128 has addition but no multiplication.
func (Expr) Cut ¶ added in v0.5.0
Cut puts each value in a bin between breaks, and answers the bin's label, a String: Polars' cut.
Col("age").Cut([]float64{18, 65}) // "(-inf, 18]", "(18, 65]", "(65, inf]"
Col("age").Cut([]float64{18, 65}, CutLabels("minor", "adult", "senior"))
The breaks must be finite and increase. A value is compared as a Float64; a null or a NaN has no bin and is null. The answer is named after the value. Polars answers a Categorical; ursus a String, since an Enum column cannot yet be built from the public API.
func (Expr) Degrees ¶ added in v0.4.0
Degrees converts radians to degrees, and Radians degrees to radians.
func (Expr) Diff ¶
Diff is the difference from the value n rows earlier.
It costs ONE shift, not two ¶
`e.Sub(e.Shift(n))` mentions the shift twice, and window resolution dedups on the rendered expression — so both mentions resolve to the same temporary and the shift is computed once. That is real common-subexpression elimination, unlike Coalesce, which has none.
The type is whatever subtraction gives, which is the operand's own for a signed number and a DURATION for an instant — the temporal algebra's instant-minus-instant rule. Polars' diff carries a null_behavior parameter this composition cannot express; the first n rows are null here.
An unsigned column is widened first ¶
Subtracting at its own width wrapped: a UInt8 that went from 3 to 1 changed by 254. It is taken one signed width up — UInt8 to Int16, UInt16 to Int32, UInt32 to Int64, UInt64 to Int128 — which holds every difference exactly, by the same plan-time call PctChange uses to reach a float. A signed integer keeps its width and wraps, as all integer arithmetic here does, and as Polars' diff does.
func (Expr) Div ¶
Div is TRUE division: it always produces a float, so 7/2 is 3.5 even for two integers. Integer division is FloorDiv, so the surprising behaviour has to be asked for by name.
func (Expr) DropNans ¶
DropNans is refused for the same reason as DropNulls, and its hint carries the asymmetry that keeps a frame-level DropNans from being written the obvious way.
func (Expr) DropNulls ¶
DropNulls is not an expression. It is refused here so the mistake is caught at build time with a message naming the operation that does exist.
Why there is no expression form ¶
Every expression must produce exactly one value per input row — the evaluator enforces it — and dropping rows changes the frame's height. There is no Expr.Filter, Expr.Slice or Expr.Gather family to hang a height-changing expression on, so this is not a missing case but a different shape of thing.
Refused at BUILD time rather than at plan time on purpose: the deferred error is the first thing expression resolution looks at, so CollectSchema and Explain both fail too. A refusal that only Collect notices — which is what happened to one of the window mapping strategies — lets Explain print a plan that cannot run.
func (Expr) EqMissing ¶
EqMissing is equality where nulls are DATA: null equals null, and the result is never null. NeMissing is its negation.
func (Expr) Err ¶
Err returns any error deferred during construction — a bad regex, say. Expression builders cannot return errors without destroying chaining, so the error rides in the tree and surfaces here or at Collect.
func (Expr) EwmMean ¶ added in v0.5.0
func (e Expr) EwmMean(decay EwmDecay, opts ...WindowOption) Expr
EwmMean, EwmStd and EwmVar are exponentially weighted: each row's answer is over every row of its window up to it, each weighted by decay's power of how far behind it lies — Polars' ewm_mean, ewm_std and ewm_var, whose recurrences are pandas'.
Col("price").EwmMean(EwmSpan(20)) // a 20-row moving average
Col("price").EwmStd(EwmHalfLife(5)).OverWith(byDayPerTicker)
A null row is no observation, and answers null, as Polars' does, where pandas answers the running value; MinSamples sets how many values must have been seen before a row answers, one by default. Each is a Float64, or a Float32 for one. A NaN is a value, and makes the answer NaN from there on, where pandas would skip it.
func (Expr) EwmStd ¶ added in v0.5.0
func (e Expr) EwmStd(decay EwmDecay, opts ...WindowOption) Expr
func (Expr) EwmVar ¶ added in v0.5.0
func (e Expr) EwmVar(decay EwmDecay, opts ...WindowOption) Expr
func (Expr) Explode ¶ added in v0.3.0
Explode is refused as an expression for the reason DropNulls is: it changes the frame's height, and an expression produces one value per input row.
It is the same refusal rather than a new one on purpose. A caller who reaches for `Col("tags").Explode()` has made the height mistake, not a list mistake, and the answer is the same shape as it is for DropNulls.
func (Expr) FillNan ¶
FillNan replaces NaN with a value, leaving nulls alone.
Nulls survive, and the reason is worth knowing ¶
`e.IsNotNan()` on a null row is NULL, not true — null and NaN are different things all the way down — and kernel.Select makes a null mask take NEITHER branch. So a null row keeps its null and the fill value never lands on it. That is exactly right, and it looks accidental, which is why TestFillNanLeavesNullsAlone exists.
Why the predicate is IsNotNan rather than IsNan ¶
Both spell the same thing, and only one names the result correctly. OutputName takes the LEFTMOST column of an expression, so with `IsNan → Then: value, Else: e` the leftmost column reference is the literal and every filled column came back called "literal" — which for the frame-level form meant a new column instead of a replaced one.
func (Expr) FillNull ¶
func (e Expr) FillNull(s FillStrategy) Expr
FillNull replaces nulls according to a named strategy.
Col("temp").FillNull(ursus.FillForward)
Col("qty").FillNull(ursus.FillZero)
For a forward or backward fill with a limit, use ForwardFill(n) or BackwardFill(n) directly — the strategy form is unlimited.
Three of these buffer the whole frame ¶
FillMin, FillMax and FillMean compute an aggregate over every row before they can fill anything, so they turn a streaming query into one that holds its input. Inside .Over(g) the aggregate is each group's, as Polars computes it. FillForward and FillBackward are ordered windows and buffer for the same reason. Only FillZero and FillOne stream.
func (Expr) FillNullWith ¶
FillNullWith replaces nulls with a value.
It does not widen the column ¶
Col("i32").FillNullWith(0) // stays Int32
Coalesce(Col("i32"), Lit(0)) // becomes Int64
A Go scalar lifts to a WEAK literal, which adopts the column's own type when the value fits it exactly. That is deliberately not what arithmetic does: Col("i32").Add(1) still widens, because addition can overflow and filling a null cannot. An Expr operand is used as written, so FillNullWith(Lit(0)) widens.
If the value does not fit — FillNullWith(int64(5000)) on an Int8 column — the type falls back to ordinary promotion rather than truncating, so the result is an Int64 column holding 5000 rather than an Int8 column holding -120.
Cost ¶
This desugars to a conditional, so the receiver is MENTIONED twice and, with no common-subexpression pass, EVALUATED twice — exactly as Coalesce is.
func (Expr) First ¶
First and Last are POSITIONAL and may return null.
They are a selection rather than a reduction: skipping nulls would mean First(a) and First(b) could come from different rows, which is exactly what people use them together for.
func (Expr) Floor ¶
Floor and Ceil round towards -Inf and +Inf. Both are the identity on an integer column and both are refused for Decimal, whose stored value is unscaled — so flooring it would floor 1234 rather than 12.34.
func (Expr) FloorDiv ¶
FloorDiv FLOORS, toward negative infinity, for integers, floats and a Duration alike: −7 // 2 is −4, not the −3 Go's / gives. Mod is its remainder, so a == b*FloorDiv(a, b) + Mod(a, b) and Mod takes the divisor's sign: −7 % 2 is 1. Both are Polars' and Python's rule. An integer divisor of zero is null; a float one follows IEEE.
func (Expr) ForwardFill ¶
ForwardFill replaces each null with the last non-null value before it.
limit caps how many consecutive nulls one value may fill; 0 means unlimited. A leading run of nulls has nothing to carry and stays null.
Like every ordered function it is a window: with no .Over() it runs over the whole frame in scan order, which makes it a pipeline breaker.
func (Expr) Implode ¶ added in v0.3.0
Implode collects every value of the group into a List, in input order.
GroupBy(Col("k")).Agg(
Col("v").Implode().Alias("all"),
Col("v").Implode().List().Sort().List().Head(3).Alias("smallest3"),
)
It is what makes the .list namespace available inside a group-by: everything list.go can do to a list, this can now do to a group.
Nulls are kept ¶
Every other aggregate skips them — the sum of an all-null group is null, not zero. Implode is a selection rather than a reduction, so a null is a value that takes a slot, and an all-null group implodes to a list OF nulls rather than to an empty list or a null one.
It makes the group-by single-threaded ¶
A list's element order is its data, and the parallel driver dispatches batches round-robin, so a shuffled input would be a different answer rather than the same one computed differently. The whole Agg list runs on one worker as a result — including the aggregates beside this one, because a query's aggregates share one hash table.
Its memory is the data ¶
Implode holds every value of every group, so a group-by containing one is O(rows) rather than O(distinct keys), and under WithMemoryLimit it refuses once a single key grows past the budget. Partitioning divides the key space and cannot divide one key.
func (Expr) Interpolate ¶ added in v0.5.0
func (e Expr) Interpolate(opts ...WindowOption) Expr
Interpolate fills each run of nulls between two values with the points on the straight line between them, by position in the window's order: Polars' interpolate. [1, null, null, 7] is [1, 3, 5, 7]. A run before the first value or after the last stays null. An integer or a Decimal answers a Float64; a float keeps its width. InterpolateNearest() fills it with the nearer value instead: [1, 1, 7, 7].
func (Expr) IsBetween ¶
IsBetween tests that e falls between lo and hi. Both bounds lift, so IsBetween(0, 100) works.
The default is CLOSED-BOTH — lo <= e <= hi — which is what this method has always meant and what reads naturally from the name. Pass a Closed to say otherwise:
Col("ts").IsBetween(start, end, ursus.ClosedLeft) // start <= ts < end
Variadic rather than a required third argument, so every existing two-argument call keeps compiling and the common case stays short. More than one is a mistake and is refused rather than ignored.
func (Expr) IsClose ¶
IsClose reports whether each value is within a relative or absolute tolerance of other.
|a - b| <= max(relTol * max(|a|, |b|), absTol)
Both guards are load-bearing, and neither is obvious ¶
The EQUALITY disjunct is what makes IsClose(Inf, Inf) true: the tolerance term alone would compute |Inf - Inf|, which is NaN, and NaN <= x is false.
The FINITENESS conjunct is what makes IsClose(Inf, -Inf) false. Without it the relative tolerance scales by max(|a|, |b|) = Inf, the limit becomes Inf, and Inf <= Inf says "close" — which it plainly is not. Python's math.isclose has the same special case for the same reason.
Together they give: equal infinities are close, opposite ones are not, and IsClose(NaN, NaN) is false because IEEE says NaN equals nothing including itself. With a null operand every term is null and Kleene OR gives null, matching every other comparison.
It computes in Float64 ¶
Both operands are cast first, so an integer column works — integers are always finite — and the finiteness test, which is float-only, has something to test.
Cost ¶
The receiver is mentioned five times and other four, with no CSE. For two bare columns that is nine column reads; for expensive operands it is not free.
func (Expr) IsDuplicated ¶
func (Expr) IsFirstDistinct ¶
IsFirstDistinct and IsLastDistinct mark the first and last occurrence of each value, in input order.
Note the asymmetry with IsUnique: a value occurring three times has one first occurrence, one last, and is not unique anywhere.
func (Expr) IsIn ¶
IsIn tests membership in a set of values: Col("region").IsIn("eu", "us").
Values, not an expression ¶
The published sketch was IsIn(other Expr), and it cannot be written: Literal's only slice term is ~[]byte, so there is no way to spell a list-valued literal. The Expr-valued form — testing membership in another frame's column — is a semi join wearing a predicate's clothes, and it belongs with the join machinery.
All values share one type parameter, so the set is homogeneous by construction.
IsIn(v) answers what Eq(v) answers ¶
The column and the values meet at the type == would compare them at, so IsIn refuses exactly the pairs Eq refuses, at plan time — a String against an Int64, an int against a Bool, an instant against a Date — and is false wherever Eq is: 5000 is not in an Int8 column, 0.1 is not in a Float32 one (they compare at Float64, as Polars does), and 00:00:01.5 is not in a Datetime(s) one. To match the float32 nearest 0.1, say so: IsIn(float32(0.1)).
Equality is GROUPING equality ¶
Membership uses the same encoding GroupBy and Distinct use, so NaN matches NaN and -0.0 matches +0.0. An is_in built on IEEE equality would disagree with Distinct about the same data.
Nulls ¶
`null.IsIn(...)` is NULL, not false — the same rule as IsNan, and the reason Filter(p) and Filter(Not(p)) do not partition. The set itself can never contain a null, because these are Go values.
An empty set makes every non-null row false, matching SQL's `x IN ()`.
func (Expr) IsInfinite ¶
func (Expr) IsLastDistinct ¶
func (Expr) IsNan ¶
IsNan and friends ask about a float VALUE, so `null.IsNan()` is null rather than false. Null and NaN are different things; use IsNull to test for missing.
func (Expr) IsUnique ¶
IsUnique reports whether each row's value occurs exactly once in the column. IsDuplicated is its negation for non-null rows.
These are windows, and that is why they were not built earlier ¶
The answer for row 0 depends on the last row, so neither is elementwise. Step 7 deferred them precisely here, and they need no new machinery: a value is unique iff the partition keyed by that value has exactly one row.
Equality is GROUPING equality, the same the partitioning uses — so NaN equals NaN and -0.0 equals +0.0, and IsUnique agrees with Unique() about the same data. Nulls likewise group together, so two nulls are duplicates of each other.
func (Expr) Len ¶
Len counts ROWS, including nulls — SQL's COUNT(*).
Count and Len are deliberately separate and are never aliased: over a column with nulls they give different answers, and which one was meant is not recoverable after the fact.
func (Expr) Ln ¶
Ln is the natural logarithm; Log10 is base 10; Log1p is ln(1+x), which keeps its precision for x near zero where ln(1+x) would lose it.
ln(0) is -Inf and ln(-1) is NaN. Both are values.
func (Expr) Log ¶
Log is the logarithm to an arbitrary base.
It is SUGAR — ln(x)/ln(base) — rather than a parameterised kernel, which is the same definition Polars uses and costs no new machinery. Two consequences worth knowing, both from the division:
- Log(10) and Log10() can differ in the last ULP. Log10 is the exact form.
- On a Float32 column Log10() stays Float32 and Log(10) becomes Float64, because dividing by a float64 promotes. Cast the result back if the narrow width was a deliberate choice.
func (Expr) MapBatches ¶ added in v0.3.0
func (e Expr) MapBatches[In, Out Literal](name string, out DataType, fn func([]In, []bool) ([]Out, []bool, error)) Expr
MapBatches applies a Go function to a whole column at a time.
The vectorized escape hatch, and the one to reach for first: one call per batch rather than one per row, and full control of the validity mask.
Col("v").MapBatches[float64, float64](
"normalise", ursus.Float64,
func(vals []float64, ok []bool) ([]float64, []bool, error) { ... })
The two slices are positional and the same length ¶
vals[i] is row i and ok[i] says whether it is present. A null row's vals entry is the zero value and must not be read. The returned pair must be the same length as the input; returning a nil mask means every output row is valid.
vals ALIASES the engine's own buffer when the layout permits, so fn must not write to it. Build a new slice for the result.
The notes on name, goroutine safety and out under MapElements apply here identically.
func (Expr) MapElements ¶ added in v0.3.0
func (e Expr) MapElements[In, Out Literal](name string, out DataType, fn func(In) (Out, error)) Expr
MapElements applies a Go function to every non-null value.
The per-element escape hatch. In Polars this is the slow one, because each call crosses into the Python interpreter; in Go it is a function call, which is what makes it worth offering rather than merely tolerating.
Col("celsius").MapElements[float64, float64](
"to_fahrenheit", ursus.Float64,
func(c float64) (float64, error) { return c*9/5 + 32, nil })
Nulls pass through ¶
fn never sees a null: a null input row is a null output row, and fn is not called for it. That is deliberate — the alternative hands fn a zero value it cannot distinguish from a real one. Use MapBatches when the null matters.
name must be unique within the query, and this is CHECKED ¶
A Go closure has no printable form, so the name is all that Explain and every error message can show of a udf. Two different functions sharing one could not be told apart there — and until the engine stopped deduplicating expressions by their rendering, they were one computation, with both results coming from whichever it saw first.
plan.CheckUDFNames refuses that at plan time, before any data is read, so the sentence above is a guarantee rather than advice. Two udfs with one name are an error; ONE udf used twice is not, so if two calls are meant to be the same function, hold the Expr in a variable and use it twice rather than calling a helper that builds a fresh closure each time.
A panic in fn is an error ¶
It is returned like an error fn returns — KindValue, naming the udf, the row and the column — whichever goroutine fn ran on. Before step 72 it killed the process.
fn must be safe to call from several goroutines ¶
ursus parallelises a Select or Filter across runtime.NumCPU() workers by default, and there is no way to opt a single expression out. A closure over a shared map is a data race. fn must also not depend on the ORDER it is called in: output order is preserved, invocation order is not.
out is a declaration, and it is checked ¶
The engine reports out as the column's type from CollectSchema and Explain, before fn has ever run. If fn then produces something else, the evaluator fails the query rather than letting a wrong schema propagate.
Example ¶
A Go function over each value, taking part in the plan like any expression. Nulls pass through without calling it.
package main
import (
"context"
"fmt"
"log"
"strings"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
df, err := exampleSales().Select(
ursus.Col("product"),
ursus.Col("product").MapElements("shout", ursus.String,
func(s string) (string, error) { return strings.ToUpper(s) + "!", nil }).Alias("loud"),
).Head(3).Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (3, 2) ┌─────────┬──────────┐ │ product │ loud │ │ String │ String │ ├─────────┼──────────┤ │ "apple" │ "APPLE!" │ │ "apple" │ "APPLE!" │ │ "pear" │ "PEAR!" │ └─────────┴──────────┘
func (Expr) MapName ¶
MapName derives the output name from the input name.
fn is called while the query is planned. A panic in it is the caller's error, of kind ErrValue, naming the input name it panicked on.
func (Expr) Mean ¶
Mean is the sum of non-null values divided by the COUNT of non-null values — never by the group size, which would silently bias the result downward whenever nulls are present.
Mean of a group with no non-null values is NULL, not NaN: there was nothing to compute, as opposed to a computation that came out undefined.
The mean of an instant or a duration is one, exact and truncated toward zero, as Polars' (step 173): a Datetime, Time or Duration keeps its type, and a Date's mean is a Datetime(us), keeping the fraction of a day.
func (Expr) Median ¶
Median is the 0.5 quantile with linear interpolation, so an even-sized group gives the mean of the two middle values.
It returns Float64 for every numeric input, and skips nulls. The median of instants or durations is one, truncated toward zero, as Polars' (step 173): its type kept, but a Date's, which is a Datetime(us). Quantile still refuses a temporal column; Cast(Int64) gives its ticks.
func (Expr) Min ¶
Min and Max use ursus's TOTAL order, the same one Sort uses: NaN sorts above everything and -0.0 equals +0.0. They therefore always agree with Sort(...).First() / .Last(), which IEEE comparison would not.
func (Expr) MinBy ¶ added in v0.6.0
MinBy is the value of e at the row where by is the least, and MaxBy where it is the greatest: Polars' min_by and max_by. The answer is e's type, and by may be of any ordered type.
GroupBy(Col("store")).Agg(Col("product").MaxBy(Col("sales")))
A row whose by is null is skipped, and a group where every by is null answers null; a null value at the chosen row is null too. A tie goes to the earliest row.
Where Polars differs: by is ordered as Max and ArgMax order it, so NaN is the greatest value and MaxBy chooses a NaN's row, where Polars skips the NaN. Then v.MaxBy(by) is always v at by.ArgMax().
func (Expr) NUnique ¶
NUnique counts distinct non-null values.
Nulls are SKIPPED, so NUnique over an all-null group is 0. Polars counts null as a distinct value; ursus follows SQL and, more importantly, follows its own other aggregates.
func (Expr) Neg ¶
Neg negates. Abs takes the absolute value.
Both are defined for every signed numeric type, Int128 and Decimal included — which matters more than it sounds, because every integer Sum outputs Int128, so `Col("x").Sum().Abs()` is an ordinary query. Abs of an unsigned integer is itself. Neg of one is refused, because most of its negations do not fit: cast it to a signed type first.
func (Expr) NullCount ¶
NullCount counts the NULLS in each group — the complement of Count, and like the other counters it is never itself null.
func (Expr) Over ¶
Over computes the expression within partitions and writes the answer back onto every row.
// each row's deviation from its group's mean
ursus.Col("x").Sub(ursus.Col("x").Mean().Over(ursus.Col("g")))
// dense rank of speed within each type
ursus.Col("speed").Rank(ursus.RankDense, true).Over(ursus.Col("type"))
The distinction from GroupBy ¶
GroupBy REDUCES: N rows in, one row per group out. Over PRESERVES: N rows in, N rows out, each carrying its own partition's answer. That is why an aggregate is refused in Select but the same aggregate under Over is not.
With no partition keys the whole frame is one partition, so `Col("x").Sum().Over()` puts the grand total on every row.
Nulls form their own partition ¶
A null partition key groups with other nulls, the same rule GroupBy uses — as opposed to joins, where null keys match nothing. The two differ on purpose and each is documented where it applies.
func (Expr) OverWith ¶
func (e Expr) OverWith(spec WindowSpec) Expr
OverWith is Over with an ordering and a mapping strategy.
An ordering is what makes Rank, the cumulative functions and Shift meaningful: without one they run in input order, which is well-defined but rarely what a ranking wants.
func (Expr) PctChange ¶
PctChange is the fractional change from the value n rows earlier.
Always a float on a numeric column, because it divides — and a zero previous value gives ±Inf rather than a null, since float division is total. A Float32 stays Float32. On an instant it is a plan-time error.
The value is taken to a float BEFORE the subtraction ¶
It used to subtract at the column's own width, and integer subtraction wraps: a UInt8 that went from 3 to 1 changed by 254, so its pct_change was 84.67 where the answer is −0.67. The public Expr has no type in hand to choose a cast with, so an internal call, typed at plan time, takes an integer or a Decimal to Float64 and a Duration's ticks to Float64, keeps a float as it is, and refuses an instant.
func (Expr) Pow ¶
Pow raises the receiver to the power v.
The result is ALWAYS a float, for the same reason Div's is: 2**-1 is 0.5, and a version that truncated negative powers to zero would be surprising in the direction that loses data silently. Cast the result if an integer is wanted.
func (Expr) Prefix ¶
Prefix prepends to the output name. Unlike Alias it is expansion-safe, deriving a distinct name per output column.
func (Expr) Product ¶
Product multiplies the non-null values.
Unlike Sum it returns FLOAT64 for every numeric input, including integers. Sum widened to Int128 so that overflow became unreachable; product cannot, because i128 has no multiplication — and an Int64 product wraps silently after roughly twenty ordinary factors. Float64 is exact to 2^53 and approximate above it, which is the weaker guarantee, stated rather than hidden.
Product of a group with no non-null values is NULL, not 1, following Sum.
func (Expr) QCut ¶ added in v0.5.0
QCut puts each value in a bin between quantiles of its column, and answers the bin's label: Polars' qcut.
Col("income").QCut([]float64{0.25, 0.5, 0.75}) // quartiles
Each break is the column's Quantile at q, interpolated linearly, over the whole frame (Over()), so QCut is a window: it belongs in Select or WithColumns. Labels, the closed side, and a null or NaN value are as Cut's. A column of few distinct values can give two equal quantiles, and the bin between them is then empty, where Polars refuses it unless allow_duplicates is set.
func (Expr) QCutN ¶ added in v0.5.0
QCutN is QCut into n bins of equal count: QCutN(4) is QCut at 0.25, 0.5 and 0.75.
func (Expr) Quantile ¶
func (e Expr) Quantile(q float64, interp Interpolation) Expr
Quantile returns the value at rank q, where q runs from 0 (the minimum) to 1 (the maximum). interp decides what happens when the rank falls between two values.
The rank is q·(n−1) over the non-null values — the convention numpy, Polars and DuckDB share — and the result is Float64 even for InterpLower and InterpHigher, which do return an actual input value.
Values are ordered by ursus's TOTAL order, so NaN sorts above everything and a high quantile of a column containing NaN is NaN, exactly as Sort would place it.
A temporal column, a Duration included, is refused for now; Cast(Int64) gives its ticks.
func (Expr) Rank ¶
func (e Expr) Rank(method RankMethod, descending bool) Expr
Rank numbers rows by value within the window, smallest first unless descending.
Ties are resolved by method; see RankMethod. Rank is never null — a null value still occupies a position — and returns Uint32, or Float64 for RankAverage.
func (Expr) RollingMax ¶ added in v0.5.0
func (e Expr) RollingMax(size int, opts ...WindowOption) Expr
func (Expr) RollingMaxBy ¶ added in v0.6.0
func (e Expr) RollingMaxBy(by Expr, period Interval, opts ...WindowOption) Expr
func (Expr) RollingMean ¶ added in v0.5.0
func (e Expr) RollingMean(size int, opts ...WindowOption) Expr
func (Expr) RollingMeanBy ¶ added in v0.6.0
func (e Expr) RollingMeanBy(by Expr, period Interval, opts ...WindowOption) Expr
func (Expr) RollingMin ¶ added in v0.5.0
func (e Expr) RollingMin(size int, opts ...WindowOption) Expr
func (Expr) RollingMinBy ¶ added in v0.6.0
func (e Expr) RollingMinBy(by Expr, period Interval, opts ...WindowOption) Expr
func (Expr) RollingStd ¶ added in v0.5.0
func (e Expr) RollingStd(size int, opts ...WindowOption) Expr
func (Expr) RollingStdBy ¶ added in v0.6.0
func (e Expr) RollingStdBy(by Expr, period Interval, opts ...WindowOption) Expr
func (Expr) RollingSum ¶ added in v0.5.0
func (e Expr) RollingSum(size int, opts ...WindowOption) Expr
RollingSum, RollingMean, RollingMin, RollingMax, RollingVar and RollingStd answer each row over the size rows up to and including it: Polars' rolling_* with a window_size, center=False. RollingCenter() labels each window at its middle row instead, as center=True does.
Col("price").RollingMean(7) // the week's average, from the 7th row
Col("price").RollingMean(7).Over(Col("ticker")) // per ticker
Col("price").RollingMax(3, MinSamples(1)).OverWith(spec) // in the spec's order
The rows are the window's: a partition's, in its order, with .Over or .OverWith, or else the frame's, in scan order. A row whose window holds fewer than MinSamples values — by default, size, so any null in a full window — is null.
Each answers what the aggregate of the same name would over that window, with the same type: a sum of integers is an Int128, a mean, var and std of numbers floats, a min and max the column's own type, of any type with an order. A NaN in a float window makes its sum, mean, var and std NaN, and its max NaN, as the aggregates'.
func (Expr) RollingSumBy ¶ added in v0.6.0
func (e Expr) RollingSumBy(by Expr, period Interval, opts ...WindowOption) Expr
RollingSumBy, RollingMeanBy, RollingMinBy, RollingMaxBy, RollingVarBy and RollingStdBy answer each row over the rows whose by value lies within period of its own, (t − period, t]: Polars' rolling_*_by, and the expression form of the frame-level Rolling.
Col("amount").RollingSumBy(Col("at"), Every("1h")) // the hour up to each row
Col("amount").RollingMeanBy(Col("at"), Every("7d")).Over(Col("account"))
The window is found by value, not position: by need not be sorted, the rows that tie with a row's by are in its window, the later ones too, and a row whose by is null is in no window and is null. By default a window answers from one value, as Polars' min_samples=1; MinSamples asks for more. WindowClosed chooses which ends of the window are in it.
by is a Date, Datetime or Time. A calendar period counts months and days on the wall clock of by's zone, so Every("1d") across a change of offset is a calendar day, and FromDuration(24*time.Hour) twenty-four hours. The answers' types are the rolling functions' of the same names.
Where Polars differs: it also takes an integer by, with a period such as "2i", and RollingMedianBy and RollingQuantileBy; and it answers the sum of a window holding no value, one empty under closed="left" or "none" or one whose rows are all null, as 0.0, although min_samples is 1. Here that window is null.
func (Expr) RollingVar ¶ added in v0.5.0
func (e Expr) RollingVar(size int, opts ...WindowOption) Expr
func (Expr) RollingVarBy ¶ added in v0.6.0
func (e Expr) RollingVarBy(by Expr, period Interval, opts ...WindowOption) Expr
func (Expr) Round ¶
Round rounds to decimals decimal places, HALF AWAY FROM ZERO.
Round(0.5) is 1 and Round(2.5) is 3, as a spreadsheet rounds, rather than IEEE's round-half-to-even. That is Polars' `mode="half_away_from_zero"`, and NOT Polars' default, which rounds half to even: Polars' `round(0)` gives 2 for 2.5. It preserves the operand's type: rounding an integer column is the identity, not a widening.
It rounds twice — the value is scaled by 10^decimals first, and that multiply rounds too. Round(2.675, 2) is 2.68, because 2.675 * 100 lands on exactly 267.5 even though 2.675 itself is stored as 2.67499999999999982…
A Float32 is rounded as a Float64 and the result rounded once more to a float32, so Round(0.1, 1) of a Float32 is the float32 nearest 0.1.
func (Expr) Shift ¶
Shift moves values n places later within the window, filling the vacated rows with null. A negative n shifts earlier — Polars' lag and lead, one function.
Rows shifted in from outside the partition are null, never a neighbouring partition's value.
func (Expr) ShiftFill ¶
ShiftFill is Shift with a value instead of null in the vacated rows.
The fill meets the column as FillNullWith's does: a Go value that fits the column's type takes it, so ShiftFill(1, 0) on an Int32 column is an Int32, and otherwise the two promote, so ShiftFill(1, 1.5) on an Int64 column is a Float64.
func (Expr) Sign ¶
Sign is -1, 0 or 1, keeping the operand's type.
For floats it returns the operand itself in the zero case, so sign(-0.0) is -0.0 and sign(NaN) is NaN. That matches NumPy and falls out of the ordering test rather than being special-cased.
func (Expr) Sin ¶ added in v0.4.0
The trigonometric and hyperbolic functions, in radians, with Polars' names. Like Sqrt they widen to a float, keeping Float32, and a value outside the domain — ArcSin(2), ArcCosh(0) — is NaN rather than an error or a null.
func (Expr) Struct ¶ added in v0.3.0
func (e Expr) Struct() StructExpr
Struct opens the struct namespace.
func (Expr) Sum ¶
Sum adds the non-null values.
An INTEGER sum accumulates and returns Int128, following DuckDB. Summing a thousand Int8 values overflows an Int8 immediately, and a wrapped sum is a plausible-looking wrong number that nothing downstream can detect. At 128 bits overflow needs 2^63 maximum-magnitude rows, i.e. it cannot happen.
A FLOAT sum accumulates in Float64 even when it returns Float32, because naive float32 accumulation stops making progress past ~2^24 elements.
A BOOL sums as 0 and 1, so its sum counts the trues, as an Int128. Count counts the non-null rows, falses included.
Sum of a group with no non-null values is NULL, not 0 — ursus follows SQL here rather than Polars. `0` cannot be distinguished afterwards from a genuine zero; NULL can be turned into 0 with FillNull if that is what you want.
func (Expr) TopK ¶ added in v0.4.0
TopK is the k largest values of the group, largest first, as a List.
GroupBy(Col("id6")).Agg(Col("v3").TopK(2).Alias("largest2")).Explode("largest2")
It holds only k values a group, so unlike Implode it does not grow with the data, and it keeps a parallel group-by parallel. Values are in ursus's total order — NaN is the largest float, as Sort places it. Nulls are skipped: a group with fewer than k values gives a shorter list, and one with none gives null. Defined for numeric, temporal, decimal and string columns; k must be at least 1.
func (Expr) Unnest ¶ added in v0.3.0
Unnest is refused as an expression, and for a DIFFERENT reason than Explode.
Explode is not an expression because it changes the frame's HEIGHT. Unnest leaves the height alone and changes the WIDTH: an expression produces exactly one column, and this produces one per field. Saying which of the two rules was broken is the difference between a message that teaches and one that just refuses.
The single-column form exists and is spelled `Col("person").Struct().Field("age")`.
func (Expr) Var ¶
Var and Std are the variance and standard deviation of the non-null values.
ddof is the delta degrees of freedom: 0 for the population statistic, 1 for the sample one. It has no default because there is no defensible default — Polars and pandas use 1, numpy uses 0, and silently picking either produces a number that is wrong for half its readers.
A group with ddof or fewer values returns NULL: the sample variance of a single observation is undefined, and 0 would claim the data has no spread rather than that the question has no answer.
Computed with Welford's algorithm, so a column of large near-equal values gives the right answer rather than the zero (or negative) that E[x²]−E[x]² produces.
A temporal column, a Duration included, is refused for now; Cast(Int64) gives its ticks.
type FillStrategy ¶
type FillStrategy uint8
FillStrategy names a fill rule that needs no value at the call site.
It is a root-package enum and never reaches the IR, so it is not a dedup key and carries none of the collision hazard the expression enums do.
const ( // FillZero and FillOne fill with a constant. The literal is WEAK, so the // column's type is preserved rather than widened — which matters more here than // anywhere else, because the call site contains no literal at all to explain a // widening. FillZero FillStrategy = iota FillOne // FillForward and FillBackward carry the neighbouring value. Both are ordered // window functions, so both are pipeline breakers. FillForward FillBackward // FillMin, FillMax and FillMean fill with the column's own aggregate over the // WHOLE FRAME. That makes them pipeline breakers too, and worse ones: the // window sink holds every row, and it is one of the operators that is accounted // and refused under a memory limit rather than spilled. // // FillMean returns a FLOAT even for an integer column, because a mean is a // float. FillMin and FillMax preserve the type. FillMin FillMax FillMean )
func (FillStrategy) String ¶
func (s FillStrategy) String() string
type GroupBy ¶
type GroupBy struct {
// contains filtered or unexported fields
}
GroupBy is the intermediate handle between GroupBy and Agg.
func (*GroupBy) Agg ¶
Agg reduces each group.
Every expression must be an aggregation: every column it reads has to pass through an aggregate function. `Col("a").Sum().Add(1)` is fine; `Col("a").Sum().Add(Col("b"))` is not, because b is still a per-row value and there is no defined way to combine the two shapes.
func (*GroupBy) Len ¶
Len counts the rows in each group under the given name.
Distinct from Count, which uses the fixed name "count": Len takes one because a frame that already has a column called "count" is not unusual, and silently colliding would be worse than asking.
func (*GroupBy) MaintainOrder ¶
MaintainOrder emits groups in first-appearance order.
Without it the group order is unspecified — which is standard (SQL uses set semantics and DuckDB exploits it for parallelism) but means a test that assumes an order is flaky.
The flag is no longer free, and that is the point of it ¶
It USED to document a guarantee the implementation happened to give anyway. Since step 12 a group-by over more distinct keys than the memory limit holds partitions the overflow to disk, and the unordered path then emits partition-major: resident groups first, then partition 0's groups, then partition 1's. The order depends on where the freeze fell, so it depends on the limit. Since step 150 a group-by of tens of thousands of groups or more, on several threads, folds its workers' tables partitioned, each partition on its own goroutine, and emits partition-major as well, in an order that depends on the thread count.
The CONTENT never does. Every group, every value, identical at every limit and thread count. See WithMemoryLimit, which states the same contract from the other side. MaintainOrder runs serially.
Costs nothing when the aggregation fits in memory. When it spills it adds an int64 ordinal to every routed row and materialises the whole result to permute it — plan.Aggregate.MaintainOrder has the detail.
func (*GroupBy) Min ¶
Min, Max, First, Last and NUnique apply to EVERY non-key column, because they select or count rather than compute.
type Int128Value ¶
Int128Value is a signed 128-bit integer, the Go representation of an Int128 column. Read one with df.Column[ursus.Int128Value]("total").
type Interpolation ¶
type Interpolation = expr.Interpolation
Interpolation selects how Quantile resolves a rank falling between two values.
Aliased from the expression IR rather than redeclared, so the kernel and the public API cannot drift — the same arrangement JoinKind uses.
type Interval ¶
Interval is a calendar-aware span: months, days and nanoseconds, kept apart because each obeys a different rule. See dtype.Interval for why a time.Duration cannot express "1 month" or, in a daylight-saving zone, "1 day".
func Every ¶
Every parses an interval: "1y", "3mo", "1w", "2d", "6h", "15m", "30s", "1ns". Components may be concatenated ("1h30m") and a leading "-" negates the whole thing.
A grid of weeks starts on a Monday, as Polars': Truncate(Every("1w")) and a GroupByDynamic every "1w" give Mondays, where "7d" counts days from the epoch, a Thursday. Weeks mixed with any other unit count from the epoch too.
A parse failure is carried in the value rather than returned, and surfaces when the query is built — the deferred-error rule the rest of the library follows. Note "m" is minutes and "mo" is months.
func FromDuration ¶
FromDuration builds an ABSOLUTE interval. Deliberately not the same as the calendar spelling: FromDuration(24*time.Hour) is twenty-four hours, Every("1d") is one day, and on the two days a year a zone changes its offset those are different instants.
type JoinOption ¶
type JoinOption func(*joinCfg)
JoinOption configures a join.
func JoinCoalesce ¶
func JoinCoalesce(b bool) JoinOption
JoinCoalesce forces key merging on or off.
Unset is not the same as either: by default the key appears once for inner, left, right, semi and anti joins, and twice for a full join — which can leave the key null on either side, so there is no single side to take it from. JoinCoalesce(true) merges a full join's keys too: the left key where the row has a left side, and the right key where it does not.
The default only merges keys that are named the SAME on both sides. Merging JoinLeftOn(Col("cust_id")) with JoinRightOn(Col("id")) would silently delete a column the caller named explicitly, so it takes an explicit JoinCoalesce(true).
func JoinLeftOn ¶
func JoinLeftOn(keys ...Expr) JoinOption
JoinLeftOn and JoinRightOn name the key columns separately, for frames where they are called different things. They are used together and must agree in count; keys are paired positionally.
func JoinMaintainOrder ¶ added in v0.4.0
func JoinMaintainOrder(b bool) JoinOption
JoinMaintainOrder keeps an inner join's rows in the left frame's order.
Without it an inner join's row order is not promised, as in Polars. ursus hashes the smaller input, estimated from the sources' row counts, and the rows come out in the order of the other one: the left frame's when the right is smaller, the right frame's when it is much larger. Left, right, semi, anti and full joins keep their order regardless; under a memory limit no join's order is promised.
func JoinNullsEqual ¶
func JoinNullsEqual(b bool) JoinOption
JoinNullsEqual makes null keys match each other. Default false, which is SQL's rule and Polars': a null key matches nothing, including another null.
Note this is deliberately the OPPOSITE of GroupBy, where null forms its own group. Grouping asks "are these the same value?" and joining asks "is this the same entity?", and a missing identifier is not evidence of sameness.
func JoinOn ¶
func JoinOn(keys ...Expr) JoinOption
JoinOn names key columns present under the same name on both sides.
func JoinRightOn ¶
func JoinRightOn(keys ...Expr) JoinOption
func JoinSuffix ¶
func JoinSuffix(s string) JoinOption
JoinSuffix sets what is appended to a right-side column whose name collides with a left-side one. Default "_right".
func JoinValidate ¶
func JoinValidate(v JoinValidation) JoinOption
JoinValidate asserts a key cardinality, failing the query when the data violates it.
orders.Join(customers, JoinOn(Col("customer_id")),
JoinValidate(ursus.ValidateManyToOne))
Worth reaching for: an unexpected duplicate on the right multiplies every matching left row, and the result looks entirely plausible.
type JoinValidation ¶
type JoinValidation = plan.JoinValidation
JoinValidation asserts a cardinality and errors when the data violates it.
The first term names the LEFT frame: ManyToOne on orders.Join(customers) asserts that the customers are unique.
type LazyFrame ¶
type LazyFrame struct {
// contains filtered or unexported fields
}
LazyFrame is an unexecuted query.
Immutable ¶
Every builder method returns a NEW LazyFrame sharing an immutable plan. So
base := ursus.ScanParquet("events.parquet")
a := base.Filter(ursus.Col("x").Gt(5))
b := base.Filter(ursus.Col("x").Lt(0))
gives two independent queries, and `base` is unchanged. That idiom is the most common thing anyone does with a lazy frame, and it only works because nothing mutates in place. It also makes a LazyFrame safe to share between goroutines.
Sticky errors ¶
A builder cannot return an error without destroying chaining, so errors are carried and surfaced at Collect (or read early via Err). An unknown column in Select does not panic and does not fail silently — it fails at Collect, with the available columns and a spelling suggestion.
func Concat ¶
func Concat(frames []*LazyFrame, opts ...ConcatOption) *LazyFrame
Concat stacks frames vertically, in the order given.
ursus.Concat([]*ursus.LazyFrame{jan, feb, mar})
ursus.Concat(fs, ursus.WithConcatMode(ursus.ConcatDiagonal))
Why a slice rather than a variadic ¶
It was `Concat(frames ...any)` for one release, which read better and let the mode be passed inline. It also made `ursus.Concat(a, "oops")` compile and fail at run time — in a library that spends generic methods and the Operand constraint precisely so that `Col("x").Gt(struct{}{})` does not. Ergonomics is not worth the one property the type system was being paid to provide.
For the common case the method form keeps both: LazyFrame.Concat is variadic and type-safe, so `jan.Concat(feb, mar)` needs no slice literal.
Order is preserved ¶
Every row of the first frame precedes every row of the second. That is not free — it is why the operator drains children serially rather than racing them — and it is what lets Concat compose with Head, Tail and anything else that cares which rows come first.
Reconciliation happens in the plan ¶
A frame missing a column, or holding one at a narrower type, is wrapped in a projection that supplies the null and performs the cast, so Explain shows why a column is null and projection pushdown still narrows each input independently.
func Frame ¶
Frame builds an in-memory LazyFrame from columns.
This is the entry point for data that is already in Go — the counterpart to ScanParquet and friends, which arrive in step 2.
func ScanArrow ¶ added in v0.3.0
func ScanArrow(open func() (array.RecordReader, error)) *LazyFrame
ScanArrow reads a stream of Arrow records.
open must return a NEW, independent reader every time it is called: ursus opens one to learn the schema when the query is planned, and one per scan each time it runs — a self-join scans twice in one query, and a LazyFrame can be collected again. A reader cannot be rewound, so handing over one reader would be read once and then be empty.
stream, err := os.ReadFile("data.arrows") // an Arrow IPC stream
if err != nil { return err }
lf := ursus.ScanArrow(func() (array.RecordReader, error) {
return ipc.NewReader(bytes.NewReader(stream))
})
Returning the same reader twice is refused by name rather than read as an empty stream.
Records are copied a batch at a time, and the reader is not advanced until its current record has been copied in full, so nothing ursus returns refers to memory the reader reuses or frees. Explain and CollectSchema open a reader and release it without reading a record. Head(n) stops asking for records once it has n rows. A reader blocked inside Next cannot be interrupted by the query's context.
Every column is nullable, and types are mapped as ScanArrowRecords describes.
func ScanArrowRecords ¶ added in v0.3.0
func ScanArrowRecords(recs ...arrow.RecordBatch) *LazyFrame
ScanArrowRecords reads Arrow records already in memory.
The records are COPIED before this returns, so the caller may release them, or let the reader they came from move on, as soon as it likes:
var recs []arrow.RecordBatch
for rdr.Next() {
rec := rdr.RecordBatch()
rec.Retain() // an IPC reader reuses the record on its next Next
recs = append(recs, rec)
}
lf := ursus.ScanArrowRecords(recs...)
for _, rec := range recs {
rec.Release()
}
Every record must convert to the same schema. Every column is nullable whatever the Arrow schema declares, because arrow-go never checks that flag and producers routinely leave it false while writing nulls. A type ursus cannot represent — a 256-bit decimal, an interval, a union — is refused by name.
With no records there is no schema, so that is an error too; hand over an empty record instead. For a stream too large to copy up front, use ScanArrow.
Example ¶
Arrow both ways. Record shares a frame's memory with arrow-go; ScanArrowRecords copies records in, so the record can be released straight away.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
ctx := context.Background()
big, err := exampleSales().Filter(ursus.Col("qty").Gt(5)).Collect(ctx)
if err != nil {
log.Fatal(err)
}
rec, err := big.Record()
if err != nil {
log.Fatal(err)
}
lf := ursus.ScanArrowRecords(rec)
rec.Release()
df, err := lf.Select(ursus.Col("region"), ursus.Col("qty")).Collect(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (3, 2) ┌─────────┬───────┐ │ region │ qty │ │ String │ Int64 │ ├─────────┼───────┤ │ "north" │ 10 │ │ "north" │ 7 │ │ "south" │ 9 │ └─────────┴───────┘
func ScanCSV ¶
ScanCSV reads a CSV file lazily.
Nothing is read until Collect, except the sample inference needs — and not even that if WithSchema is given. Projection reaches the reader, so selecting two columns of a hundred parses two.
df, err := ursus.ScanCSV("sales.csv").
Filter(ursus.Col("qty").Gt(10)).
Select(ursus.Col("region"), ursus.Col("qty")).
Collect(ctx)
func ScanCSVFiles ¶
ScanCSVFiles reads several files as one frame, in the order given.
Columns are matched by the NAMES in each file's header: every file must name the same columns, in any order. A file that differs — a missing, extra, renamed or repeated column, or no header at all — is refused with ErrSchema, naming both files, when the scan reaches it. The types are inferred from the first file (or given by WithSchema). WithColumnNames renames the first file's columns by position, and later files are still matched to it by what the files say. With WithHasHeader(false) there is nothing to match, and files are read by position. A UTF-8 byte-order mark at the start of a file is dropped.
func ScanCSVFrom ¶ added in v0.3.0
ScanCSVFrom reads CSV files the caller opens, as one frame in the order given, under ScanCSVFiles' rules for several files.
It is how ursus reaches an object store without depending on one: give each file an Open that returns the body of a GET.
func ScanCSVGlob ¶
ScanCSVGlob reads every file matching a shell pattern as one frame.
The files must share their columns; the types are inferred from the first match, and every other file's header is matched to the first's by NAME — see ScanCSVFiles. Matches are sorted, so `part-*.csv` reads in the order the names imply rather than whatever the filesystem returns — row order is a property of the data here, and leaving it to readdir would make the same query return different orders on different machines.
func ScanCSVReader ¶
ScanCSVReader reads CSV from memory.
It takes the bytes rather than an io.Reader because a source is opened more than once — once for inference, once per execution — and a Reader cannot be rewound.
func ScanParquet ¶
func ScanParquet(path string, opts ...ParquetOption) *LazyFrame
ScanParquet reads a Parquet file lazily.
Only the footer is read until Collect. Projection reaches the reader, so a query selecting two columns of a hundred never decompresses the other ninety-eight, and a predicate on a column with statistics skips whole row groups.
df, err := ursus.ScanParquet("events.parquet").
Filter(ursus.Col("ts").Gt(cutoff)).
Select(ursus.Col("user"), ursus.Col("ts")).
Collect(ctx)
Lists and structs are read; a column ursus cannot read — an encrypted one, a map, a list of lists — is refused with an error naming it when a query reads it, rather than dropped.
func ScanParquetBytes ¶
func ScanParquetBytes(b []byte, name string, opts ...ParquetOption) *LazyFrame
ScanParquetBytes reads a Parquet file from memory.
func ScanParquetFiles ¶
func ScanParquetFiles(paths []string, opts ...ParquetOption) *LazyFrame
ScanParquetFiles reads several files as one frame, in the order given.
Columns are matched by NAME: every file must have the same columns, of the same types, in any order. A file that differs is refused with ErrSchema, naming both files and the difference, when the query is planned — every file's footer is read then, before any row is produced. A column is nullable if it is nullable in any file. To combine files that differ, scan them separately and Concat them.
func ScanParquetFrom ¶ added in v0.3.0
func ScanParquetFrom(files []ParquetFile, opts ...ParquetOption) *LazyFrame
ScanParquetFrom reads Parquet files the caller opens, as one frame in the order given, under ScanParquetFiles' rules for several files.
It is how ursus reaches an object store without depending on one: give each file an Open that returns a ReaderAt over ranged GETs.
files := []ursus.ParquetFile{{
Name: "s3://logs/2026/10/part-0.parquet",
Open: func(ctx context.Context) (io.ReaderAt, int64, error) {
return newRangedReader(ctx, client, "logs", "2026/10/part-0.parquet")
},
}}
df, err := ursus.ScanParquetFrom(files).Filter(ursus.Col("status").Eq(500)).Collect(ctx)
Example ¶
Read Parquet from wherever it lives. Open returns an io.ReaderAt and its size — here over bytes in memory, standing in for a ranged GET against an object store. ursus reads the footer, then only the column chunks the query needs.
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
ctx := context.Background()
var file bytes.Buffer
if err := exampleSales().WriteParquet(ctx, &file); err != nil {
log.Fatal(err)
}
lf := ursus.ScanParquetFrom([]ursus.ParquetFile{{
Name: "s3://shop/sales.parquet",
Open: func(ctx context.Context) (io.ReaderAt, int64, error) {
return bytes.NewReader(file.Bytes()), int64(file.Len()), nil
},
}})
n, err := lf.Filter(ursus.Col("product").Eq("pear")).Count(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(n, "pear sales")
}
Output: 2 pear sales
func ScanParquetGlob ¶
func ScanParquetGlob(pattern string, opts ...ParquetOption) *LazyFrame
ScanParquetGlob reads every file matching a shell pattern as one frame, sorted by name so `part-*.parquet` reads in the order the names imply.
func (*LazyFrame) BottomK ¶
BottomK keeps the k smallest rows by the given keys. See TopK — including that nulls are placed last and so never appear in the result.
func (*LazyFrame) Collect ¶
Collect runs the query and returns the whole result.
A result too large to hold is an error ¶
Under the default budget, Collect counts its result as it arrives, and refuses once the query holds more, the result and what its operators hold together, than three quarters of the memory it may use: the default budget and half as much again. The error is a resource error naming collect.
Three quarters, because ursus sets the Go soft limit at nine tenths of the same ceiling, and a query holds up to about a fifth more than it counts: three quarters and a fifth is nine tenths. Past that the collector cannot keep the heap under the soft limit, and under a container's limit the kernel kills the process instead. CollectBatches, SinkParquet and SinkCSV hold one batch at a time and are not checked.
The result is counted apart from what the operators hold, so they spill exactly as they would if it were streamed, and MemoryStats.Peak leaves it out.
Under a limit you give with WithMemoryLimit, or none, Collect is not checked and holds whatever it is asked to, as it always has.
A panic is an error ¶
Every entry point that runs a query recovers a panic in it and returns it as ErrInternal — "this is a bug in ursus" — and every goroutine the engine starts does the same for its own work, so no query can take down the process that asked it. A panic in a udf is the caller's own, and is a KindValue error naming the udf.
func (*LazyFrame) CollectBatches ¶
func (lf *LazyFrame) CollectBatches(ctx context.Context, opts ...CollectOption) iter.Seq2[*DataFrame, error]
CollectBatches streams the result without materialising the whole frame.
Breaking out of the loop tears the operator tree down, so an early exit is safe.
A panic while planning, or in any operator, is an error yielded like any other. A panic in the loop BODY is the caller's own and propagates as it would from any loop: recovering around yield is something Go forbids a range function to do.
func (*LazyFrame) CollectInto ¶
func (lf *LazyFrame) CollectInto[T any](ctx context.Context, opts ...CollectOption) (rows []T, err error)
CollectInto runs the query and decodes each row into a T.
A generic METHOD, which is the Go 1.27 feature this library was waiting for: before, this had to be a package-level function taking the frame as an argument.
func (*LazyFrame) CollectRecords ¶ added in v0.3.0
func (lf *LazyFrame) CollectRecords(ctx context.Context, opts ...CollectOption) iter.Seq2[arrow.Record, error]
CollectRecords streams the result as Arrow records without materialising the whole frame, which is what makes a larger-than-RAM result exportable.
It is CollectBatches with the conversion applied, and it inherits every property: breaking out of the loop tears the operator tree down, so an early exit is safe.
for rec, err := range lf.CollectRecords(ctx) {
if err != nil { return err }
if err := w.Write(rec); err != nil { rec.Release(); return err }
rec.Release()
}
Each record shares the memory of the batch it came from, and the engine reuses nothing across batches, so a record stays valid after the next one arrives.
func (*LazyFrame) CollectSchema ¶
CollectSchema returns the result schema without reading any data.
It takes a context because resolving a source's schema is real I/O — a Parquet footer, a CSV sample. A schema request that could not be cancelled would be a hole in the cancellation story, which is why this is not the no-argument Schema() the design documents originally specified.
func (*LazyFrame) Describe ¶ added in v0.5.0
Describe summarises each column, one statistic a row: Polars' describe.
statistic count, null_count, mean, std, min, 25%, 50%, 75%, max
A number's column is a Float64: its mean, its standard deviation (ddof 1), and its percentiles, each the nearest value, as Polars takes them. A Bool's is a Float64 too, with true as 1: its mean is the share of trues, and its min and max say whether there is a false and a true. Any other column is a String, holding its counts, and for a String or a temporal column its min and max, formatted as a Cast to String formats them. A statistic a column has no answer for is null.
percentiles replace 0.25, 0.5 and 0.75, each between 0 and 1. It runs the query once, through one aggregate per statistic and column, and is therefore eager.
func (*LazyFrame) Drop ¶
Drop removes columns by name.
Sugar over `Select(Exclude(...))`, which is the whole implementation: Exclude is already an expansion-time selector, so Drop inherits its error messages, its interaction with projection pushdown, and its behaviour on a name that is not there — the selector ignores it rather than failing, matching Polars.
func (*LazyFrame) DropNulls ¶
DropNulls removes rows that are null in any of the named columns, or in any column at all when no names are given.
It is `Filter(Col(c).IsNotNull(), ...)`, and being a filter rather than a dedicated node is what earns it Parquet row-group pruning: the pruner claims IsNotNull over a bare column, so a row group whose statistics say a column is entirely null is skipped without being read.
Note the asymmetry with a hypothetical DropNans: IsNotNull is TOTAL, so the predicate is never itself null and the filter's own null rule never comes into play. `Col(c).IsNotNan()` is null on a null row, and Filter drops null predicates, so the obvious spelling of DropNans would silently drop nulls too. That is why it is not here.
func (*LazyFrame) Explain ¶
Explain renders the query plan.
The output is stable and diff-friendly on purpose: it is what golden tests snapshot, and a plan diff is the only thing that notices a query which still returns the right answer while reading forty columns instead of two.
func (*LazyFrame) Explode ¶ added in v0.3.0
Explode turns each row's list into one row per element, repeating every other column.
id | tags -> id | tags
1 | [10, 11] 1 | 10
2 | [] 1 | 11
3 | null 2 | null
4 | [12] 3 | null
4 | 12
An empty list and a null list each produce ONE row holding a null, rather than vanishing. Dropping them would silently lower the height — which is only visible to someone who checks it — and would make two columns disagree about how many rows a pair of empty lists gives. It is also what polars does.
The consequence is worth knowing: **explode erases the difference between an empty list and a null list.** A List column keeps those apart deliberately; after this they are the same row. The distinction belongs to the list, and the list is what explode consumes.
Several columns explode TOGETHER — zipped, not multiplied. Exploding twice in sequence would give the cross product instead, so the lists must be the same length in every row, and a row where they disagree is an error rather than a truncation.
Example ¶
Split builds a List column; the .list namespace asks questions of each list, and Explode gives every element a row of its own.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
func main() {
lf := ursus.Frame(
ursus.Values("order", []int64{1, 2}),
ursus.Values("items", []string{"apple,pear", "fig"}),
).WithColumns(ursus.Col("items").Str().Split(","))
df, err := lf.WithColumns(ursus.Col("items").List().Len().Alias("n")).
Explode("items").Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (3, 3) ┌───────┬─────────┬────────┐ │ order │ items │ n │ │ Int64 │ String │ Uint32 │ ├───────┼─────────┼────────┤ │ 1 │ "apple" │ 2 │ │ 1 │ "pear" │ 2 │ │ 2 │ "fig" │ 1 │ └───────┴─────────┴────────┘
func (*LazyFrame) FillNan ¶
FillNan replaces NaN in the named columns, or in every FLOAT column when no subset is given. Nulls are left alone — see Expr.FillNan.
func (*LazyFrame) FillNull ¶
FillNull replaces nulls in the named columns, or in every column the value can fill when no subset is given.
lf.FillNull(0) // every numeric column lf.FillNull(0, "qty", "amount") // exactly these
Why the no-subset form restricts rather than refuses ¶
Almost every real frame has a string column, and `lf.FillNull(0)` erroring on it would make the shorthand useless exactly where it is most wanted. So the value's own type chooses the columns — the same argument GroupBy(k).Sum()'s doc makes for restricting to numeric columns, and with the same consequence: it is not a silent skip, because the selector is part of the expression and Explain shows precisely which columns were filled.
A named column whose type the value cannot fill is a plan-time error, because the user asked for it by name.
The value is a WEAK literal, so filling an Int32 column with 0 leaves it Int32.
func (*LazyFrame) Filter ¶
Filter keeps rows where every predicate is true.
Predicates are AND-ed. A row whose predicate is NULL is DROPPED, matching SQL's WHERE. One consequence worth knowing: Filter(p) and Filter(p.Not()) do not partition the input, because a row where p is null is dropped by both.
Example ¶
Filter keeps rows; Select keeps columns. Neither runs until Collect.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
df, err := exampleSales().
Filter(ursus.Col("qty").Gt(int64(5))).
Select(ursus.Col("region"), ursus.Col("qty")).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (3, 2) ┌─────────┬───────┐ │ region │ qty │ │ String │ Int64 │ ├─────────┼───────┤ │ "north" │ 10 │ │ "north" │ 7 │ │ "south" │ 9 │ └─────────┴───────┘
func (*LazyFrame) GroupBy ¶
GroupBy begins an aggregation.
lf.GroupBy(ursus.Col("region")).
Agg(ursus.Col("revenue").Sum(), ursus.Len().Alias("n"))
With no keys it is a GLOBAL aggregate producing exactly one row — including over an empty input, where `count(*)` is 0 rather than no rows at all.
Example ¶
GroupBy takes the keys, Agg takes what to compute per group.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
df, err := exampleSales().
GroupBy(ursus.Col("region")).
Agg(
ursus.Col("qty").Sum().Alias("total_qty"),
ursus.Col("price").Mean().Alias("avg_price"),
ursus.Len().Alias("orders"),
).
Sort(ursus.Asc(ursus.Col("region"))).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (3, 4) ┌─────────┬───────────┬───────────┬────────┐ │ region │ total_qty │ avg_price │ orders │ │ String │ Int128 │ Float64 │ Uint64 │ ├─────────┼───────────┼───────────┼────────┤ │ "east" │ 2 │ 2.25 │ 1 │ │ "north" │ 17 │ 1.875 │ 2 │ │ "south" │ 13 │ 2.25 │ 2 │ └─────────┴───────────┴───────────┴────────┘
Example (WholeFrame) ¶
GroupBy with NO keys aggregates the whole frame as one group. polars infers this from a bare select; ursus asks for it, so it cannot be confused with a row-wise select that happens to return one row.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
df, err := exampleSales().
GroupBy().
Agg(
ursus.Len().Alias("rows"),
ursus.Col("qty").Sum().Alias("total_qty"),
).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (1, 2) ┌────────┬───────────┐ │ rows │ total_qty │ │ Uint64 │ Int128 │ ├────────┼───────────┤ │ 5 │ 32 │ └────────┴───────────┘
func (*LazyFrame) GroupByDynamic ¶
func (lf *LazyFrame) GroupByDynamic(index Expr, o DynamicOptions) *GroupBy
GroupByDynamic groups rows into fixed temporal windows cut from a sorted index.
lf.GroupByDynamic(ursus.Col("ts"), ursus.DynamicOptions{
Every: ursus.Every("1h"),
GroupBy: []ursus.Expr{ursus.Col("service")},
}).
Agg(ursus.Len().Alias("n"))
It is not GroupBy(truncate(ts, every)), and the difference is empty windows ¶
A hash group-by creates a group when a row arrives, so an hour with no rows simply is not in the output and a gap in a time series is invisible. A dynamic group generates the whole grid between the first and last instant, so the gap is a row with a zero in it. That is the entire reason this operator exists.
The index must be sorted, and it is CHECKED ¶
Windows are cut as contiguous ranges of a sorted index, so an unsorted one gives wrong groups. ursus verifies rather than trusting an assertion: an unchecked SetSorted-style hint is a user claim that deletes a correctness check, and its failure mode is wrong rows with no error. Sort first if the check refuses.
func (*LazyFrame) HStack ¶
HStack places frames side by side: the same rows, with the columns concatenated.
It buffers, and Concat does not ¶
Nothing in the engine aligns batch boundaries across independent pipelines — one frame may deliver 8192 rows at a time while another delivers 100 — and pairing row i of each means having row i of each in hand. So every input is materialised. Concat, which appends rows rather than pairing them, streams.
Every frame must have the same height, and column names must not collide. There is no automatic suffixing: a join suffixes because it has a principled left and right to name the suffix after, and these inputs are peers.
func (*LazyFrame) Join ¶
func (lf *LazyFrame) Join(other *LazyFrame, opts ...JoinOption) *LazyFrame
Join combines this frame with another.
enriched := orders.Join(customers,
ursus.JoinOn(ursus.Col("customer_id")),
ursus.JoinHow(ursus.JoinLeft),
)
Column names that appear on both sides get the right one suffixed; the join key appears once unless the kind is a full join. See JoinCoalesce and JoinSuffix.
An inner join's row order is not promised: ursus hashes whichever input it estimates is smaller, and the rows follow the other. JoinMaintainOrder keeps the left frame's order.
Example ¶
Joins take the key columns and, optionally, the kind. Inner is the default.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
regions := ursus.Frame(
ursus.Values("region", []string{"north", "south", "east"}),
ursus.Values("manager", []string{"ada", "bob", "cy"}),
)
df, err := exampleSales().
Join(regions, ursus.JoinOn(ursus.Col("region"))).
Select(ursus.Col("product"), ursus.Col("manager")).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (5, 2) ┌─────────┬─────────┐ │ product │ manager │ │ String │ String │ ├─────────┼─────────┤ │ "apple" │ "ada" │ │ "apple" │ "bob" │ │ "pear" │ "ada" │ │ "pear" │ "cy" │ │ "fig" │ "bob" │ └─────────┴─────────┘
func (*LazyFrame) JoinAsOf ¶
func (lf *LazyFrame) JoinAsOf(other *LazyFrame, opts ...AsOfOption) *LazyFrame
JoinAsOf matches each left row to the NEAREST right row rather than an equal one.
trades.JoinAsOf(quotes,
ursus.AsOfOn(ursus.Col("ts")),
ursus.AsOfBy(ursus.Col("symbol")),
ursus.AsOfTolerance(ursus.Every("1m")),
)
It is a LEFT join ¶
Every left row survives; the right columns are null when nothing is near enough. So the output height always equals the left height, tolerance and strategy only decide which rows come back populated.
Both sides must be sorted on the as-of key, and it is CHECKED ¶
The search is a binary search over a sorted run. ursus verifies rather than trusting an assertion — an unchecked sortedness hint is a user claim that deletes a correctness check, and its failure mode is wrong matches with no error. Sort first if the check refuses.
Memory ¶
The default, backward, streams both sides: it holds a row a by-key and a few right batches, however long the right side is (step 183). Forward and nearest hold the right side, since a row at or after a left key may lie anywhere ahead.
func (*LazyFrame) JoinWhere ¶ added in v0.3.0
JoinWhere joins every pair of rows that satisfies the predicates.
sessions.JoinWhere(events,
ursus.Col("ts").Ge(ursus.Col("start")),
ursus.Col("ts").Lt(ursus.Col("end")),
)
This is the non-equi join: the predicates may be any boolean expression over both sides, and are AND-ed. Several predicates are the usual case, because an interval match takes two.
Which names the predicates use ¶
The join's OUTPUT names, which is to say left columns by their own name and a colliding right column with the suffix — `k` and `k_right` by default. There is no separate left/right namespace to qualify with, and the same rule applies here as to a Filter written after any other join. JoinSuffix changes the suffix.
What it costs ¶
A pair of rows for every pair that satisfies the predicates, which can be very large — that is the nature of the operation and not a defect. When at least one predicate is an EQUALITY between the two sides, the optimizer turns it into a hash join key and evaluates the rest per matching pair; without one, every pair is tested. Explain shows which happened.
At least one predicate is required. A JoinWhere with none is a cartesian product written as though it were a filtered one, and the cardinality difference is |L| vs |L|x|R| — so it is an error rather than a silent cross join, for the same reason a keyless equi-join is.
Example ¶
A join on a condition rather than equal keys: each sale with the price band it falls in.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
bands := ursus.Frame(
ursus.Values("band", []string{"cheap", "dear"}),
ursus.Values("lo", []float64{0, 2}),
ursus.Values("hi", []float64{2, 10}),
)
df, err := exampleSales().Select(ursus.Col("product"), ursus.Col("price")).
JoinWhere(bands,
ursus.Col("price").Ge(ursus.Col("lo")),
ursus.Col("price").Lt(ursus.Col("hi"))).
Select(ursus.Col("product"), ursus.Col("price"), ursus.Col("band")).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (5, 3) ┌─────────┬─────────┬─────────┐ │ product │ price │ band │ │ String │ Float64 │ String │ ├─────────┼─────────┼─────────┤ │ "apple" │ 1.5 │ "cheap" │ │ "apple" │ 1.5 │ "cheap" │ │ "pear" │ 2.25 │ "dear" │ │ "pear" │ 2.25 │ "dear" │ │ "fig" │ 3 │ "dear" │ └─────────┴─────────┴─────────┘
func (*LazyFrame) MergeSorted ¶
MergeSorted interleaves this frame with another that is sorted on the same key, keeping the result sorted.
Not a concat: a concat appends, so the result is ordered only if the second frame begins after the first ends. Not a join either — nothing is matched and no row is dropped, so the output height is always the sum of the two.
Both schemas must match EXACTLY and both inputs must be sorted on key, which is checked rather than assumed, as the rows arrive: it streams, a batch of each side at a time, so its memory does not grow with the inputs (step 182).
func (*LazyFrame) Remove ¶
Remove is the inverse of Filter: it drops rows where ANY predicate is true.
Spelled out because the alternative reading is equally plausible: this is NOT(p1 OR p2 OR ...), i.e. a row survives only if every predicate is false. Rows where a predicate is null are dropped, same as Filter.
func (*LazyFrame) Rename ¶
Rename changes column names, leaving everything else in place.
Sugar over `Select(All().MapName(...))`. MapName is the expansion-safe renamer — Alias sets ONE fixed name and so cannot apply to a multi-column selection, which is exactly why the two are different methods.
Names not present in the frame are ignored rather than an error, so a rename map written against a wider schema still works. Renaming two columns onto the same name IS an error, caught by Select's output-name uniqueness check with both offending expressions named.
func (*LazyFrame) Reverse ¶
Reverse emits rows in the opposite order.
A full pipeline breaker — the last input row is the first output row — so it holds the whole frame. It does no comparison, which is what makes it cheaper than sorting by a synthesised descending index.
func (*LazyFrame) Rolling ¶
func (lf *LazyFrame) Rolling(index Expr, o RollingOptions) *GroupBy
Rolling gives every row its own window, reaching back Period from that row's own instant. The output has one row per input row.
Where GroupByDynamic answers "how many per hour", Rolling answers "how many in the hour before each event". The index must be sorted, and is checked.
func (*LazyFrame) Select ¶
Select computes a new set of columns, replacing the frame's columns entirely.
Expressions may expand: Select(All()) keeps everything, and Select(ColDType(Float64).Suffix("_f")) selects and renames every float column.
func (*LazyFrame) SinkCSV ¶
SinkCSV runs the query and writes the result to path, streaming.
This is the half that makes "larger than RAM" true end to end. Collect holds the whole result; this holds one batch, so a query that reads more data than fits in memory and writes more data than fits in memory works.
The file is written to a temporary name and renamed on success, so a failed query leaves no half-written file where a complete one is expected.
func (*LazyFrame) SinkParquet ¶
SinkParquet runs the query and writes the result to path, streaming.
Memory is one row group rather than the whole result, so this is the write half of "larger than RAM". The file is written to a temporary name and renamed on success: a Parquet file without its footer is not merely truncated, it is unreadable, so leaving a partial one where a complete one is expected would be worse than leaving nothing.
func (*LazyFrame) Slice ¶
Slice keeps length rows starting at offset. A negative length means "to the end", which is how you drop a prefix without knowing the height.
Streaming: it counts rows past and stops, so it never holds more than one batch.
func (*LazyFrame) Tail ¶
Tail keeps the last n rows.
Unlike Head it cannot stream to completion: which rows are the last n is unknown until the input ends, so it holds a ring of the most recent n. Bounded — O(n), not O(input) — but not free.
func (*LazyFrame) TopK ¶
TopK keeps the k LARGEST rows by the given keys, and BottomK the k smallest.
Both are sugar, and that is the point ¶
Each is `Sort(...).Head(k)`, which the limit-pushdown rule turns into a bounded top-k: kernel.ArgTopK is O(n log k) time and O(k) memory against a full sort's O(n log n) and O(n), and its contract is that it returns EXACTLY the indices ArgSort would, ties included. So there is no separate operator to keep in step with Sort, and `TopK(k)` and `Sort(...).Head(k)` cannot drift apart because they are the same plan.
Both place nulls LAST, unlike Sort ¶
Sort keeps direction and null placement orthogonal on purpose — "`.Desc()` silently relocating the nulls surprises people every time" — and that argument does NOT transfer here. In a sort, placement is cosmetic: every row comes back either way. In a top-k it is SELECTION, and a null has no rank, so leaving the default (nulls first) would make `TopK(2)` over [3,1,4,1,5,null] return the null and the 5 — a row with no value in the very column being ranked.
So both force nulls last, and both say so. `Sort(...).Head(k)` remains available for a different placement, and it is one call away.
func (*LazyFrame) Unique ¶
Unique removes duplicate rows.
With no arguments it compares whole rows. With column names it compares only those, keeping the FIRST row for each distinct combination — so the other columns come from that row rather than being chosen arbitrarily.
func (*LazyFrame) Unnest ¶ added in v0.3.0
Unnest replaces a struct column with its fields, in place.
id | person -> id | age | city
1 | {age: 30, city: "NY"} 1 | 30 | "NY"
2 | {age: null, city: null} 2 | null | null
3 | null 3 | null | null
It is to a struct what Explode is to a list: afterwards the nested column is gone and the fields are ordinary columns, so every filter, aggregate, join and sort applies to them directly rather than through `.Struct().Field(...)`.
The fields land where the struct was and keep their OWN names. A name that would collide with a column already in the frame is refused, naming both — renaming a field the caller cannot see yet is not something this can guess at.
**Unnest erases the difference between a null struct and a struct of nulls**, the way Explode erases the difference between a null list and an empty one (rows 2 and 3 above). The distinction belongs to the struct, and this is what consumes it — `Col("person").IsNull()` is how to keep it, before unnesting.
At least one column must be named. "Every struct column" is well defined here, unlike for Explode, but it would silently widen the frame the day a file grows a struct.
func (*LazyFrame) Unpivot ¶ added in v0.3.0
func (lf *LazyFrame) Unpivot(opts UnpivotOptions) *LazyFrame
Unpivot turns several value columns into two: which column, and what it held.
sales.Unpivot(ursus.UnpivotOptions{On: []string{"jan", "feb"}})
id jan feb id variable value
1 10 20 -> 1 jan 10
2 30 40 1 feb 20
2 jan 30
2 feb 40
Known elsewhere as melt. Every melted column shares one value column, so their types must promote to a common one — an Int32 beside an Int64 gives an Int64, and two types with no common one is an error naming both.
The row order is not Polars' ¶
Polars emits every row's first variable, then every row's second. This emits every variable for one row, then the next row.
The reason is that ursus streams: unpivot transforms one batch at a time, so Polars' order would interleave differently at different batch sizes — the row order would depend on how the query ran rather than on what it asked for. Sort afterwards if a particular order matters.
Index defaults; On does not ¶
Leaving Index empty keeps every column that is not melted, so adding a column to the source keeps it. Unnest refuses the equivalent default for the opposite reason: there, a new column would silently start being CONSUMED.
func (*LazyFrame) VStack ¶
VStack is Concat for exactly two frames, named for the operation people look for.
It is not cheaper than Concat. Polars documents vstack as "cheap (adds a chunk)", which relies on a chunked column layout ursus does not have — a Column here is one contiguous run, so stacking copies. Naming it the same and pretending otherwise would be the misleading part.
func (*LazyFrame) ValueCounts ¶ added in v0.5.0
ValueCounts counts the rows of each distinct value of by, most frequent first, in a column named "count": Polars' value_counts, sorted.
lf.ValueCounts(Col("city")) // city, count
lf.ValueCounts(Col("city"), Col("year")) // each combination
Ties keep the order in which their values first appear, and a null is a value like any other, as a group-by's key. It is GroupBy(by...).MaintainOrder(), Len, and a stable Sort by count, descending. A key named "count" is refused, as Polars refuses it; alias it first.
func (*LazyFrame) WhereExists ¶ added in v0.3.0
WhereExists keeps the rows of this frame that have at least one row in other satisfying every predicate.
orders.WhereExists(lineitem,
ursus.Col("o_orderkey").Eq(ursus.Col("l_orderkey")),
ursus.Col("l_quantity").Gt(40.0),
)
It is SQL's `EXISTS (SELECT ... WHERE ...)`, and the output is this frame: same columns, same order, fewer rows. No column of other appears in the result, which is why this is not a join you can write as one — see below.
Why it is not JoinWhere plus a filter ¶
JoinWhere pairs rows, so filtering its output filters pairs. This asks whether a satisfying partner EXISTS, which is a question about the left row, and a left row with three partners must come back once rather than three times. The predicate is therefore evaluated before the verdict, inside the join, and no combination of JoinWhere and Filter expresses that without a deduplication.
Which names the predicates use ¶
The names a JOIN of the two frames would produce: this frame's columns by their own name, and a colliding column of other with the suffix — `k` and `k_right`. The output has no such column, but the predicate is evaluated on pairs, so it can name both.
What it costs ¶
An equality between the two sides becomes a hash join key and the rest is tested per matching pair; with no equality among the predicates, every pair is tested. Explain shows which. A partner is found by scanning the candidates, so this stops at the first satisfying one — WhereNotExists cannot.
func (*LazyFrame) WhereNotExists ¶ added in v0.3.0
WhereNotExists keeps the rows of this frame that have NO row in other satisfying every predicate.
// customers who never ordered anything large
customers.WhereNotExists(orders,
ursus.Col("c_custkey").Eq(ursus.Col("o_custkey")),
ursus.Col("o_totalprice").Gt(1000.0),
)
SQL's `NOT EXISTS`, and the exact complement of WhereExists: for any frame and any predicates, the two partition this frame's rows.
It is strictly more work than WhereExists on the same inputs. Finding one satisfying partner ends the search; proving there is none means examining every candidate.
func (*LazyFrame) WithColumns ¶
WithColumns adds columns, replacing any that already exist BY NAME and keeping their original position.
Later expressions can reference columns that earlier ones added, so `WithColumns(a.Alias("x"), Col("x").Mul(2))` works.
Example ¶
Expressions compose: a computed column is just another expression.
package main
import (
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
// exampleSales is the little frame the examples below work on.
func exampleSales() *ursus.LazyFrame {
return ursus.Frame(
ursus.Values("region", []string{"north", "south", "north", "east", "south"}),
ursus.Values("product", []string{"apple", "apple", "pear", "pear", "fig"}),
ursus.Values("qty", []int64{10, 4, 7, 2, 9}),
ursus.Values("price", []float64{1.50, 1.50, 2.25, 2.25, 3.00}),
)
}
func main() {
df, err := exampleSales().
WithColumns(ursus.Col("qty").Mul(ursus.Col("price")).Alias("revenue")).
Select(ursus.Col("product"), ursus.Col("revenue")).
Collect(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (5, 2) ┌─────────┬─────────┐ │ product │ revenue │ │ String │ Float64 │ ├─────────┼─────────┤ │ "apple" │ 15 │ │ "apple" │ 6 │ │ "pear" │ 15.75 │ │ "pear" │ 4.5 │ │ "fig" │ 27 │ └─────────┴─────────┘
func (*LazyFrame) WithRowIndex ¶
WithRowIndex prepends a Uint32 column numbering the rows from offset.
Row order is a property rather than an addressable label space, which is why there is no implicit index — this is how you ask for one when you want it.
It is a counter that crosses batch boundaries, so it depends on batches arriving in input order, and it is deliberately not the same thing as `CumCount(false).Over()`: that computes identical numbers through the window sink, which buffers the entire frame to do it.
func (*LazyFrame) WriteCSV ¶
WriteCSV runs the query and writes the result to w, streaming.
It does not close w. Whoever opened it closes it — the only rule that works when the destination might be os.Stdout.
func (*LazyFrame) WriteParquet ¶
func (lf *LazyFrame) WriteParquet(ctx context.Context, w io.Writer, opts ...ParquetSinkOption) (err error)
WriteParquet runs the query and writes the result to w, streaming. It does not close w.
Example ¶
A List column writes to Parquet and reads back, in the standard encoding PyArrow and Polars read too.
package main
import (
"bytes"
"context"
"fmt"
"log"
"github.com/advenn/ursus"
)
func main() {
ctx := context.Background()
orders := ursus.Frame(
ursus.Values("order", []int64{1, 2}),
ursus.Values("items", []string{"apple,pear", "fig"}),
).WithColumns(ursus.Col("items").Str().Split(","))
var file bytes.Buffer
if err := orders.WriteParquet(ctx, &file); err != nil {
log.Fatal(err)
}
df, err := ursus.ScanParquetBytes(file.Bytes(), "orders.parquet").Collect(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(df)
}
Output: shape: (2, 2) ┌───────┬───────────────────┐ │ order │ items │ │ Int64 │ List(String) │ ├───────┼───────────────────┤ │ 1 │ ["apple", "pear"] │ │ 2 │ ["fig"] │ └───────┴───────────────────┘
type ListExpr ¶ added in v0.3.0
type ListExpr struct {
// contains filtered or unexported fields
}
ListExpr is the list namespace: `Col("tags").List().Len()`.
The same wrapper shape `.str` and `.dt` use, for the reason ursus-api.md §4.5 gives: methods return Expr, so chaining continues naturally through it.
Two halves, told apart by what they return ¶
Len, Get, Contains, Min, Max, Sum and Mean answer a question ABOUT a list and hand back an ordinary column — a length, an element, a total.
Reverse, Head, Tail, Slice, Sort, Unique and DropNulls RESHAPE the list and hand back a list, so they chain into each other and into the first half: `Sort().Head(3).Get(0)`. The element type is unchanged by all seven — reshaping a list cannot change what it is a list OF.
The set operations (`set_union` and friends) and `gather` take a SECOND list and are not here yet.
A null list is not an empty list ¶
It is the distinction the whole List type is careful about, and it survives into every method:
Len() Sum() Min() Get(0) Contains(x) [1, 2] 2 3 1 1 as asked [] (empty) 0 null null null false null (list) null null null null null
An empty list HAS a length, and it is zero. A null list has no length at all. An empty list contains nothing, which is a false answer to a real question; a null list cannot answer it. Working from "both are empty" gives three wrong cells here, none of which errors.
The aggregates are where the two agree, and that is a choice: polars gives 0 for the sum of an empty list, ursus gives null, because ursus's Sum gives null for a group with no values. Agreeing with the aggregate beside it beats agreeing with polars.
func (ListExpr) Contains ¶ added in v0.3.0
Contains reports whether the list holds v.
The comparison is ursus's, not Go's: values go through the same group-key encoder `IsIn` uses, so NaN matches NaN and -0.0 matches +0.0, consistently with how GroupBy and Sort treat them.
The element and v meet as == would compare them, as IsIn's values do: v of a type == refuses is refused, and `Contains(5000)` against Int8 elements is false.
func (ListExpr) DropNulls ¶ added in v0.3.0
DropNulls removes the null ELEMENTS from each list. A null LIST stays null — there is nothing to drop from, which is not the same as having dropped everything.
func (ListExpr) Get ¶ added in v0.3.0
Get returns the element at i, or null if there is none there.
A negative index counts from the END, the convention every dataframe library uses and the one `.str.slice` already follows here.
Out of range is null rather than an error. Lists are ragged by nature, so asking for element 3 of a column whose rows mostly hold two is an ordinary question — erroring would make `Get` unusable on exactly the data it is for.
func (ListExpr) Head ¶ added in v0.3.0
Head keeps the first n elements of each list, Tail the last n.
Asking for more than a row holds returns the whole row, not an error and not a padded list. Lists are ragged by nature, so asking for three tags from a column whose rows mostly have two is an ordinary question — the same reasoning Get uses for an index past the end.
A negative n is refused: "the first -2 elements" has no reading worth guessing at. Slice is where a negative number means something.
func (ListExpr) Join ¶ added in v0.5.0
Join joins each list's strings with sep, skipping null elements, as Polars' list.join does by default: ["a", null, "b"] joins with "-" to "a-b". An empty list joins to "", and a null list is null. The list must be of String; cast it first otherwise, e.g. .Cast(List(String)).
func (ListExpr) Len ¶ added in v0.3.0
Len is the number of elements, as a Uint32 — matching `.str.len_bytes`, since a length is never negative and never needs 64 bits.
Null ELEMENTS are counted: they occupy a slot, so `[1, null]` has length 2. A null LIST has no length and gives null.
func (ListExpr) Mean ¶ added in v0.3.0
Mean averages each list, as a Float64. Null elements are skipped, so the divisor is the count of PRESENT elements.
func (ListExpr) Min ¶ added in v0.3.0
Min and Max reduce each list to its smallest or largest element, keeping the element type. Null elements are skipped, exactly as the column-wide aggregates skip null rows.
func (ListExpr) Reverse ¶ added in v0.3.0
Reverse puts each list's elements in the opposite order. Null elements move with everything else — they occupy a slot.
func (ListExpr) Slice ¶ added in v0.3.0
Slice takes length elements from offset.
A negative OFFSET counts from the end, matching Get and `.str.slice`; the clamping is `.str.slice`'s too, so an offset past either edge lands in range and a length running off the end stops there. A negative length is refused.
func (ListExpr) Sort ¶ added in v0.3.0
Sort orders each list's elements ascending; SortDesc orders them descending.
The ordering is ursus's own — the same comparator a column-wide Sort uses — so a list of values and a column of the same values order identically. That includes null placement: nulls come FIRST, matching the default of `Asc()`. Both placements are defensible and this is the one ursus picked.
Equal elements keep their input order.
func (ListExpr) Sum ¶ added in v0.3.0
Sum totals each list.
It widens an integer element to Int128, because the column-wide Sum does and a per-row sum that overflowed where that one does not would be the worse surprise. The rule is not restated here — the type comes from the aggregate's own binding, so the two cannot drift.
An empty list sums to NULL, not zero — the same answer ursus's Sum gives for a group with no values. polars differs here.
func (ListExpr) Unique ¶ added in v0.3.0
Unique drops repeated elements, keeping the FIRST occurrence and leaving the order alone — sorting as a side effect would make `Unique().Head(2)` mean something different from `Head(2)` on the same data.
Equality is ursus's, as it is for Contains: NaN dedupes against NaN. A null element counts as a value here, so a list of three nulls keeps one.
type Literal ¶
type Literal interface {
~bool |
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64 |
~string | ~[]byte |
time.Time
}
Literal is the set of Go types that lift to a column literal.
Every accepted width is enumerated explicitly because Go infers a type parameter from the argument's type with NO implicit widening: `a.Gt(int32(5))` is a compile error unless ~int32 appears here.
time.Duration is DELIBERATELY ABSENT. Its underlying type is int64, so listing it alongside ~int64 gives overlapping type sets and the union does not compile — this is the defect the design review found. ~int64 already admits it, and a type switch recovers the named type exactly, so nothing is lost.
time.Time is a bare (non-~) term: it is a defined struct type, disjoint from every other term. A union term may be a type with methods; only interface terms with methods are forbidden.
type MappingStrategy ¶
type MappingStrategy = expr.MappingStrategy
MappingStrategy decides how a window's per-partition result returns to the frame.
type MemoryStats ¶
type MemoryStats struct {
// Peak is the largest total the query retained at one moment, in bytes.
//
// It counts what buffering operators HOLD ACROSS BATCHES, deduplicated by
// allocation so a payload two operators share is counted once. It does not
// count a kernel's transient output, because ursus's allocator reports
// nothing and there is no hook at the allocation site.
//
// Nor the result Collect assembles. Under the default budget Collect counts
// that apart, against a limit of its own: see Collect.
Peak int64
// Limit is the ceiling that was in force, or 0 if there was none.
Limit int64
// Spills is how many files a spilling operator wrote: sorted runs for a sort,
// radix partitions for a group-by, and for a join BOTH sides' partitions —
// counted at every level of the recursion.
//
// It is a count of FILES, not of bytes and not of distinct spill events, and a
// group-by that recursed reports more than one level's fan-out — which is the
// only public evidence that a partition did not fit on the first try.
Spills int64
}
MemoryStats reports what a query actually held.
Peak is the number that demonstrates bounded memory: that a query FINISHED proves only that it did not run out, while a peak far below the input size proves it never held the input at all.
type NonExistent ¶ added in v0.6.0
type NonExistent string
NonExistent answers a wall clock that ReplaceTimeZone's new zone never reads, in the hour its clocks spring forward: Polars' non_existent.
const ( NonExistentRaise NonExistent = expr.ZoneRaise // refuse the query; the default NonExistentNull NonExistent = expr.ZoneNull // a null )
type Operand ¶
Operand is either another Expr or a Go scalar that lifts to a literal.
This union — a struct type alongside an embedded constraint interface — is what lets every binary operator accept both forms with no wrapper at the call site.
type OptFlags ¶ added in v0.6.0
type OptFlags struct {
ProjectionPushdown bool // read only the columns a query uses
PredicatePushdown bool // filter as early as the rows allow
LimitPushdown bool // stop reading once a Head has its rows
SimplifyExprs bool // fold constants and simplify expressions
JoinPredicatePushdown bool // predicate pushdown's arm through joins
CollapseCrossJoin bool // a filter over a cross join becomes a join
BuildSide bool // an inner join hashes its smaller input
RuntimeFilters bool // a join's keys filter its probe side's source
}
OptFlags chooses which optimizer rules run, for WithOptFlags: each is on in DefaultOptFlags, the optimizer every query gets, and off in NoOptFlags.
q.Collect(ctx, ursus.WithOptFlags(ursus.NoOptFlags())) // the plan as written f := ursus.DefaultOptFlags() f.BuildSide = false // all but one rule q.Collect(ctx, ursus.WithOptFlags(f))
A rule changes how a query runs, never its answer; turning one off is how a wrong answer is bisected to the rule that caused it, or a rule's worth measured.
func DefaultOptFlags ¶ added in v0.6.0
func DefaultOptFlags() OptFlags
DefaultOptFlags is every rule on, as a query runs by default.
func NoOptFlags ¶ added in v0.6.0
func NoOptFlags() OptFlags
NoOptFlags is every rule off: the plan runs as it was written.
type ParquetFile ¶ added in v0.3.0
type ParquetFile struct {
// Name identifies the file in errors and in Explain.
Name string
// Open returns the file's bytes and their size.
//
// It is called more than once per query — once to read the schema when the
// query is planned, once for each execution — and must return a fresh reader
// each time. ctx is the query's and outlives the reader, so a reader over a
// network may keep it for its reads. If the reader is also an io.Closer, ursus
// closes it when it is done with it.
Open func(ctx context.Context) (r io.ReaderAt, size int64, err error)
}
ParquetFile is one Parquet file, read through ReadAt from wherever it lives — an object store, an HTTP server, an archive — by code that knows how to reach it.
ursus reads a file the way an object store wants to be read: the footer from the end, then one ranged read for each column chunk the query needs, after the statistics have pruned the row groups it does not. A ReadAt over a ranged GET therefore fetches what the query reads and nothing else.
type ParquetOption ¶
ParquetOption configures ScanParquet.
func WithPruning ¶
func WithPruning(b bool) ParquetOption
WithPruning enables or disables row-group skipping from column statistics.
It is on by default. The switch exists because pruning is the one part of the Parquet reader that can change which rows come back, so a wrong answer must be bisectable to it — the same reason plan.Flags can disable each optimizer rule.
type ParquetSinkOption ¶
type ParquetSinkOption interface {
// contains filtered or unexported methods
}
ParquetSinkOption is anything SinkParquet and WriteParquet accept: a writer option (WithCompression, WithRowGroupRows, WithStatistics) or an execution option (WithMemoryLimit, WithSpillDir, WithBatchSize, WithThreads).
A sink is where a memory limit matters most — it is the consumer that streams, so it is the one a larger-than-RAM query ends in — and before this the two option families could not meet.
It is an interface rather than a variadic `...any` because the union has to be checked at compile time. A library that uses generic methods and an Operand constraint to stop `Col("x").Gt(struct{}{})` from compiling should not then accept SinkParquet(ctx, path, "oops").
type ParquetWriteOption ¶
type ParquetWriteOption func(*parquet.WriteOptions)
ParquetWriteOption configures the Parquet writer.
func WithCompression ¶
func WithCompression(c compress.Compression) ParquetWriteOption
WithCompression sets the codec applied to every column. Default Snappy.
func WithRowGroupRows ¶
func WithRowGroupRows(n int) ParquetWriteOption
WithRowGroupRows sets the target row-group size. It is the unit of both pruning granularity and writer memory: smaller groups prune better and buffer less.
func WithStatistics ¶
func WithStatistics(b bool) ParquetWriteOption
WithStatistics enables or disables column statistics. On by default — a file without them cannot be pruned, which gives up the main reason to use Parquet.
type RollingOptions ¶
type RollingOptions struct {
// Period is how far back each row's window reaches from its own instant.
Period Interval
// Offset shifts the window's end away from the row's own instant.
Offset Interval
// Closed says which end belongs to the window. The zero value is ClosedRight —
// (end-period, end] — because a rolling window ends AT the row, so closing the
// left end instead would drop every row from its own window.
Closed Closed
// GroupBy adds categorical keys, as in DynamicOptions.
GroupBy []Expr
}
RollingOptions configures Rolling.
type Schema ¶
Schema is an ordered, name-unique sequence of Fields.
func MustSchema ¶
MustSchema is NewSchema for tests and package-level vars.
type SortKey ¶
type SortKey struct {
// contains filtered or unexported fields
}
SortKey is one component of an ordering. Build one with Asc or Desc.
func Desc ¶
Desc orders descending, with nulls still FIRST by default.
Null placement is deliberately independent of direction. SQL ties them together — nulls sort as the largest value, so they land last ascending and first descending — which means switching to Desc silently relocates them. Keeping the two orthogonal is more predictable.
func (SortKey) NullsFirst ¶
NullsFirst places nulls at the start of the output.
type Span ¶
Span is an interval in either spelling — the calendar-aware Interval, or a plain time.Duration for the absolute case.
The same union shape as Operand, and for the same reason: it lets one method accept both forms with no wrapper at the call site, so `Truncate(time.Hour)` keeps compiling now that `Truncate(Every("1mo"))` is also legal.
type StrExpr ¶
type StrExpr struct {
// contains filtered or unexported fields
}
StrExpr is the string namespace: `Col("name").Str().ToLower()`.
A small wrapper struct whose methods return Expr, so chaining continues naturally through it. This is the shape ursus-api.md §4.5 specifies for every namespace, and `.dt` follows it identically.
The literal flag ¶
Contains, Find, CountMatches, Replace and ReplaceAll take `literal bool`, and literal is what you want unless you know otherwise. Go's regexp is RE2: no backtracking, so no catastrophic blowup, but also no JIT — a plain substring test through it costs far more than strings.Contains. Passing literal=true keeps the common case on the fast path, which is why the flag is explicit rather than inferred from whether the pattern looks like a regex.
func (StrExpr) CountMatches ¶
CountMatches counts non-overlapping occurrences. An empty pattern, literal or regex, matches at every rune boundary: len_chars + 1 times.
func (StrExpr) EscapeRegex ¶ added in v0.3.0
EscapeRegex quotes every regex metacharacter, so the result matches itself literally when used as a pattern.
func (StrExpr) Extract ¶
Extract returns capture group n of the first match, or NULL if there is none. Group 0 is the whole match. Regex only: extracting a literal is just Find.
func (StrExpr) ExtractAll ¶ added in v0.3.0
ExtractAll returns every non-overlapping match of a regex as a List(String).
Regex only, for the reason Extract is: extracting every literal occurrence of a pattern returns that many copies of the pattern.
func (StrExpr) Find ¶
Find returns the byte offset of the first match, or NULL when there is none.
Null rather than -1: a sentinel index would compare and do arithmetic like a real position, so `find(x) < 5` would be true for "not found".
func (StrExpr) Join ¶ added in v0.5.0
Join joins a column's strings into one, with sep between each two and nulls skipped: Polars' str.join. It is an aggregate, so GroupBy().Agg gives one row for the frame, and GroupBy(keys).Agg one per group:
df.GroupBy(Col("team")).Agg(Col("name").Str().Join(", "))
It is Implode, then List().Join, and its order is the rows': a group-by's rows arrive in the input's order within each group.
func (StrExpr) LenBytes ¶
LenBytes counts bytes; LenChars counts runes. They differ on any non-ASCII input, and which one is wanted is not guessable — so there is no `Len`.
func (StrExpr) PadStart ¶ added in v0.3.0
PadStart and PadEnd pad each value to width, counted in RUNES, using the first rune of fill. A value already that wide is returned unchanged — padding never truncates.
func (StrExpr) Replace ¶
Replace substitutes the FIRST match; ReplaceAll substitutes every match.
For a regex, the replacement expands $1, ${1} and ${name} in both, as Go's regexp.Expand does — so $1x names a group called "1x", and ${1}x is group 1 then an x. A literal pattern's replacement is literal, $ and all.
func (StrExpr) Slice ¶
Slice takes length runes from offset, which may be negative to count from the end. Runes rather than bytes, so it can never split a character in half.
func (StrExpr) Split ¶ added in v0.3.0
Split splits each value on a literal separator, producing a List(String).
lf.WithColumns(ursus.Col("tags").Str().Split(",").Alias("tag")).
Explode("tag")
That two-stage form is the canonical use, and it is two stages because Explode changes the frame's height, which an expression may not.
Three cases that are not the same ¶
"a,b" -> ["a", "b"] "" -> [""] one empty element, matching strings.Split NULL -> NULL a null list, not an empty one
The separator is LITERAL. A regex split would be a different function, and splitting on "." meaning "any character" is the mistake it would invite.
func (StrExpr) SplitN ¶ added in v0.3.0
SplitN is Split with at most n parts; the last part keeps the rest, separators and all. n <= 0 means unlimited — unlike strings.SplitN, whose 0 means none.
func (StrExpr) StartsWith ¶
StartsWith and EndsWith are always literal — a prefix match against a regex is not a well-defined thing to ask for.
func (StrExpr) StripChars ¶
StripChars trims any of the given characters from both ends. An empty set trims whitespace, matching Polars.
func (StrExpr) StripCharsEnd ¶ added in v0.3.0
func (StrExpr) StripCharsStart ¶ added in v0.3.0
StripCharsStart and StripCharsEnd are the one-sided forms of StripChars: they remove any leading (or trailing) character in the cutset. An empty cutset means Unicode whitespace, as it does for StripChars.
func (StrExpr) StripPrefix ¶
func (StrExpr) StripSuffix ¶
func (StrExpr) Strptime ¶ added in v0.4.0
Strptime parses each string with a strftime-style format into dt, a Date, Datetime or Time — Polars' str.strptime.
Col("when").Str().Strptime(ursus.Date, "%d/%m/%Y", true)
Col("ts").Str().Strptime(ursus.Datetime(ursus.Micro, "UTC"), "%Y-%m-%d %H:%M:%S%.f %z", true)
The directives are strftime's: %Y %m %d %H %M %S, %.f for a fraction, %z for an offset, %b and %B for month names, %j, %p, and the rest (internal/strftime lists them). A format that cannot produce dt is refused while the query is planned: a Date needs a date, a Time has none, and only a Datetime holds an offset.
Strict, a value that does not match — or names a day that does not exist, such as 30 February — is an error naming it. Otherwise it becomes null, as ToDate's do.
A Datetime takes its instant from the offset when the format reads one; otherwise the wall clock is read in dt's zone, or kept as it is when dt is naive. Digits finer than the unit are dropped.
func (StrExpr) ToInteger ¶
ToInteger, ToDate and ToDatetime parse. They are ordinary casts, which is why they are spelled as casts rather than as new kernels — and unparseable values become NULL rather than failing the query, as do integers past Int64's range. ToInteger parses exactly at 64 bits: "9007199254740993" is itself, not the float64 beside it.
type StructExpr ¶ added in v0.3.0
type StructExpr struct {
// contains filtered or unexported fields
}
StructExpr is the struct namespace: `Col("person").Struct().Field("age")`.
The same wrapper shape `.str`, `.dt` and `.list` use, for the reason ursus-api.md §4.5 gives: methods return Expr, so chaining continues naturally through it.
A null struct is not a struct of nulls ¶
Both hold no field values, and only the struct's own validity separates them:
Field("age") renders as
{age: 30, city: "NY"} 30 {age: 30, city: "NY"}
{age: null, city: null} null {age: null, city: null}
null (the struct) null null
The last two rows answer identically through Field — there is no age either way — and differ where it matters, on the struct itself. Working from "a struct with nothing in it is absent" gives the wrong answer for a real struct that happens to hold nulls, and nothing errors.
func (StructExpr) Field ¶ added in v0.3.0
func (s StructExpr) Field(name string) Expr
Field reads one field out of each struct.
The result is an ordinary column of the field's own type, so everything else in the language applies to it: filter on it, group by it, join on it.
A name the struct does not have is refused when the query is PLANNED, with the available names listed — the type of the result depends on which field was asked for, so the name has to be resolved against the schema anyway.
func (StructExpr) WithFields ¶ added in v0.6.0
func (s StructExpr) WithFields(exprs ...Expr) Expr
WithFields sets fields of each struct: an expression named as one of its fields replaces that field in place, and one of a new name is added after the others, each named as its expression is. Polars' struct.with_fields.
Col("person").Struct().WithFields(
Col("person").Struct().Field("age").Add(1).Alias("age"), // replaces age
Col("city"), // adds city
)
Each expression is read from the frame, so a field of the struct is read as it is anywhere, through Field, and named as any expression is: a Field read is named after its struct, so alias it to set a field of that name. The struct's own validity is kept: a null struct stays null. Two expressions of one name would set one field twice, and are refused.
type ThenBuilder ¶
type ThenBuilder struct {
// contains filtered or unexported fields
}
ThenBuilder is a conditional that may take another When or be closed by Otherwise.
func (ThenBuilder) Otherwise ¶
func (t ThenBuilder) Otherwise[T Operand](v T) Expr
Otherwise supplies the fall-through value and closes the chain.
The chain becomes a right-nested tree of conditionals, in the order written: when(c1).then(v1).when(c2).then(v2).otherwise(d) is Cond{c1, v1, Cond{c2, v2, d}}. Nesting rather than a flat list is what keeps type unification well-defined — promotion is pairwise, and folding a flat list would make the result depend on an association order the user never chose.
func (ThenBuilder) When ¶
func (t ThenBuilder) When(pred Expr) WhenBuilder
When adds another condition, the else-if of the chain.
type UnpivotOptions ¶ added in v0.3.0
type UnpivotOptions struct {
// On names the value columns to melt into the variable/value pair.
On []string
// Index names the columns carried through unchanged. Empty means every column
// that is not in On.
Index []string
// VariableName and ValueName name the two invented columns. Empty means
// "variable" and "value", which are Polars' names.
VariableName string
ValueName string
}
UnpivotOptions configures Unpivot.
On is required; everything else has a working default, so the common call is UnpivotOptions{On: []string{...}}.
type WhenBuilder ¶
type WhenBuilder struct {
// contains filtered or unexported fields
}
WhenBuilder is a conditional awaiting its Then.
func When ¶
func When(pred Expr) WhenBuilder
When begins a conditional.
ursus.When(ursus.Col("score").Ge(90)).Then("A").
When(ursus.Col("score").Ge(80)).Then("B").
Otherwise("F").Alias("grade")
Why a builder and not a function ¶
The alternative was a variadic Case(cond1, val1, cond2, val2, …, default). That reads compactly and pairs its arguments POSITIONALLY, so a miscounted list is a runtime error rather than a type error — and the compiler cannot tell a condition from a value when both are Expr. The builder makes each pair a method call, so there is nothing to miscount.
An unterminated chain cannot be used, by construction ¶
Only Otherwise returns an Expr; WhenBuilder and ThenBuilder are not expressions and satisfy nothing that takes one. So a chain that forgets its else branch fails to compile rather than silently defaulting. Where a null default IS wanted, say so: Otherwise(ursus.Null(ursus.NullT)).
Both branches are evaluated ¶
This is a columnar engine, so Then and Otherwise are each computed for every row and then merged. That is unobservable — expressions have no side effects, and an arithmetic fault such as division by zero yields NULL rather than trapping — but it does mean a conditional does not make an expensive branch cheaper.
func (WhenBuilder) Then ¶
func (w WhenBuilder) Then[T Operand](v T) ThenBuilder
Then supplies the value for the pending condition. The value lifts, so Then("A") needs no Lit.
type WindowOption ¶ added in v0.5.0
WindowOption configures a rolling or an exponentially weighted function. An option a function has no use for, Ddof on an EwmMean, does nothing.
func Biased ¶ added in v0.5.0
func Biased() WindowOption
Biased makes EwmVar and EwmStd the biased estimate, Polars' bias=True. By default they are corrected for the weights, and a first value's is null.
func Ddof ¶ added in v0.5.0
func Ddof(d int) WindowOption
Ddof sets RollingVar's and RollingStd's delta degrees of freedom; the default, 1, is the sample variance, as Var(1) and Polars' rolling_var.
func EwmAdjust ¶ added in v0.5.0
func EwmAdjust(adjust bool) WindowOption
EwmAdjust chooses how the first rows are weighed; true is the default, as Polars' adjust=True. Adjusted, each row's answer divides by the weights its rows had; unadjusted, the newest row weighs alpha and the running answer 1-alpha.
func IgnoreNulls ¶ added in v0.5.0
func IgnoreNulls() WindowOption
IgnoreNulls weighs an ewm by position among the values, so a null does not age the rows before it. By default it does, as a row would: Polars' ignore_nulls=False.
func InterpolateNearest ¶ added in v0.6.0
func InterpolateNearest() WindowOption
InterpolateNearest makes Interpolate fill a run of nulls with the nearer of the two values around it, the later on a tie, as Polars' method="nearest" (step 172). The column's own type is kept, a number's or an instant's.
func MinSamples ¶ added in v0.5.0
func MinSamples(k int) WindowOption
MinSamples lets a rolling window answer from k values, where by default it needs as many as it has rows: RollingMean(7, MinSamples(1)) averages what the first six rows have. k is between 1 and the window's size.
func RollingCenter ¶ added in v0.6.0
func RollingCenter() WindowOption
RollingCenter labels a rolling window at its middle row rather than its last, as Polars' center=True (step 172): a window of size rows covers size/2 rows before the row and (size-1)/2 after it. A window that runs past either end of the rows holds fewer values, and is null unless MinSamples allows it.
func WindowClosed ¶ added in v0.6.0
func WindowClosed(c Closed) WindowOption
WindowClosed chooses which ends of a rolling window by time hold their instant: ClosedRight, the default, is (t − period, t], as Polars' closed="right"; ClosedLeft is [t − period, t), ClosedBoth both ends and ClosedNone neither. A rolling window of N rows has no use for it.
type WindowSpec ¶
type WindowSpec struct {
PartitionBy []Expr
OrderBy []SortKey
Mapping MappingStrategy
}
WindowSpec is the full form of a window, for OverWith.
type ZoneOption ¶ added in v0.6.0
type ZoneOption interface {
// contains filtered or unexported methods
}
ZoneOption is a policy for ReplaceTimeZone: an Ambiguous or a NonExistent.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package dtype is ursus's type system: DataType, Field and Schema.
|
Package dtype is ursus's type system: DataType, Field and Schema. |
|
Package i128 provides a signed 128-bit integer.
|
Package i128 provides a signed 128-bit integer. |
|
internal
|
|
|
arrowin
Package arrowin imports Arrow data into ursus, by copying it.
|
Package arrowin imports Arrow data into ursus, by copying it. |
|
arrowout
Package arrowout exports an ursus batch as Arrow, which is how anything outside this module reads ursus's output without a copy.
|
Package arrowout exports an ursus batch as Arrow, which is how anything outside this module reads ursus's output without a copy. |
|
arrowx
Package arrowx owns ursus's allocator and the few Arrow facts shared across packages.
|
Package arrowx owns ursus's allocator and the few Arrow facts shared across packages. |
|
bitmap
Package bitmap is ursus's validity-bitmap layer.
|
Package bitmap is ursus's validity-bitmap layer. |
|
data
Package data holds ursus's runtime column representation.
|
Package data holds ursus's runtime column representation. |
|
exec
Package exec drives a physical operator tree to completion.
|
Package exec drives a physical operator tree to completion. |
|
execopt
Package execopt carries the execution-time knobs that are not planning decisions: how much memory one query may hold, and where it is allowed to spill.
|
Package execopt carries the execution-time knobs that are not planning decisions: how much memory one query may hold, and where it is allowed to spill. |
|
expr
Package expr is ursus's expression IR.
|
Package expr is ursus's expression IR. |
|
gen/levels
command
Command levels enforces ursus's import-level invariant:
|
Command levels enforces ursus's import-level invariant: |
|
kernel
Package kernel is ursus's compute layer.
|
Package kernel is ursus's compute layer. |
|
memcheck
Package memcheck holds tests, and nothing else, that measure a query's live heap against what its memory budget counts.
|
Package memcheck holds tests, and nothing else, that measure a query's live heap against what its memory budget counts. |
|
physical
Package physical turns a logical plan into runnable operators, and expressions into columns.
|
Package physical turns a logical plan into runnable operators, and expressions into columns. |
|
plan
Package plan is ursus's logical plan IR.
|
Package plan is ursus's logical plan IR. |
|
source
Package source is the runtime half of ursus's scan contract.
|
Package source is the runtime half of ursus's scan contract. |
|
source/arrowsrc
Package arrowsrc scans a stream of Arrow records: an IPC stream, a Flight response, a C Data Interface stream — anything that is an array.RecordReader.
|
Package arrowsrc scans a stream of Arrow records: an IPC stream, a Flight response, a C Data Interface stream — anything that is an array.RecordReader. |
|
source/csv
Package csv reads and writes delimited text files.
|
Package csv reads and writes delimited text files. |
|
source/memsrc
Package memsrc is an in-memory scan source.
|
Package memsrc is an in-memory scan source. |
|
source/parquet
Package parquet reads and writes Apache Parquet files.
|
Package parquet reads and writes Apache Parquet files. |
|
source/testsrc
Package testsrc is a scan source that lies about its capabilities on purpose.
|
Package testsrc is a scan source that lies about its capabilities on purpose. |
|
spill
Package spill writes batches to a file and reads them back exactly.
|
Package spill writes batches to a file and reads them back exactly. |
|
strftime
Package strftime parses and formats instants with strftime-style formats, the ones Polars, chrono, Python and C share: "%Y-%m-%d %H:%M:%S".
|
Package strftime parses and formats instants with strftime-style formats, the ones Polars, chrono, Python and C share: "%Y-%m-%d %H:%M:%S". |
|
uerr
Package uerr is ursus's error type.
|
Package uerr is ursus's error type. |
|
Package ursustest provides assertions for testing code that uses ursus.
|
Package ursustest provides assertions for testing code that uses ursus. |