Tempo treats every value as a bounded interval on the time line, never as an instant (see When to use Tempo for why). The moment you bring a stdlib Date, Time, NaiveDateTime, or DateTime into Tempo, it stops being a point and becomes a span.
This guide answers the two questions that follow from that:
- At what resolution does a converted value land?
- What interval does a "point in time" actually become — and why does the width matter?
Setup — required for every example
Every example uses the ~o sigil from Tempo.Sigils. Bring it into scope first:
import Tempo.Sigils1. Stdlib → Tempo: the resolution you get
Tempo.from_elixir/2 is the unified gateway (there are also type-specific from_date/1, from_time/1, from_naive_date_time/1, from_date_time/1).
The rule is: resolution follows the type's declared precision, not the magnitude of its fields. Elixir's Time/NaiveDateTime/DateTime are second-granular by construction, so 09:00:00 is a fully specified second — not an under-specified hour. A zero component is still a specified component.
| Elixir value | Inferred resolution |
|---|---|
~D[2022-07-04] | :day (a Date has no time axis) |
~T[14:30:00] | :second |
~N[2022-07-04 14:30:00] | :second |
~U[2022-07-04 14:30:00Z] | :second |
~U[2022-07-04 14:30:00.000000Z] | :microsecond (sub-second precision present) |
Tempo.from_elixir(~U[2022-07-04 14:30:00Z]) |> Tempo.resolution()
#=> {:second, 1}To deliberately widen to a coarser span, pass :resolution:
# Treat a midnight value as the whole day, not the first second of it.
Tempo.from_elixir(~N[2022-07-04 00:00:00], resolution: :day)
#=> ~o"2022Y7M4D""Read a calendar value at the precision it was written; widen to a whole day only when you ask for it."
2. A point in time is an interval
Every Tempo value materialises to an explicit half-open [from, to) span via Tempo.to_interval/1. The width is one unit at the value's resolution — so the resolution from §1 decides the span:
| Converted from | Interval | Width |
|---|---|---|
~D[2022-07-04] (day) | [2022-07-04, 2022-07-05) | 1 day |
~U[…14:30:45Z] (second) | […14:30:45, …14:30:46) | 1 second |
~U[…14:30:45.123456Z] (µs) | […45.123456, …45.123457) | 1 microsecond |
{:ok, interval} = Tempo.from_elixir(~U[2022-07-04 14:30:45Z]) |> Tempo.to_interval()
Tempo.Interval.duration(interval)
#=> ~o"PT1S"{:ok, day} = Tempo.from_elixir(~D[2022-07-04]) |> Tempo.to_interval()
Tempo.Interval.duration(day)
#=> ~o"PT86400S" # one whole dayThe upper bound is exclusive ([from, to)), which is what makes the spans tile the time line cleanly — [2022-07-04, 2022-07-05) followed by [2022-07-05, 2022-07-06) is exactly [2022-07-04, 2022-07-06) with no gap or overlap. See Enumeration semantics for how this drives iteration, and Tempo.to_interval/2 for the full materialisation contract.
"A day is the span from this midnight to the next; a timestamp is the span from this second to the next."
Two notes:
Sub-second spans and
duration/1.duration/1reports whole seconds, so a one-microsecond interval reads as~o"PT0S". The span is still one microsecond wide — inspect the endpoints (from/1,to/1) when you need sub-second extent.A bare
Timematerialises non-anchored.~T[14:30:00]becomesT14H30M0S/T14H30M1S— a one-second span on the time-of-day axis with no date. Operations that need an absolute position (duration in wall-clock seconds, cross-zone comparison) require anchoring it to a date first.
3. Why the width matters
This is the whole point of the interval model, and the place stdlib intuition trips up. Because each "instant" is really a half-open one-unit span, two timestamps one second apart do not overlap — they meet at the shared boundary.
earlier = ~o"2022Y7M4DT14H30M45S" # the span [45s, 46s)
later = ~o"2022Y7M4DT14H30M46S" # the span [46s, 47s)
Tempo.relation(earlier, later)
#=> :meets
Tempo.overlaps?(earlier, later)
#=> falseIdentical timestamps are :equals; consecutive ones :meets; only a genuine span covering shared time :overlaps. That precision is exactly what lets free/busy scans, coalescing, and set operations be unambiguous — there is never a "do these touch?" grey area.
"9:30:45 and 9:30:46 don't clash — they're back-to-back."
And because a plain DateTime/NaiveDateTime now materialises (it infers to second resolution, and a second is a one-second span), converted timestamps drop straight into the set-algebra API:
{:ok, busy} = Tempo.union(~o"2022Y7M4DT14H30M45S", ~o"2022Y7M4DT14H30M46S")
#=> two adjacent one-second spans, ready for difference/intersection/…4. Tempo → stdlib: projecting back out
Going the other way, you choose how much of the interval/zone information to keep:
| Function | Result | Zone handling |
|---|---|---|
to_date/1 | Date | dropped (wall-clock date) |
to_time/1 | Time | dropped (wall-clock time-of-day) |
to_naive_date_time/1 | NaiveDateTime | dropped (wall-clock reading) |
to_date_time/1 | DateTime | preserved (lossless inverse) |
to_naive_date_time/1 keeps the wall-clock numbers and discards the offset — exactly like the stdlib DateTime.to_naive/1. It does not shift to UTC:
# Paris is UTC+2 in summer; the wall reading is 10:30, not 08:30.
paris = Tempo.from_elixir(DateTime.new!(~D[2022-06-15], ~T[10:30:00], "Europe/Paris"))
Tempo.to_naive_date_time(paris)
#=> {:ok, ~N[2022-06-15 10:30:00.000000]}When the zone matters, to_date_time/1 is the lossless inverse of from_elixir/2 on a DateTime — it preserves the named zone and re-derives the offset from the time-zone database:
Tempo.to_date_time(paris)
#=> {:ok, #DateTime<2022-06-15 10:30:00.000000+02:00 CEST Europe/Paris>}If you want UTC wall time rather than the local reading, normalise explicitly first:
{:ok, utc} = Tempo.shift_zone(paris, "Etc/UTC")
Tempo.to_naive_date_time(utc)
#=> {:ok, ~N[2022-06-15 08:30:00.000000]}Two caveats:
Resolution must reach the target. A value coarser than a full datetime (e.g.
~o"2022-11") cannot fill aNaiveDateTime/DateTimeand returns{:error, _}. Project to the type the value's resolution supports (to_date/1for a day,to_time/1for a time-of-day).Microsecond precision normalises to 6. A second-resolution value round-trips through
{0, 0}→{0, 6}microseconds; the instant, zone, and wall reading are identical, only the precision tag widens.
5. Where to convert
Convert at the edges, compute in the middle:
- Edge layer (HTTP, DB, log parsing): stdlib types. Parse Unix timestamps and ISO strings into
Date/DateTime. - Domain layer (scheduling, availability, recurrence, set operations): Tempo. Convert in with
from_elixir/2when the next operation is interval-shaped. - Display layer:
Tempo.to_string/2for locale-aware output, or theto_*projections above to hand a stdlib value back to non-Tempo code.
See When to use Tempo for the full decision tree.
Cheat sheet
# Stdlib → Tempo (resolution = the type's precision; override with :resolution)
Tempo.from_date(~D[2026-06-15]) # :day
Tempo.from_time(~T[14:30:00]) # :second (time-of-day, non-anchored)
Tempo.from_naive_date_time(~N[2026-06-15 14:30:00])
Tempo.from_date_time(~U[2026-06-15 14:30:00Z]) # :second, zoned
Tempo.from_elixir(value, resolution: :day) # unified gateway + explicit widen
# Point → interval (every value is a span of one unit at its resolution)
Tempo.to_interval(tempo) # {:ok, %Interval{}} half-open [from, to)
# Tempo → Stdlib
Tempo.to_date(tempo) # zone dropped
Tempo.to_time(tempo) # zone dropped
Tempo.to_naive_date_time(tempo) # zone dropped (wall-clock, not UTC)
Tempo.to_date_time(tempo) # zone preserved (lossless)
Tempo.shift_zone(tempo, "Etc/UTC") # normalise to UTC first if needed