Skip to content

Dates and times

Type Holds
Date a calendar day
Datetime(unit, zone) an instant, to the second, ms, us or ns, in an IANA zone or none
Duration(unit) a length of time
TimeOf(unit) a time of day

A Go time.Time is a Datetime(ns, "UTC") in Values, and a literal anywhere an expression goes. Methods for these types sit behind Dt().

Parsing

Str().Strptime(type, format, strict) parses text with a strftime format; not strict, what does not parse is null:

logs := ursus.Frame(
    ursus.Values("at", []string{"2026-03-08 06:30", "2026-03-08 07:45", "oops"}),
)
utc := ursus.Datetime(ursus.Micro, "UTC")
show(logs.Select(
    ursus.Col("at"),
    ursus.Col("at").Str().Strptime(utc, "%Y-%m-%d %H:%M", false).Alias("instant"),
))
Output
shape: (3, 2)
┌────────────────────┬─────────────────────────────┐
│ at                 │ instant                     │
│ String             │ Datetime(us, UTC)           │
├────────────────────┼─────────────────────────────┤
│ "2026-03-08 06:30" │ 2026-03-08T06:30:00.000000Z │
│ "2026-03-08 07:45" │ 2026-03-08T07:45:00.000000Z │
│ "oops"             │ null                        │
└────────────────────┴─────────────────────────────┘

A CSV column is read as a date when the scan is told so: WithSchemaOverrides(map[string]ursus.DataType{"date": ursus.Date}), as the guide's sales does.

The parts of a date

The examples here read three days:

func days() *ursus.LazyFrame {
    return ursus.Frame(
        ursus.Values("day", []string{"2024-02-29", "2026-01-03", "2026-12-31"}),
    ).Select(ursus.Col("day").Str().ToDate())
}
d := ursus.Col("day").Dt()
show(days().Select(
    ursus.Col("day"),
    d.Year().Alias("year"),
    d.Month().Alias("month"),
    d.Weekday().Alias("weekday"),
    d.Week().Alias("week"),
    d.Quarter().Alias("quarter"),
    d.OrdinalDay().Alias("day_of_year"),
    d.IsLeapYear().Alias("leap"),
))
Output
shape: (3, 8)
┌────────────┬───────┬───────┬─────────┬───────┬─────────┬─────────────┬───────┐
│ day        │ year  │ month │ weekday │ week  │ quarter │ day_of_year │ leap  │
│ Date       │ Int32 │ Int32 │ Int32   │ Int32 │ Int32   │ Int32       │ Bool  │
├────────────┼───────┼───────┼─────────┼───────┼─────────┼─────────────┼───────┤
│ 2024-02-29 │ 2024  │ 2     │ 4       │ 9     │ 1       │ 60          │ true  │
│ 2026-01-03 │ 2026  │ 1     │ 6       │ 1     │ 1       │ 3           │ false │
│ 2026-12-31 │ 2026  │ 12    │ 4       │ 53    │ 4       │ 365         │ false │
└────────────┴───────┴───────┴─────────┴───────┴─────────┴─────────────┴───────┘

Weekday is ISO's, Monday 1 to Sunday 7, and Week the ISO week: 2026's last day is in its week 53. Datetimes add Hour, Minute, Second and the fractions, Date and Time.

Arithmetic

A date minus a date is a Duration; TotalDays and its kin turn it into a number. OffsetBy moves by a calendar interval, so a month after January 31 is February 28, not March 3:

