skip to content

Why doesn't Truncate(24 * time.Hour) on a time.Time give you local midnight?

level: middleimportance: nice to knowfreq 32%

answer

  1. it does not look at the calendar
  2. elapsed arithmetic, not presentation
  3. measured from Go's zero time
  4. multiples counted since year 1 UTC
  5. use time.Date for a day boundary

basics

~20 s

time.Time.Truncate rounds an instant down to a multiple of the duration measured from Go's zero time, January 1 of year 1 UTC, never from the calendar presentation. For a day boundary, rebuild the instant with time.Date.

solid answer

~50 s

`Truncate` treats the instant as an absolute elapsed duration since Go's zero time — midnight, January 1, year 1, UTC — and rounds that number down to a multiple of the argument. It does not operate on the presentation form, so it has no notion of "the day this timestamp is displayed in". Truncating by `24 * time.Hour` therefore lands on a UTC day boundary, which coincides with local midnight only where the offset from UTC is zero. Sub-day units behave more intuitively — `Truncate(time.Hour)` zeroes the minutes, seconds and nanoseconds for any whole-hour offset — which is exactly why the day case surprises people. `Round` is the same computation except it rounds to the *nearest* multiple, with halves rounding away from zero. When you want a calendar boundary, take the instant's date parts and construct the time you want with `time.Date`, which is calendar arithmetic rather than duration arithmetic.

code

go · 9 lines
go
t := time.Date(2025, time.March, 3, 13, 47, 30, 0, time.UTC)

fmt.Println(t.Truncate(time.Hour)) // 2025-03-03 13:00:00 +0000 UTC
fmt.Println(t.Round(time.Hour))    // 2025-03-03 14:00:00 +0000 UTC

// The start of t's own calendar day, in t's own location:
y, m, d := t.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, t.Location())
fmt.Println(start) // 2025-03-03 00:00:00 +0000 UTC

go deeper

for a junior

Know that Truncate rounds an instant down to a multiple of a duration and is the usual way to strip sub-second noise or bucket a timestamp by minute.

for a middle

Explain that the multiple is counted from Go's zero time as absolute elapsed time, never from the calendar presentation, and that this is why sub-hour units feel natural and day units do not.

for a senior

Show that you reach for time.Date with an explicit location when a boundary is a calendar concept, and that you validate a configured bucket size is positive, since a zero duration makes Truncate a silent no-op.

for a principal

Set the house rule that bucketing and windowing use duration truncation while anything a human would call a day, week or month is built with explicit calendar construction, so reports and metrics do not drift apart.

