Skip to content

Dates & Times

Three stdlib files cover dates and times, each handling a different layer:

  • std/calendar — civil types: Date, Time, NaiveDateTime, OffsetDateTime, DateTime. What humans read off a calendar or clock face.
  • std/instantInstant, the machine timestamp. What hardware records.
  • std/durationDuration, an exact span of time (5 seconds, 30 minutes).

The bare/attractive name in std/calendar is DateTime: when you say “10:30 AM on June 15 in New York,” you mean a real moment in history, and a DateTime is what models it (IANA-zoned, DST-aware). NaiveDateTime carries the “Naive” prefix as a footgun marker — it’s a wall-clock reading without a zone, useful for templates and unzoned data, not for “the meeting happens at.”

Each civil type has a typed-literal form. DateTime uses RFC 9557’s bracket suffix to name the IANA zone:

import {
  std/calendar: Date, DateTime, Error
  std/io: IO
}

fn main(): Result<Unit, Error> {
  d = try Date"2026-06-15"
  IO.print(d)

  dt = try DateTime"2026-06-15T14:30:00-04:00[America/New_York]"
  IO.print(dt)

  IO.print(DateTime.year(dt))
  IO.print(DateTime.month(dt))
  IO.print(DateTime.hour(dt))
  Ok(Unit)
}

Date shifts by civil calendar periods with + and -. Days and weeks move by whole dates; months and years clamp when the target month has fewer days:

import {
  std/calendar: Civil, Date, Error
  std/io: IO
}

fn main(): Result<Unit, Error> {
  start = try Date"2026-05-04"
  jan31 = try Date"2026-01-31"
  leap = try Date"2024-02-29"

  IO.print(start + Civil.Days(10))
  IO.print(start + Civil.Weeks(2))
  IO.print(start - Civil.Weeks(1))
  IO.print(jan31 + Civil.Months(1))
  IO.print(jan31 - Civil.Months(1))
  IO.print(leap + Civil.Years(1))
  Ok(Unit)
}

An Instant is a machine timestamp — what Instant.now() returns, what Timer.sleep operates on. A Duration is an exact span of nanoseconds, constructed with unit functions (Duration.seconds(5), Duration.minutes(30)) and combined with +, -, *, and /:

import {
  std/duration: Duration
  std/instant: Instant
  std/io: IO
}

fn main() {
  start = Instant.from_seconds(1_700_000_000)
  five_minutes = Duration.minutes(5)

  IO.print(Duration.as_minutes(five_minutes))

  later = start + five_minutes
  IO.print(Instant.to_seconds(later))

  // Instant.between returns the Duration between two Instants
  gap = Instant.between(start, later)
  IO.print(Duration.as_seconds(gap))
}

Instant.now() and Timer.sleep aren’t shown here because their output isn’t deterministic — they read the real wall clock and pause for real time. See the Concurrency chapter for Timer.sleep in practice.

A DateTime holds a moment plus a display zone. Equality is instant-only — same moment in two different zones compares equal, even though their local readings differ:

import {
  std/calendar: DateTime, Error
  std/io: IO
}

fn main(): Result<Unit, Error> {
  ny = try DateTime"2026-06-15T12:00:00-04:00[America/New_York]"
  paris = try DateTime"2026-06-15T18:00:00+02:00[Europe/Paris]"

  IO.print(ny == paris)
  IO.print(ny)
  IO.print(paris)
  Ok(Unit)
}

The 2026 spring-forward in America/New_York lands the small hours of March 8th. Adding one calendar day to noon March 7 lands at noon March 8 — the civil reading you’d want — even though the elapsed wall-clock time between the two moments is 23 hours, not 24:

import {
  std/calendar: Civil, DateTime, Error
  std/io: IO
}

fn main(): Result<Unit, Error> {
  start = try DateTime"2026-03-07T12:00:00-05:00[America/New_York]"
  next = start + Civil.Days(1)

  IO.print(start)
  IO.print(next)
  Ok(Unit)
}

+ Civil.Days(1) does civil arithmetic — the local clock reads the same value the next day, and the UTC offset shifts (-05:00-04:00) to account for the DST transition. For exact-elapsed-time arithmetic (+24 real hours, which lands at 1pm here), add a Duration instead.