invoices := ursus.Frame(
    ursus.Values("issued", []string{"2026-01-31", "2026-02-10"}),
    ursus.Values("paid", []string{"2026-02-14", "2026-02-11"}),
).Select(ursus.Col("issued").Str().ToDate(), ursus.Col("paid").Str().ToDate())
issued := ursus.Col("issued").Dt()
show(invoices.Select(
    ursus.Col("issued"),
    ursus.Col("paid").Sub(ursus.Col("issued")).Dt().TotalDays().
        Alias("days_to_pay"),
    issued.OffsetBy(ursus.Every("1mo")).Alias("plus_a_month"),
    issued.MonthEnd().Alias("month_end"),
    issued.AddBusinessDays(5, ursus.RollForward).Alias("plus_5_business"),
))
Output
shape: (2, 5)
┌────────────┬─────────────┬──────────────┬────────────┬─────────────────┐
│ issued     │ days_to_pay │ plus_a_month │ month_end  │ plus_5_business │
│ Date       │ Int64       │ Date         │ Date       │ Date            │
├────────────┼─────────────┼──────────────┼────────────┼─────────────────┤
│ 2026-01-31 │ 14          │ 2026-02-28   │ 2026-01-31 │ 2026-02-09      │
│ 2026-02-10 │ 1           │ 2026-03-10   │ 2026-02-28 │ 2026-02-17      │
└────────────┴─────────────┴──────────────┴────────────┴─────────────────┘

MonthStart and MonthEnd move to a month's ends. AddBusinessDays(n, roll) counts business days, Monday to Friday unless a BusinessCalendar gives other weekdays and its holidays; roll says what a start on a day off does: RollForward, RollBackward, or RollRaise, the default, to refuse it. IsBusinessDay tests one.

Truncating and formatting

Truncate(every) moves each value to the start of its interval: a week starts on Monday, as in Polars. Round moves it to the nearest. Strftime(format) formats it:

date := ursus.Col("date").Dt()
show(sales().Select(
    ursus.Col("date"),
    date.Truncate(ursus.Every("1w")).Alias("week"),
    date.Truncate(ursus.Every("1mo")).Alias("month"),
    date.Strftime("%a %d %b").Alias("label"),
).Unique())
Output
shape: (5, 4)
┌────────────┬────────────┬────────────┬──────────────┐
│ date       │ week       │ month      │ label        │
│ Date       │ Date       │ Date       │ String       │
├────────────┼────────────┼────────────┼──────────────┤
│ 2026-01-03 │ 2025-12-29 │ 2026-01-01 │ "Sat 03 Jan" │
│ 2026-01-04 │ 2025-12-29 │ 2026-01-01 │ "Sun 04 Jan" │
│ 2026-01-05 │ 2026-01-05 │ 2026-01-01 │ "Mon 05 Jan" │
│ 2026-01-06 │ 2026-01-05 │ 2026-01-01 │ "Tue 06 Jan" │
│ 2026-01-07 │ 2026-01-05 │ 2026-01-01 │ "Wed 07 Jan" │
└────────────┴────────────┴────────────┴──────────────┘

Time zones

A Datetime with a zone is an instant shown on that zone's clock. ConvertTimeZone shows the same instant on another clock; ReplaceTimeZone keeps the clock and changes the instant. New York's clocks moved forward an hour at 07:00 UTC on March 8, 2026, and the second row is on the other side of it:

at := []time.Time{
    time.Date(2026, 3, 8, 6, 30, 0, 0, time.UTC),
    time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC),
}
instants := ursus.Frame(ursus.Values("utc", at)).
    Select(ursus.Col("utc").Cast(ursus.Datetime(ursus.Second, "UTC")))
show(instants.Select(
    ursus.Col("utc"),
    ursus.Col("utc").Dt().ConvertTimeZone("America/New_York").Alias("new_york"),
    ursus.Col("utc").Dt().ReplaceTimeZone("Asia/Tokyo").Alias("same_wall_in_tokyo"),
))
Output
shape: (2, 3)
┌──────────────────────┬───────────────────────────────┬───────────────────────────┐
│ utc                  │ new_york                      │ same_wall_in_tokyo        │
│ Datetime(s, UTC)     │ Datetime(s, America/New_York) │ Datetime(s, Asia/Tokyo)   │
├──────────────────────┼───────────────────────────────┼───────────────────────────┤
│ 2026-03-08T06:30:00Z │ 2026-03-08T01:30:00-05:00     │ 2026-03-08T06:30:00+09:00 │
│ 2026-03-08T07:30:00Z │ 2026-03-08T03:30:00-04:00     │ 2026-03-08T07:30:00+09:00 │
└──────────────────────┴───────────────────────────────┴───────────────────────────┘