## Two methods, one computation `time.Time` has a pair of rounding methods: func (t Time) Truncate(d Duration) Time func (t Time) Round(d Duration) Time Both take a `time.Duration` — a plain int64 of nanoseconds — and both work the same way underneath. Conceptually, take the instant `t` as a single number: the elapsed nanoseconds since Go's **zero time**, which is midnight on January 1 of year 1, UTC. Divide by `d`. `Truncate` throws away the remainder (rounds *down*, toward the zero time). `Round` rounds to the nearest multiple, with a half rounding away from zero. Then convert back to a `Time`. If `d` is zero or negative, both methods return the instant unchanged rather than dividing by zero. The crucial sentence, and the one the standard library documentation itself leads with, is that these operate on the time **as an absolute duration since the zero time** — not on its presentation form. The year, month, day, hour and minute you would see if you formatted the value play no part in the arithmetic. ## Why sub-day units feel natural For durations that divide an hour, or for whole hours, the result matches intuition: - `Truncate(time.Second)` clears the nanoseconds. - `Truncate(time.Minute)` clears seconds and nanoseconds. - `Truncate(time.Hour)` clears minutes, seconds and nanoseconds. That works because the zero time is itself aligned to a whole hour, minute and second, so "a multiple of an hour since year 1" is also "a whole hour on the clock". Almost every real use of these methods is in this range: bucketing metrics into ten-second or one-minute windows, stripping sub-second noise before comparing or storing a timestamp, or aligning a poll to the top of the minute. ## Why the day case breaks Extend the same rule to `24 * time.Hour` and the alignment you get is a multiple of 24 hours from the zero time, which is a **UTC** midnight. Format the result in a location whose offset from UTC is not zero and it will not read as midnight — it reads as whatever local clock time corresponds to that UTC instant. And even in a place where every local midnight *is* a UTC-aligned instant, the method has still answered a duration question, not a calendar one, which is why anything more complicated — the start of a month, the start of a week — has no `Truncate` formulation at all. The honest way to say it: `Truncate` is *elapsed* arithmetic. "The start of the day", "the start of the month", "the same time next week" are *calendar* arithmetic. Go keeps those in different APIs on purpose, and mixing them up is the whole bug. ## What to do instead for a calendar boundary Pull the date parts out and construct the instant you actually mean: y, m, d := t.Date() startOfDay := time.Date(y, m, d, 0, 0, 0, 0, t.Location()) `time.Date` builds a `Time` from calendar components in a given location, which is precisely the presentation-form operation `Truncate` refuses to do. The same shape gives you the first of the month (`time.Date(y, m, 1, 0, 0, 0, 0, loc)`) or the start of the year. Note that this construction depends on which location you pass, so the choice of location is a decision the caller must make explicitly — which is a feature, since a "start of day" that silently used the server's location is a bug waiting for a deployment to a differently-configured host. ## Round, and the half case `Round` differs from `Truncate` only in the rounding rule: nearest multiple, halves away from zero. For an instant at 13:47:30 UTC, `Round(time.Hour)` gives 14:00:00 while `Truncate(time.Hour)` gives 13:00:00; at exactly 13:30:00, `Round(time.Hour)` goes up to 14:00. Because "away from zero" is defined relative to the zero time, instants before year 1 round the other way, which is a curiosity rather than something you will meet. `time.Duration` has its own `Truncate` and `Round` methods with the same semantics applied to a duration rather than an instant — `d.Round(time.Millisecond)` is a tidy way to keep a measured latency readable in a log without pretending to nanosecond precision. ## Choosing between them Use `Truncate` when you are assigning an instant to a bucket and every instant in the bucket must map to the bucket's start: metrics windows, rate-limit windows, cache keys by minute. Use `Round` when you are presenting a number to a human and want the closest value. Use neither when the boundary you want is named on a calendar — reach for `time.Date` then, and say which location you mean.

  • Then why does `Truncate(time.Minute)` behave exactly as people expect?
    Because the zero time is itself aligned to a whole minute, so a multiple of a minute since year 1 is also a whole minute on the clock. Every unit that divides an hour inherits that alignment, which is why second, minute and hour truncation feel like field-clearing. The illusion only breaks once the unit is larger than the alignment the zero time gives you.
  • How does `Round` differ from `Truncate` on an instant sitting exactly halfway?
    `Truncate` always goes down toward the zero time; `Round` goes to the nearest multiple and breaks a tie away from zero, so an instant at exactly 13:30:00 rounds up to 14:00 under `Round(time.Hour)`. For bucketing you want `Truncate`, because it guarantees every instant in a window maps to that window's start.
  • What does `Truncate` do if you pass it a zero or negative duration?
    It returns the instant unchanged rather than attempting a division. That is a safe default, but it also means a duration that came from configuration and parsed to zero will silently truncate nothing. If a bucket size is required, validate that it is positive when you read it rather than discovering the no-op later in a metrics dashboard.

saying these in an interview costs you the question

  • Thinks Truncate clears calendar fields like month and day
  • Believes Truncate(24 * time.Hour) means start of the local day
  • Assumes rounding is relative to the Unix epoch
  • Cannot say how Round breaks a tie
  • Reaches for Truncate to find the first of the month