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.A pure time-of-day group materialises non-anchored too. A grouped time value with no date — for example the first quarter-hour
[hour: 16, minute: {:group, 1..15}]— becomes the relative span[16:01, 16:16)on the time-of-day axis. The same anchoring caveat applies. A group whose upper bound would carry off the end of the day (23:45.., with no date to carry into) or any group with a date component (5G10DU) needs anchoring first and returns aTempo.MaterialisationErroruntil then.
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