How to Use time.Since and time.Until in Go

Use time.Since to calculate elapsed time from a past moment and time.Until to calculate remaining time until a future deadline.

Measuring time without the clock jumps

You just finished a function that fetches data from a remote API. You want to log how long it took. You grab time.Now(), do the work, grab time.Now() again, and subtract. It works. Then you try to calculate how much time is left before a deadline expires. You do the math backward. It works. Go gives you shortcuts for both. time.Since and time.Until exist to save you from writing the subtraction logic every time and to handle a subtle trap with system clocks.

These functions are not just convenience wrappers. They carry a safety mechanism that protects your measurements from system clock adjustments, daylight saving time shifts, and NTP corrections. Using them correctly keeps your latency logs accurate and your deadline checks reliable.

The concept: elapsed versus remaining

time.Since(t) calculates how much time has passed since a past moment t. It is equivalent to time.Now().Sub(t). time.Until(t) calculates how much time remains until a future moment t. It is equivalent to t.Sub(time.Now()). Both return a time.Duration.

A duration is an int64 representing nanoseconds. Go provides methods like .Seconds(), .Milliseconds(), and .String() to make durations readable. The signature of both functions is identical in terms of return type, but the direction of the subtraction flips.

The real value lies in how Go implements the subtraction. Go's time.Time type can hold two pieces of information: a wall clock time (the date and time displayed on your calendar) and a monotonic clock reading (a hardware counter that only moves forward). time.Now() returns both. time.Since and time.Until prefer the monotonic clock when it is available. This preference prevents bugs caused by wall clock jumps.

Monotonic clocks save you from NTP jumps. Trust the counter, not the calendar.

Minimal example

Here's the basic pattern for measuring elapsed time and remaining time.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Capture the start moment. time.Now() includes a monotonic clock reading.
	start := time.Now()

	// Simulate work that takes some time.
	time.Sleep(100 * time.Millisecond)

	// time.Since calculates elapsed time using the monotonic clock if available.
	elapsed := time.Since(start)
	fmt.Printf("Elapsed: %s\n", elapsed)

	// Set a deadline 500ms in the future.
	deadline := time.Now().Add(500 * time.Millisecond)

	// time.Until calculates remaining time until the deadline.
	remaining := time.Until(deadline)
	fmt.Printf("Remaining: %s\n", remaining)
}

What happens under the hood

When you call time.Now(), Go queries the operating system. On most modern systems, this returns a time.Time struct that holds the wall clock time and a monotonic timestamp. The monotonic timestamp is a raw counter from the hardware. It starts at zero when the system boots and increases continuously. It never goes backward.

When you call time.Since(start), Go checks if start has a monotonic reading. If it does, Go takes the current monotonic reading, subtracts the stored reading, and returns the difference. It ignores the wall clock entirely for the calculation. This guarantees the result reflects actual time passed, even if the system administrator changed the date halfway through.

If start lacks a monotonic reading, Go falls back to wall clock arithmetic. This fallback is where bugs hide. Parsed times, times constructed from Unix timestamps, and times read from external sources usually lack monotonic readings. Using time.Since on those values reverts to wall clock math, which can produce negative durations or wildly incorrect results if the clock adjusts.

The compiler does not warn you about this. time.Since accepts any time.Time. If you pass a wall-clock-only time, the code compiles and runs. The error appears later as a negative duration in your logs or a deadline check that fails unexpectedly.

Realistic usage: HTTP handlers and deadlines

In a production service, you measure request latency and respect client deadlines. time.Since logs how long a request took. time.Until checks if you have enough time left to do the work.

Here's a handler that logs latency and validates a context deadline.

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

// handleRequest logs latency and checks context deadlines.
func handleRequest(w http.ResponseWriter, r *http.Request) {
	// Capture start time for latency logging.
	start := time.Now()

	// Check if the client sent a deadline via context.
	if deadline, ok := r.Context().Deadline(); ok {
		// Calculate remaining time to avoid working past the deadline.
		remaining := time.Until(deadline)
		if remaining < 0 {
			// Deadline already passed. Bail immediately.
			http.Error(w, "too late", http.StatusRequestTimeout)
			return
		}
		fmt.Printf("Deadline in %s\n", remaining)
	}

	// Simulate processing.
	time.Sleep(50 * time.Millisecond)

	// Log how long the handler took.
	elapsed := time.Since(start)
	fmt.Printf("Handled in %s\n", elapsed)

	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/", handleRequest)
	http.ListenAndServe(":8080", nil)
}