ursus reads zones from the system's zone database, as Go's time package does, where Polars bundles its own. They agree but for a zone whose daylight time the system's database writes in the rearguard form, as Ubuntu's does for Dublin.

Ranges

DateRange(start, end, every) makes a date for each step, ends included, and DatetimeRange and TimeRange the same for instants and times of day. DateRanges makes a list a row from columns:

from := ursus.Lit(time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC)).Cast(ursus.Date)
to := ursus.Lit(time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)).Cast(ursus.Date)
span := ursus.DateRange(from, to, ursus.Every("1d")).Alias("day")
show(ursus.Frame().Select(span))
Output
shape: (4, 1)
┌────────────┐
│ day        │
│ Date       │
├────────────┤
│ 2026-01-30 │
│ 2026-01-31 │
│ 2026-02-01 │
│ 2026-02-02 │
└────────────┘

Upsampling

New in 0.6.7. Upsample(time, every, groupBy...) puts a row at every step from each group's first instant to its last, as Polars' upsample. A row the frame has at a step is kept; where it has none, the new row is null but for the time and the group's keys, which a fill forward completes: the weekend below. A row that falls between two steps is left out, as Polars leaves it.

show(prices().
    Filter(ursus.Col("ticker").Eq("ACME")).
    Upsample("date", ursus.Every("1d"), "ticker").
    WithColumns(ursus.Col("close").FillNull(ursus.FillForward).Alias("filled")))
Output
shape: (8, 4)
┌────────────┬────────┬─────────┬─────────┐
│ date       │ ticker │ close   │ filled  │
│ Date       │ String │ Float64 │ Float64 │
├────────────┼────────┼─────────┼─────────┤
│ 2026-01-05 │ "ACME" │ 10      │ 10      │
│ 2026-01-06 │ "ACME" │ 11      │ 11      │
│ 2026-01-07 │ "ACME" │ 10.5    │ 10.5    │
│ 2026-01-08 │ "ACME" │ 12      │ 12      │
│ 2026-01-09 │ "ACME" │ 12      │ 12      │
│ 2026-01-10 │ "ACME" │ null    │ 12      │
│ 2026-01-11 │ "ACME" │ null    │ 12      │
│ 2026-01-12 │ "ACME" │ 13.5    │ 13.5    │
└────────────┴────────┴─────────┴─────────┘

It is a LazyFrame's too. The groups keep the order each first appears, and their rows the grid's, with or without a memory limit, so a fill after it reads the rows it should. Unlike Polars', the frame need not be sorted.

Dynamic groups

GroupByDynamic(index, options) groups rows into windows of time cut from a sorted index: per day, per week, per hour. Every is how far apart windows start, and Period how long each is, Every when not given, so the windows tile the time.

show(prices().
    Filter(ursus.Col("ticker").Eq("ACME")).
    GroupByDynamic(ursus.Col("date"),
        ursus.DynamicOptions{Every: ursus.Every("1d")}).
    Agg(ursus.Len().Alias("rows"), ursus.Col("close").Mean().Alias("close")))
Output
shape: (8, 3)
┌────────────┬────────┬─────────┐
│ date       │ rows   │ close   │
│ Date       │ Uint64 │ Float64 │
├────────────┼────────┼─────────┤
│ 2026-01-05 │ 1      │ 10      │
│ 2026-01-06 │ 1      │ 11      │
│ 2026-01-07 │ 1      │ 10.5    │
│ 2026-01-08 │ 1      │ 12      │
│ 2026-01-09 │ 1      │ 12      │
│ 2026-01-10 │ 0      │ null    │
│ 2026-01-11 │ 0      │ null    │
│ 2026-01-12 │ 1      │ 13.5    │
└────────────┴────────┴─────────┘

Empty windows are rows, which Polars leaves out

ursus makes every window of the grid from the first instant to the last, so a day with no rows is a row with a count of 0: the weekend above. Polars' group_by_dynamic leaves those windows out. Filter rows > 0 for Polars' answer.

GroupBy cuts windows within each key; the index column comes first, then the keys, where Polars puts the keys first:

