Comparing times and dates in Go

Avatar of the author Willem Schots
18 Jan, 2024
~7 min.
RSS

It’s tempting to think of time (and dates by extension) as an ever increasing number. While you’re developing in Go you might even try to compare two time.Time values using the comparison operators.

You will then find out that comparisons using >, <, <= or >= won’t compile:

invalid operation: t > u (operator > not defined on struct)

Comparisons using == and != will compile, but you should be careful, it probably doesn’t do what you intended to do!

So what is going on? time.Time values don’t seem to be like integers or floats.

What is time.Time

By checking the docs, we can see that the time.Time type is a struct:

type Time struct {
	// contains filtered or unexported fields
}

Structs are allowed to be compared using == and !=, but not with other operators. Which explains why comparison operators like > and <= don’t compile.

Why is time.Time a struct?

Time on computers is more complex than you might initially expect:

An integer or float would not be enough to store all the necessary data. This is why time.Time is a struct.

It contains:

  • The location reference. A pointer to a time.Location.
  • Two numbers encoding the wall element for telling time and the monotonic element for measuring time.

The monotonic element is only set when a time.Time is created via time.Now(), or when such a value is provided to t.Add(d). The system on which this happens also needs to have monotonic clock (most do).

Are you or your team struggling with aspects of Go?

Book a 30 minute check-in with me.

I can help with questions like:

  • How should we structure our Go web app?
  • Is this package a good choice for us?
  • How do I get my team up to speed quickly?
  • Can I pick your brain on {subject}?
Learn more

Looking forward to meeting you.

- Willem

Comparing time instants

What you are usually interested in, is not comparing the time value itself, but the time instant (moment in time) it represents. Different clocks can represent the same time instant with different readings.

In the time.Time struct there are potentially two kinds of representations:

  • The wall element used together with a time.Location reference.
  • The monotonic element.

When comparing time.Time values they need to be handled differently:

  • Wall elements can represent the same time instant but be in different locations.
  • Monotonic elements can only be compared with other monotonic elements, but not every time.Time value has one.

That’s why all the comparison methods use the following logic:

If both time.Time values have a monotonic element, use that for the comparison. Otherwise, use the wall element.

When using the wall element, location is always kept into account as you will see in the following examples.

Equal

The t.Equal(u) method checks if the time instants represented by t and u are the same.

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	// first compare using wall element
	eastOfUTC := time.FixedZone("UTC+1", 60*60)
	t := time.Date(2024, 1, 18, 13, 37, 0, 0, time.UTC)
	u := time.Date(2024, 1, 18, 14, 37, 0, 0, eastOfUTC)
	fmt.Printf("t.Equal(u): %v\n", t.Equal(u))

	// then compare using monotonic element.
	m := time.Now()
	k := m // copy m
	fmt.Printf("m.Equal(k): %v\n", m.Equal(k))
}

In the example above tand u have different wall clock elements of 13:37 and 14:37. They are still equal because they represent the same time instant.

t and u are equal
t and u represent the same time instant on different timelines and are equal.

Likewise, m and k are equal, but they are compared using their monotonic elements:

m and k are equal
m and k represent the same time instant on the monotonic timeline and are equal.

Before and After

The t.Before(u) method checks if the time instant represented by t is before the time instant represented by u.

Similarly, the t.After(u) method checks if the time instant represented by t comes after the time instant represented by u.

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	// first compare using wall element
	eastOfUTC := time.FixedZone("UTC+1", 60*60)
	t := time.Date(2024, 1, 18, 13, 30, 0, 0, time.UTC)
	u := time.Date(2024, 1, 18, 13, 45, 0, 0, eastOfUTC)
	fmt.Printf("t.After(u): %v\n", t.After(u))
	fmt.Printf("u.Before(t): %v\n", u.Before(t))

	// then compare using monotonic element.
	m := time.Now()
	time.Sleep(2 * time.Nanosecond)
	k := time.Now()
	fmt.Printf("m.Before(k): %v\n", m.Before(k))
	fmt.Printf("k.After(m): %v\n", k.After(m))
}

As you can see, this does not check the wall element (13:30 vs 13:45) of u and t. It actually checks if represented time instant comes first.

u comes before t
u's time instant comes before t's time instant because they use different timelines.

m and k are again using the monotonic elements for this comparison.

