How to Compare Times in Go (Before, After, Equal)

Compare Go time values using the Before, After, and Equal methods on time.Time objects.

The instant, not the label

You are writing a background worker that processes jobs with deadlines. The worker pulls a job, checks the deadline, and decides whether to run it or discard it. You have the deadline as a time.Time and the current moment as another time.Time. You need to know if the deadline has passed.

Go gives you methods that read like natural language: Before, After, and Equal. They handle the comparison correctly, but they also hide a few traps involving timezones and the internal structure of the time.Time type. Using the equality operator == on times is a common mistake that breaks when timezones enter the picture. The methods exist to keep you safe.

How time comparison works

A time.Time value represents a single instant in time with nanosecond precision. Internally, the struct stores the number of nanoseconds since the Unix epoch and a pointer to a time.Location. The location is metadata for display and parsing. It does not change the instant.

When you call Before or After, Go compares the underlying instants. It ignores the location labels. If you have midnight UTC and 2 AM CEST, those are the same instant. Before and After treat them as equal. Equal does the same. It checks if two values represent the same moment, regardless of how they are labeled.

The == operator is different. It compares the struct fields directly, including the location pointer. If the locations differ, == returns false even if the instants match. This distinction is the source of most bugs in time comparison code.

Time is an instant. Location is a label. Compare instants, not labels.

Minimal comparison

Here is the basic comparison pattern: create two times and check their relationship using the methods.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Capture the current instant.
	now := time.Now()
	// Create a time one hour in the future.
	later := now.Add(time.Hour)

	// Before returns true if the receiver is chronologically earlier.
	if now.Before(later) {
		fmt.Println("Now is earlier than later.")
	}

	// After returns true if the receiver is chronologically later.
	if later.After(now) {
		fmt.Println("Later is indeed later.")
	}

	// Equal returns true if both times represent the same instant.
	if now.Equal(now) {
		fmt.Println("A time is equal to itself.")
	}
}

The methods return booleans. Before and After are inverses. t1.After(t2) is equivalent to t2.Before(t1). Equal checks for identity of the instant.

The monotonic clock advantage

Go's time.Now() captures a monotonic clock reading alongside the wall clock time. A monotonic clock is a hardware counter that only moves forward. It is immune to system time adjustments. If NTP syncs the clock and the wall time jumps backward, the monotonic reading keeps flowing smoothly.

When you compare two times that both have monotonic readings, Go uses the monotonic values for the comparison. This makes Before and After extremely reliable for measuring intervals. Even if the system clock jumps between two calls to time.Now(), the order remains correct.

Parsed times do not have monotonic readings. If you compare a time from time.Now() with a time parsed from a string, Go falls back to comparing the absolute wall clock times. The monotonic advantage disappears.

Calling .UTC() or .Local() on a time strips the monotonic reading. The result has a location but no monotonic clock. Use this carefully in performance-sensitive code where you rely on monotonic ordering.

Realistic token check

Here is a realistic check: validating a token expiry in a helper function.

// CheckTokenExpiry returns true if the token is still valid.
// It compares the token's expiry time against the current time.
func CheckTokenExpiry(expiry time.Time) bool {
	// Get the current instant with monotonic clock support.
	now := time.Now()

	// The token is valid if the expiry is after now.
	// This comparison uses the monotonic clock if both times have it.
	return expiry.After(now)
}

The function takes an expiry time and checks if it is in the future. After handles the comparison safely. If expiry was parsed from a string, it has no monotonic reading. The comparison still works, but it relies on the wall clock. The code remains correct regardless of the source of the time.

Go convention favors short receiver names. Methods on time.Time use (t Time) or (tm Time). The standard library uses t. Stick to that pattern in your own time-related code.

The equality trap

The equality operator == compares struct fields. It checks if the nanosecond counts match and if the location pointers are identical. This causes failures when times have different locations but represent the same instant.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Load a timezone location.
	loc, _ := time.LoadLocation("America/New_York")

	// Midnight UTC.
	t1 := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
	// 5 AM EST is the same instant as midnight UTC.
	t2 := time.Date(2023, 1, 1, 5, 0, 0, 0, loc)

	// Equal returns true because the instants match.
	fmt.Println(t1.Equal(t2)) // true

	// == returns false because the Location fields differ.
	fmt.Println(t1 == t2) // false
}

The compiler accepts t1 == t2 because time.Time supports equality. It does not warn you about the semantic mismatch. The code compiles, but the logic breaks when timezones vary.

Never use == on times unless you explicitly want to compare timezones too.

Zero values and IsZero

The zero value of time.Time is time.Time{}. It represents January 1, year 1, at 00:00:00 UTC. This is rarely a meaningful time in application code. It often indicates an uninitialized value or a missing timestamp.

Comparing a time to the zero value with Before or After works, but it is better to use IsZero(). The method checks if the time is the zero value. It is explicit and readable.

if deadline.IsZero() {
	// Handle missing deadline.
}

If you try to compare a time.Time with a different type, the compiler rejects the program. Passing an integer where a time is expected triggers invalid operation: t == 1 (mismatched types time.Time and untyped int). The type system catches these errors early.

Duration and Sub

Sometimes you need more than a boolean. You need to know how much time has passed. The Sub method returns a time.Duration. A duration is a signed integer representing nanoseconds.

elapsed := later.Sub(now)
fmt.Println(elapsed) // 1h0m0s

Sub calculates the difference. If the result is positive, the receiver is later. If negative, the receiver is earlier. You can compare durations to zero or to other durations. elapsed > 0 is equivalent to later.After(now).

Use Sub when you need the magnitude of the difference. Use Before and After when you only need the order. The methods express intent clearly.

Go convention accepts the verbosity of if err != nil. Time code is usually error-free, but time.Parse and time.LoadLocation return errors. Handle them. Do not discard errors with _ unless you are certain the input is valid and static. Discarding an error from LoadLocation can cause silent failures when the timezone database changes.

Decision matrix

Use Before when you need to check if one instant precedes another. Use After when you need to check if one instant follows another. Use Equal when you need to check if two times represent the same instant, regardless of timezone. Use == when you need to check if two time.Time values are identical, including their location metadata. Use Compare when you need a three-way comparison for sorting. Use Sub when you need the duration between two times. Use IsZero when you need to detect an uninitialized or missing time value. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Pick the method that matches your intent. The compiler won't save you from semantic errors.

Where to go next