show(sales().
    GroupByDynamic(ursus.Col("date"), ursus.DynamicOptions{
        Every:   ursus.Every("1w"),
        GroupBy: []ursus.Expr{ursus.Col("region")},
    }).
    Agg(ursus.Col("qty").Sum().Alias("qty")).
    Sort(ursus.Asc(ursus.Col("region")), ursus.Asc(ursus.Col("date"))))
Output
shape: (5, 3)
┌────────────┬─────────┬────────┐
│ date       │ region  │ qty    │
│ Date       │ String  │ Int128 │
├────────────┼─────────┼────────┤
│ 2026-01-05 │ "east"  │ 14     │
│ 2025-12-29 │ "north" │ 17     │
│ 2026-01-05 │ "north" │ 3      │
│ 2025-12-29 │ "south" │ 4      │
│ 2026-01-05 │ "south" │ 15     │
└────────────┴─────────┴────────┘

Offset shifts the grid, Closed says which end of a window holds its instant, Label what the index column shows, StartBy where the grid begins, and IncludeBoundaries adds each window's bounds.

Rolling groups

Rolling(index, options) gives every row its own window, reaching back Period from its instant, and aggregates it: the rows and the high of the three days up to each close.

show(prices().
    Filter(ursus.Col("ticker").Eq("ACME")).
    Rolling(ursus.Col("date"), ursus.RollingOptions{Period: ursus.Every("3d")}).
    Agg(ursus.Len().Alias("rows"), ursus.Col("close").Max().Alias("high_3d")))
Output
shape: (6, 3)
┌────────────┬────────┬─────────┐
│ date       │ rows   │ high_3d │
│ Date       │ Uint64 │ Float64 │
├────────────┼────────┼─────────┤
│ 2026-01-05 │ 1      │ 10      │
│ 2026-01-06 │ 2      │ 11      │
│ 2026-01-07 │ 3      │ 11      │
│ 2026-01-08 │ 3      │ 12      │
│ 2026-01-09 │ 3      │ 12      │
│ 2026-01-12 │ 1      │ 13.5    │
└────────────┴────────┴─────────┘

A rolling group is a group-by with one row for each input row. The rolling windows by time in Window functions answer the same question as a column beside the rows.

Offset places each window as Polars' offset does: from the row's instant plus the offset, a period long, (t + Offset, t + Offset + Period]. Unset, it is minus the period, the window up to the row; NoOffset() is a window wholly after it, Polars' offset="0d". Changed in 0.6.7: Offset moved the window's end before.

An integer index

New in 0.6.7. An index can be a count rather than an instant: a step, a sequence number. An Int32 or Int64 index counts by Polars' integer unit, so Every("5i") is five of it, and its windows are cut from its values, as from time's:

readings := ursus.Frame(
    ursus.Values("step", []int64{1, 2, 4, 7, 8, 12}),
    ursus.Values("load", []float64{3, 5, 2, 6, 4, 7}),
)
fives := ursus.DynamicOptions{Every: ursus.Every("5i")}
show(readings.GroupByDynamic(ursus.Col("step"), fives).
    Agg(ursus.Len().Alias("rows"), ursus.Col("load").Sum()))
Output
shape: (3, 3)
┌───────┬────────┬─────────┐
│ step  │ rows   │ load    │
│ Int64 │ Uint64 │ Float64 │
├───────┼────────┼─────────┤
│ 0     │ 3      │ 10      │
│ 5     │ 2      │ 10      │
│ 10    │ 1      │ 7       │
└───────┴────────┴─────────┘

Dynamic and rolling groups take one, as do Upsample and the rolling windows and ewms by a column, and the index keeps its type. All but GroupByDynamic take a UInt32 or a UInt64 too, as Polars' do. An interval of time on an integer index is refused, and so is "i" where a date is moved: in Truncate, Round, OffsetBy, a range or an as-of tolerance, where Polars moves nothing, panics, or reads it as nanoseconds.

Under a memory limit

Dynamic and rolling groups stream their input and hand out each window when it closes, keyed ones too, so a long series runs under a memory budget.