m comes before t
m comes before k on the monotonic timeline.

Compare

The t.Compare(u) method combines the Equal, Before and After methods. It returns:

  • -1: if t is before u.
  • 0: if t is the same as u.
  • +1: if t is after u.
main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	// first compare using wall element
	eastOfUTC := time.FixedZone("UTC+1", 60*60)
	t := time.Date(2024, 1, 18, 13, 30, 0, 0, time.UTC)
	u := time.Date(2024, 1, 18, 13, 45, 0, 0, eastOfUTC)
	fmt.Printf("t.Compare(t): %v\n", t.Compare(t))
	fmt.Printf("t.Compare(u): %v\n", t.Compare(u))
	fmt.Printf("u.Compare(t): %v\n", u.Compare(t))

	// then compare using monotonic element.
	m := time.Now()
	time.Sleep(2 * time.Nanosecond)
	k := time.Now()
	fmt.Printf("m.Compare(m): %v\n", m.Compare(m))
	fmt.Printf("m.Compare(k): %v\n", m.Compare(k))
	fmt.Printf("k.Compare(m): %v\n", k.Compare(m))
}

IsZero

The t.IsZero() method returns true when the time instant represented by t is the same one that is represented by "January 1, year 1, 00:00:00 UTC".

IsZero does not check that the fields of the struct are all zero.

The IsZero method never uses the monotonic element.

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	// first compare using wall element
	eastOfUTC := time.FixedZone("UTC+1", 60*60)
	t := time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)
	u := time.Date(1, 1, 1, 1, 0, 0, 0, eastOfUTC)
	fmt.Printf("t.IsZero(): %v\n", t.IsZero())
	fmt.Printf("u.IsZero(): %v\n", u.IsZero())
}
t and u are zero
t and u both represent "January 1, year 1, 00:00:00 UTC" on different timelines.

Comparison methods

We just saw how to compare time.Time values using the available comparison methods.

Because time.Time is a struct, it’s also possible to check for (non) equality using the == and != operators.

The only time (that I can think of), where you might consider this is when using the time.Time type as a map key.

How to make == and != work

Just to be clear, it’s safer and easier to use the t.Equal(u) method.

There are linters that can warn you when you're using == instead of Equal this.

The == and != operators will compare time.Time on a field-by-field basis. Due to the way the data is encoded into the struct, comparisons will only give valid results under certain conditions.

For values to be equal using ==:

  • Both values need values need to hold the same time.Location pointer.
  • Both values need to have the same wall element.
  • Either both are missing a monotonic element, or both have the exact same monotonic element.

The most straightforward way to achieve this is by:

  • Ensuring they are in the same location using t.UTC(), t.Local() or t.In(loc *time.Location).
  • Ensuring the monotonic elements are stripped using t.Round(0).

For an example, see the next section.

time.Time as map key

When times are “normalized” this way, we can compare them using == and use them as map keys:

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
  m := make(map[time.Time]string, 0)

  now := time.Now()
  m[now] = "stays the same"

  normNow := now.UTC().Round(0)
  m[normNow] = "is overwritten"

  hardcodedNow := time.Date(2009, 11, 10, 23, 0, 0, 0, time.UTC)
  m[hardcodedNow] = "a new value"

  fmt.Println(m)
}

The map in the above example will contain two elements. One with the value "stays the same" and one with "a new value".

The now time is not normalized, and will not equal any of the later times because it contains a monotonic element.

In the playground environment the time will always begin at "2009-11-10 23:00:00 UTC" which makes normNow equal the hardcodedNow.

Summary

In this article we discussed how to compare time.Time in different ways.

The key points are:

  • time.Time contains two time representations: wall element and monotonic element.
  • The monotonic element is used when all compared values have it.
  • Otherwise, the comparison methods fall back to using wall time.
  • When comparing using wall time, the location is taken into account.
  • Under specific conditions you can compare time.Time values using == or !=.

I hope you learned something. Feel free to reach out if you have comments or questions.

Happy coding!

🎓

Subscribe to my Newsletter and Keep Learning.

Gain access to more content and get notified of the latest articles:

I send emails every 1-2 weeks and will keep your data safe. You can unsubscribe at any time.

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Twitter/X, LinkedIn or Mastodon.

Thanks for reading!