Measuring time gaps in Go
You are building a CLI tool that measures how long a database query takes. You capture a timestamp before the call, run the query, capture another timestamp after, and need the gap. In Python you subtract datetime objects and get a timedelta. In JavaScript you subtract milliseconds. Go takes a different path. It gives you a time.Duration, which looks like a string when you print it but behaves like a number when you do math.
How time.Duration actually works
Think of time.Time as a point on a timeline. The .Sub() method measures the distance between two points. The result is not a float or a standard integer. It is a time.Duration, which is just an int64 counting nanoseconds since a zero point. Go wraps that raw number with built-in formatting and conversion methods. You never have to manually divide by 1_000_000_000 to get seconds. The type system keeps you from accidentally mixing milliseconds with hours.
The time package stores two clocks inside every time.Time value. The wall clock shows the human readable date and time. The monotonic clock counts nanoseconds since an arbitrary starting point and never jumps backward. When you call .Sub(), Go prefers the monotonic component. System clock adjustments, NTP syncs, and daylight saving shifts do not affect the result. The duration reflects actual elapsed time.
Duration values are immutable. You cannot mutate a time.Duration in place. You create new ones through arithmetic. Adding two durations returns a new duration. Multiplying a duration by an integer returns a new duration. The compiler rejects the program with invalid operation: d += 5 (mismatched types time.Duration and untyped int) if you try to use += with a plain number. You must multiply the number by a unit first, like 5 * time.Second. This design prevents silent unit confusion.
The minimal example
Here is the simplest way to measure a gap. Spawn a start time, wait, capture the end time, and subtract.
package main
import (
"fmt"
"time"
)
func main() {
// Capture the current instant. Includes wall clock and monotonic clock.
start := time.Now()
// Simulate work. The monotonic clock ticks forward regardless of system time changes.
time.Sleep(2 * time.Second)
// Capture the second instant.
end := time.Now()
// Subtract start from end. Returns a time.Duration (int64 nanoseconds).
gap := end.Sub(start)
// .String() formats the duration intelligently. Prints something like 2.000123456s.
fmt.Println(gap)
}
The compiler rejects the program with cannot use start (variable of struct type time.Time) as time.Duration value in assignment if you try to assign the result directly to an int or float64. You must use the .Duration type or call a conversion method like .Seconds(). The type system forces you to acknowledge the unit of measurement.
Real-world latency tracking
Measuring a two second sleep is academic. Real code measures HTTP handlers, database calls, or background jobs. Here is how you track request latency and convert it to a metric friendly format.
package main
import (
"fmt"
"time"
)
// MeasureHandler logs how long a simulated request takes.
func MeasureHandler() {
// Record the moment the request arrives.
start := time.Now()
// Simulate processing. In production this would be your business logic.
processWork()
// Calculate elapsed time using the monotonic clock component.
latency := time.Since(start)
// Convert to milliseconds for metrics systems that expect integers.
ms := latency.Milliseconds()
// Print both the formatted duration and the raw millisecond count.
fmt.Printf("request took %s (%d ms)\n", latency, ms)
}
func processWork() {
time.Sleep(150 * time.Millisecond)
}
Notice the use of time.Since(start). It is syntactic sugar for time.Now().Sub(start). The standard library includes it because elapsed time measurement is so common. The convention is to pass time.Since() when you want a quick elapsed time from a past moment to right now. It saves two characters and reads naturally.
Where things go wrong
Time arithmetic trips up developers who expect JavaScript style millisecond integers or Python style timezone aware objects. Go handles time differently, and the differences cause silent bugs.
Order matters. end.Sub(start) returns a positive duration. start.Sub(end) returns a negative duration. The compiler does not complain. You get a value like -2.000123456s. If you pass that negative value to a logging library or a metrics exporter, it might drop the data or crash. Always verify the sign before exporting.
Monotonic clocks disappear during serialization. If you marshal a time.Time to JSON, the monotonic component is stripped. The resulting value only contains the wall clock. If you unmarshal it later and call .Sub() against another time, you get wall clock arithmetic. Wall clock arithmetic breaks when the system clock jumps. The compiler warns with monotonic clock not available in some edge cases, but usually it just silently falls back to wall time. Store durations as numbers if you need them to survive serialization. Store time.Time values only for scheduling or display.
Manual conversion loses precision. Calling .Seconds() returns a float64. Floats cannot represent every nanosecond exactly. If you convert back to nanoseconds, you might lose a few ticks. The compiler complains with cannot use duration.Seconds() (float64) as int64 value in assignment if you try to force it. Use .Milliseconds() or .Microseconds() for integer metrics. Keep the raw time.Duration for internal logic.
Duration formatting follows strict rules. Go prints the smallest unit that keeps the value readable. 1.5s prints as 1.5s. 1500ms prints as 1.5s. 100ns prints as 100ns. If you need a fixed format for a UI, call .String() and parse it, or use fmt.Sprintf("%.2fs", d.Seconds()). Do not rely on the default formatter for strict alignment.
Timezone independence is a feature, not a bug. .Sub() ignores the location attached to each time.Time. A time in Tokyo subtracted from a time in New York returns the correct elapsed nanoseconds. The location metadata is only used for display and wall clock calculations. If you need to compare wall clocks across timezones, convert both to UTC first using .UTC(). Otherwise, subtraction handles the offset automatically.
Picking the right tool
Go provides several ways to measure time. Each one solves a specific problem. Pick the right one to keep your code readable and correct.
Use .Sub() when you have two explicit time.Time values and need the exact gap between them. Use time.Since() when you want a quick elapsed time from a past moment to right now. Use time.Until() when you are waiting for a future deadline or scheduled event. Use time.Now().UnixMilli() when you need to store timestamps in a database that only supports integers. Use plain time.Duration arithmetic when you are adding timeouts, retry delays, or backoff intervals.
Where to go next
- How to Get the Day of the Week in Go
- How to Use Labels with break and continue in Go
- Complete Guide to the encoding/hex and encoding/base64 Packages
Trust the monotonic clock. Export durations, not timestamps. Measure what matters.