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"),
))
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"),
))
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"),
))
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())
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"),
))
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))
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")))
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")))
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"))))
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")))
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()))
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.