std/calendar defines a ladder from less specific to more specific:

  • Date — calendar day
  • Time — time of day
  • NaiveDateTime — wall reading, no zone (not a real moment)
  • OffsetDateTime — anchored at a fixed UTC offset
  • DateTime — anchored in an IANA zone (DST-aware)

DateParts and TimeParts give uniform accessors across every type with those fields:

import {
  std/calendar: DateTime, Error
  std/io: IO
}

fn main(): Result<Unit, Error> {
  dt = try DateTime"2026-06-15T14:30:00-04:00[America/New_York]"
  DateTime.year(dt) |> IO.print()

  DateTime.month(dt) |> IO.print()

  DateTime.day(dt) |> IO.print()

  DateTime.hour(dt) |> IO.print()

  DateTime.minute(dt) |> IO.print()

  Ok(Unit)
}

with_zone(dt, zone) keeps the underlying instant and re-renders the wall reading through a different IANA zone:

import {
  std/calendar: DateTime, Error
  std/io: IO
}

fn main(): Result<Unit, Error> {
  ny = try DateTime"2026-06-15T12:00:00-04:00[America/New_York]"
  tokyo = try DateTime.with_zone(ny, "Asia/Tokyo")
  IO.print(tokyo)
  IO.print(ny == tokyo)
  Ok(Unit)
}

Civil arithmetic (+ Civil.Hours(24), - Civil.Hours(24)) adjusts the wall reading and re-resolves through the zone. Physical arithmetic (+ Duration, - Duration) advances the instant by exact elapsed time. Around DST, they diverge:

import {
  std/calendar: Civil, DateTime, Error
  std/duration: Duration
  std/io: IO
}

fn main(): Result<Unit, Error> {
  // Saturday March 7 at noon in New York. DST springs forward overnight.
  start = try DateTime"2026-03-07T12:00:00-05:00[America/New_York]"

  // Civil: "+24 wall-clock hours" — stays at noon EDT. Only 23h physically.
  civil = start + Civil.Hours(24)
  IO.print(civil)

  // Physical: "+24 hours of real time" — lands at 1pm EDT.
  physical = start + Duration.hours(24)
  IO.print(physical)
  Ok(Unit)
}

Both are correct; the split lets the caller spell which intent applies.

Projecting a wall reading into an IANA zone can hit a gap (the wall reading never exists — clocks jumped over it) or a fold (the wall reading exists twice — clocks rolled back). The Disambiguation enum controls how in_zone resolves them:

import {
  std/calendar: DateTime, Disambiguation, Error, NaiveDateTime
  std/instant: Instant
  std/io: IO
}

fn main(): Result<Unit, Error> {
  // 2026-11-01 01:30 NY happens twice (2am EDT rolls back to 1am EST).
  naive = try NaiveDateTime"2026-11-01T01:30:00"

  earlier = try DateTime.in_zone(
    naive,
    "America/New_York",
    Disambiguation.Earlier,
  )
  later = try DateTime.in_zone(naive, "America/New_York", Disambiguation.Later)

  // Same wall reading, different offsets, different instants.
  IO.print(earlier)
  IO.print(later)

  // One hour apart in the underlying instant.
  earlier_sec = DateTime.to_instant(earlier) |> Instant.to_seconds()

  later_sec = DateTime.to_instant(later) |> Instant.to_seconds()

  IO.print(later_sec - earlier_sec)

  // Instant-only equality: they are NOT equal.
  IO.print(earlier == later)
  Ok(Unit)
}

The default mode is Disambiguation.Compatible (total resolution matching naive intuition — earlier in folds, advances forward through gaps). Disambiguation.Reject is the strict mode for when the wall reading came from user input and the right answer is to bounce the ambiguity back via Err.

DateTime.now_in(zone) reads the system clock and projects it into the given zone in one call (sugar for Instant.now() |> from_instant_in(zone)):

import {
  std/calendar: Error
  std/io: IO
}

fn main(): Result<Unit, Error> {
  now = try DateTime.now_in("America/New_York")
  IO.print(now) // example: 2026-05-26T17:42:00-04:00[America/New_York]
  Ok(Unit)
}

This snippet is static-only because the actual output varies with the wall clock.