The handler captures start immediately. If the context has a deadline, time.Until(deadline) computes the remaining time. The check remaining < 0 handles the case where the deadline already expired before the request arrived. This pattern is common in gRPC and HTTP services that propagate deadlines.

time.Since appears at the end to log the total duration. Because start came from time.Now(), it has a monotonic reading. The logged latency is accurate even if NTP adjusted the system clock during the request.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and silent bugs

The biggest risk with time.Since and time.Until is using times that lack monotonic clocks. When you parse a time from a string, the result has no monotonic reading.

t, err := time.Parse(time.RFC3339, "2023-10-01T12:00:00Z")

The variable t holds only wall clock data. If you pass t to time.Since, Go uses wall clock subtraction. If the system clock jumps backward by one hour due to a timezone change or NTP correction, time.Since(t) can return a negative duration. Your code might interpret a negative duration as "time hasn't started yet" or crash if you assume durations are always positive.

The compiler rejects type mismatches but not logic errors. If you try to pass a string directly to time.Since, you get cannot use "2023-10-01" (untyped string constant) as time.Time value in argument. That error is helpful. The error of passing a parsed time is silent.

Another pitfall is time.Until returning negative values. If the target time is in the past, time.Until returns a negative duration. Code that sleeps based on this value will panic.

time.Sleep(time.Until(deadline))

If deadline is in the past, time.Sleep receives a negative duration and panics with negative sleep interval. Always check the sign before sleeping.

d := time.Until(deadline)
if d > 0 {
	time.Sleep(d)
}

Duration arithmetic can also trip you up. time.Duration is an integer of nanoseconds. Converting to seconds requires care. d.Seconds() returns a float64. Casting to int truncates.

seconds := int(elapsed.Seconds())

If elapsed is 1.9 seconds, seconds becomes 1. The compiler allows this conversion. The logic loses precision. Use int64(elapsed.Seconds()) if you need the full range, or keep the duration as a duration until the final output.

Parsed times are wall clocks. They drift. Don't measure latency with parsed times.

Convention asides

Go has strong conventions around time handling. Follow them to match the rest of the ecosystem.

Always capture time.Now() at the point of interest. Do not create a global time variable. Do not pass a time.Time through multiple layers just to measure time at the end. Capture the start, do the work, measure the end.

Log durations using %s or .String(). This prints human-readable output like 1.5s or 2m30s. Do not log raw nanoseconds. Debugging a log line with 1500000000 is painful.

When checking deadlines, use time.Until and compare against zero. The pattern if time.Until(deadline) < 0 is idiomatic. It clearly expresses "is the deadline past?".

Receiver names in methods should be short. If you wrap time logic in a struct, use (t *Timer) not (this *Timer). Go style favors brevity in receivers.

Testing with time

Testing code that uses time.Since can be tricky. You cannot easily mock time.Now(). If your function calls time.Now() internally, tests run at unpredictable speeds and depend on wall clock time.

The standard approach is to inject the time source. Define an interface like TimeSource with a Now() time.Time method. Pass the interface to your function. In production, use a real implementation. In tests, use a mock that returns fixed times.

time.Since does not solve mocking. It only ensures that when you do measure time, the measurement is robust. If you capture start and elapsed as variables, you can assert on elapsed in tests without depending on the wall clock.

start := source.Now()
// ... work ...
elapsed := time.Since(start)
assert.True(t, elapsed > 0)

This pattern works even with mocked time, as long as the mock advances time between calls.

Decision matrix

Use time.Since(t) when you need to measure elapsed time since a past moment captured by time.Now().

Use time.Until(t) when you need to calculate remaining time until a future deadline or expiration.

Use t.Sub(u) when you need the difference between two arbitrary times, regardless of which is earlier.

Use time.Now().Sub(t) only if you are writing code that predates Go 1.0; time.Since is the modern equivalent.

Use time.Parse followed by wall clock arithmetic when you are comparing dates from external sources like JSON payloads, but never use parsed times for high-precision latency measurement.

Use a monotonic clock reading directly when you are building a custom timer library and need raw hardware counters.

time.Since for past. time.Until for future. Sub for arbitrary math.

Where to go next