1. Why compare time and calculate “how much has passed”
When you first see time.Time, it may seem like it’s “just another type you can print.” But almost any real program sooner or later runs into questions like: “did we make the deadline?”, “how long did the operation take?”, “when should we show the user a reminder?”, “is the record stale or still fresh?”. In those moments you don’t need a pretty date format—you need arithmetic and comparisons.
It’s important to understand that in Go, the standard library authors tried to design the API so you make fewer mistakes. So instead of forcing you to compare times manually with > and <, there are methods Before/After/Equal. Instead of manually writing time.Now().Sub(start), there is the more readable time.Since(start). This is not “syntactic sugar”—it’s a way to make code clearer and safer, both for you in a week and for a colleague who opens your file in a year (and quietly cries if everything is in milliseconds with no labels).
2. Add and Sub: “moment ± duration” and “moment − moment”
Let’s start with the most basic time arithmetic. In Go there is a very simple rule that is useful to keep in your head like a multiplication table:
- Time + Duration = Time (shift a moment forward/backward)
- Time - Time = Duration (get how much time is between them)
- Duration + Duration = Duration (add intervals)
You can’t directly add two time.Time values—and that’s good: fewer chances to accidentally write nonsense.
Add a duration: t.Add(d)
After each subheading I’ll briefly “lead you in” to the topic. Here the lead-in is simple: often you know “when it started” and you know “how long it should last”—and you want to get “when it will end.”
package main
import (
"fmt"
"time"
)
func main() {
start := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
end := start.Add(45 * time.Minute)
fmt.Println("start:", start) // start: 2026-01-16 10:00:00 +0000 UTC
fmt.Println("end: ", end) // end: 2026-01-16 10:45:00 +0000 UTC
}
Notice two things. First, Add does not modify the variable start; it returns a new time.Time. Second, 45 * time.Minute looks like a “human” duration—you don’t have to remember how many seconds there are.
Difference between moments: end.Sub(start)
Now the symmetric story: often we have a start and an end, and we want the duration. This is the “ideal” case for Sub.
package main
import (
"fmt"
"time"
)
func main() {
start := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 16, 10, 45, 0, 0, time.UTC)
elapsed := end.Sub(start)
fmt.Println("elapsed:", elapsed) // elapsed: 45m0s
}
If you ever see “manual” calculations in code like (endUnix - startUnix) / 60, know that there’s probably a bug hiding nearby, waiting for its time. Sub is not perfect for every scenario in the world (time has calendar nuances), but for “how much time is between two moments”—it’s exactly what you need.
3. time.Since(start) and time.Until(deadline)
The lead-in here is this: often we don’t even need the “second moment” as a variable. We want “how much has passed” from the start until “now,” or “how much is left” until a deadline from “now.” You can, of course, write time.Now().Sub(start) every time—but Go provides functions that read like English (and this is the rare case where English in code actually helps).
Measure “how long it took”: time.Since(start)
time.Since(start) is basically time.Now().Sub(start), but the intent is instantly readable: “since start.”
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
time.Sleep(30 * time.Millisecond)
fmt.Println("since:", time.Since(start)) // example: since: 30.1ms
}
An important practical point: it’s better to call time.Now() once, store it in start, and then work from it. If you call time.Now() twice “right next to each other,” you will still get different values, and sometimes that breaks logic in unexpected places (especially when comparisons are “on the edge”).
How much time is left until a deadline: time.Until(deadline)
If Since is “passed,” then Until is “left.”
package main
import (
"fmt"
"time"
)
func main() {
deadline := time.Now().Add(200 * time.Millisecond)
fmt.Println("left:", time.Until(deadline) > 0) // left: true (in a typical run)
}
time.Until(deadline) can be negative. And that’s not an error: it’s an honest answer meaning “the deadline has already passed.” So if you want to check “made it / didn’t make it,” you usually compare against zero (> 0 or <= 0), or you compare moments with Before/After (more on those next).
4. Time comparisons: Before, After, Equal
Time comparisons are the part where beginners most often try to “reinvent the wheel”: compare strings, compare “hours” separately, “minutes” separately, or convert everything to numbers and live like it’s the stone age. In reality, time.Time is a rich type, and it has methods that express intent better than any “manual” hacks.
Check ordering: t1.Before(t2) and t1.After(t2)
Imagine you’re writing a check “start is before end.” In a sane world, that should be one line, not a mini-algorithm in 20 lines with “if the year is equal.”
package main
import (
"fmt"
"time"
)
func main() {
t1 := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
t2 := t1.Add(1 * time.Hour)
fmt.Println(t1.Before(t2)) // true
fmt.Println(t2.After(t1)) // true
}
The code reads almost like English: “t1 before t2.” That’s the goal.
Equality: Equal and why == is not always what you want
time.Time has an Equal method, and it’s worth preferring when you compare moments as “the same point on the timeline.” Why not just t1 == t2? Because time.Time has nuances in its internal representation (including those related to the monotonic component), and comparing “as structs” can behave differently than comparing “as moments.”
While you’re a beginner, a good rule is: for comparing moments, use Before/After/Equal. This makes the intent explicit and makes you less dependent on internal details.
package main
import (
"fmt"
"time"
)
func main() {
t := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
fmt.Println(t.Equal(t)) // true
}
5. UTC vs Local: one moment, many representations
This is usually the point (sorry) when people start to suspect that time is a conspiracy. And they’re not entirely wrong.
The lead-in is this: in a program there is an “event”—for example, a meeting at 10:00 UTC. This is one and the same moment on the timeline. But if you are in a different zone, that moment will “look” like a different local time. That is, the moment does not change; only “how we display it” changes.
UTC(), Local() and In(loc) are about representation
The most common beginner mistake is thinking that t.UTC() “converts the time” and “shifts the event.” No. It changes only the location (representation) for the same moment.
Let’s look at an example with a fixed zone (it’s simpler and more predictable for learning).
package main
import (
"fmt"
"time"
)
func main() {
utc := time.Date(2026, 1, 16, 12, 0, 0, 0, time.UTC)
loc := time.FixedZone("UTC+3", 3*60*60)
inLoc := utc.In(loc)
fmt.Println("utc: ", utc) // utc: 2026-01-16 12:00:00 +0000 UTC
fmt.Println("loc: ", inLoc) // loc: 2026-01-16 15:00:00 +0300 UTC+3
fmt.Println("equal:", utc.Equal(inLoc)) // equal: true
}
The last line is the most important one. Equal says: yes, it’s the same moment. It’s just that in one zone it is “12:00,” and in another it is “15:00.” Like in real life: the same online meeting, but someone joins “at 9 a.m.” and someone else “at 6 p.m.”
Why programs like UTC
You can say this about UTC without any pathos: UTC is convenient as a “universal ruler.” If you store times in UTC, it’s easier to compare them, there are fewer surprises, and there is less dependence on the environment (server in one place, user in another, tests run in a third). And the local zone is more often used at the boundary—when you need to show a person “the way they’re used to.”
At the same time, you don’t have to “do everything in UTC all the time.” The main thing is to understand: the zone is about display, while comparisons and arithmetic operate on moments.
6. Mini time tracker: measurement and deadline
So the topic doesn’t stay abstract, let’s add a small feature to our learning app: measure a duration against a plan. Let the program do the following: “start now,” “plan is N minutes,” “after some time, check whether we’re overdue or not,” and print a clear output.
This is not a full product (the world is not ready yet), but it’s a good learning building block: it includes Add, Since, time comparison, and neat output.
Data model: Session
Let’s start with a simple struct. We already know how to work with structs, so we’ll use them instead of scattered variables.
package main
import "time"
type Session struct {
StartedAt time.Time
Planned time.Duration
}
StartedAt is a time.Time, Planned is a time.Duration. You can’t accidentally swap these fields: the types are different, and the compiler won’t allow it.
Session deadline: Deadline()
Now let’s add a method that returns the deadline moment. The rule “moment + duration = moment” is used directly here.
package main
import "time"
type Session struct {
StartedAt time.Time
Planned time.Duration
}
func (s Session) Deadline() time.Time {
return s.StartedAt.Add(s.Planned)
}
The method is small, but very important: it hides “how we calculate the deadline” in one place. If you later decide the plan should be rounded or breaks should be accounted for, you’ll change it here, not across the whole codebase.
Check “are we overdue”: IsOverdue(now)
Lead-in: comparisons almost always read better if you extract them into a small function/method with a descriptive name. Then the main code starts to look like text.
package main
import "time"
func (s Session) IsOverdue(now time.Time) bool {
return now.After(s.Deadline())
}
We intentionally accept now as a parameter instead of calling time.Now() inside. This makes the code more predictable and (in the future) easier to test: you can pass in any “current moment.”
Mini main: start, “work,” check
Now let’s glue everything together into a short program. Yes, we use time.Sleep simply to simulate “time has passed”—that’s fine for a demonstration.
package main
import (
"fmt"
"time"
)
type Session struct {
StartedAt time.Time
Planned time.Duration
}
func (s Session) Deadline() time.Time { return s.StartedAt.Add(s.Planned) }
func (s Session) IsOverdue(now time.Time) bool {
return now.After(s.Deadline())
}
func main() {
s := Session{StartedAt: time.Now(), Planned: 50 * time.Millisecond}
time.Sleep(80 * time.Millisecond)
fmt.Println("elapsed:", time.Since(s.StartedAt)) // example: elapsed: 80.2ms
fmt.Println("overdue:", s.IsOverdue(time.Now())) // overdue: true
}
You can see several things at once here. We use time.Since for “how much has passed,” Add is hidden inside Deadline, and the comparison is done via After, which reads naturally. This is already the “skeleton” of a time tracker, into which you can later add output formatting and parameter input.
A small diagram: how these operations relate
Sometimes it’s useful to “see” the relationships between types. Let’s lock this in with a simple diagram:
flowchart LR
A[time.Time: start] -- Add(time.Duration) --> B[time.Time: deadline]
C[time.Time: now] -- Sub(start) --> D[time.Duration: elapsed]
C -- After(deadline) --> E[bool: overdue?]
That’s usually how it works in applications: there is a start, there is a deadline (start + planned), there is “current time,” there is “elapsed” (now - start), and there is a boolean decision “overdue?”.
7. Common mistakes
Mistake #1: measuring duration as time.Now().Sub(time.Now()).
People sometimes write this “in one line,” but it’s a logic error: two calls to time.Now() produce two different moments, and the difference may be microscopic, noisy, or not reflect what you actually wanted to measure. The right approach is to store start := time.Now() once, do the work, and then use time.Since(start) or time.Now().Sub(start).
Mistake #2: mixing up “moment” and “duration” and trying to add/compare them arbitrarily.
If you feel the urge to “add two times,” stop and ask yourself: are you sure you want a moment and not a duration? In a correct model, you only “add” durations, and a moment can only be shifted by a duration: t.Add(d).
Mistake #3: comparing times as strings or manually comparing hours/minutes.
This is one of the most expensive mistakes in terms of consequences. Strings depend on format, locale, and zone, and manual comparisons easily break at a date change, month, year, etc. In Go, to compare moments you have Before/After/Equal, and you should use them by default because they express intent and protect you from “homegrown logic.”
Mistake #4: thinking that t.UTC() “shifts an event in time.”
UTC() and In(loc) change the display, not the moment. If you “convert” 12:00 UTC to UTC+3, you’ll see 15:00, but it’s the same moment. So check yourself with the question: “Do I want to change what a person sees, or do I really want to change the deadline/schedule?” Almost always, you want the former.
Mistake #5: forgetting that time.Until(deadline) can be negative.
If you write logic like “if less than a minute is left—warn,” remember that “left” can become “minus 3 hours.” That’s not a bug in Until; it’s an honest answer. The correct handling is to explicitly check the sign or compare moments (now.After(deadline)), so the code behaves predictably after the deadline too.
GO TO FULL VERSION