1. Two meanings of the word “time”: instant and duration
When a programmer says “time”, they often mean two different things at once. The first is a specific instant: “now”, “2026-01-16 10:30”. The second is a duration: “wait 2 seconds”, “the operation took 150 milliseconds”.
If you mix these meanings into a single type, bugs become very… creative. Go solves the problem up front: an instant in time is stored in time.Time, and a duration is stored in time.Duration.
Imagine time.Time as a point on a ruler, and time.Duration as a segment. You can shift a point by a segment and get a new point. But “adding two points” is nonsense, so the compiler won’t let you do something silly (or at least will make you try very hard).
A small table to lock in the meaning:
| What we want to express | Type in Go | Real-world example | Code example |
|---|---|---|---|
| Instant (a point in time) | |
“now”, “at 12:00” | |
| Duration (interval) | |
“2 seconds”, “15 minutes” | |
2. time.Time: an instant in time
If you previously worked with dates as strings (“2026-01-16”), then you already know how convenient it is… until you need to compare dates, add a day, or deal with time zones. The time.Time type is a proper, structured representation of an instant in time.
You can compare it, format it, subtract another instant from it to get a duration, and generally live without endless manual string parsing. A string is good for output or interchange, but it’s a poor “source of truth” inside a program.
The most common way to get a time.Time is to call time.Now(). That is the “current instant” according to the system clock. It’s convenient for measuring durations, logging “when it started”, storing “when it was created”, and so on.
Mini example: print time.Time and look at the type
package main
import (
"fmt"
"time"
)
func main() {
now := time.Now()
fmt.Printf("now=%v\n", now)
fmt.Printf("type=%T\n", now) // type=time.Time
}
Note: fmt can print time.Time in a human-readable way, but that’s still not a “controlled format”. For now it’s more important to note: now is not a string, it’s a separate type.
Mini example: create an instant manually
Sometimes it’s useful to create a fixed instant. For example, in tests or demos. For that there is time.Date. To avoid diving into time zones and locales, we’ll use time.UTC.
package main
import (
"fmt"
"time"
)
func main() {
t := time.Date(2026, 1, 16, 10, 30, 0, 0, time.UTC)
fmt.Println(t) // 2026-01-16 10:30:00 +0000 UTC
}
Zero value of time.Time: how Go expresses “time not set”
Many types in Go have a meaningful default value. For int it’s 0, for string it’s "", for bool it’s false. time.Time also has a zero value — a “zero instant in time”.
In real life it almost never means an “actual date”; it means “value not set”. To check whether time is set, people usually use t.IsZero().
package main
import (
"fmt"
"time"
)
func main() {
var t time.Time
fmt.Println(t.IsZero()) // true
fmt.Println(t) // 0001-01-01 00:00:00 +0000 UTC
}
Important: “year 0001” may look like “some date”, but in most business scenarios it’s exactly a “blank” marker. And that’s very convenient for data models.
Example: a task with DoneAt and a check via IsZero()
Let’s start carefully evolving a hypothetical task-tracker app (for now without files, HTTP, or a database — data only). The logic is simple: if DoneAt is zero, the task is not completed.
package main
import (
"fmt"
"time"
)
type Task struct {
Title string
DoneAt time.Time
}
func main() {
t := Task{Title: "read about time.Time"}
fmt.Println(t.DoneAt.IsZero()) // true
}
3. time.Duration: duration, units, and a beginner trap
time.Duration is a duration: 10 milliseconds, 2 seconds, 3 minutes, 1 hour. Internally it’s a number of nanoseconds, but you should almost never think in “nanoseconds”.
Instead, Go provides unit constants: time.Millisecond, time.Second, time.Minute, time.Hour. The correct style almost always looks like “number * unit”.
Mini example: a correct duration (and printing)
package main
import (
"fmt"
"time"
)
func main() {
d := 150 * time.Millisecond
fmt.Println(d) // 150ms
fmt.Printf("%T\n", d) // time.Duration
}
The most classic beginner bug: trying to write “5 seconds” as time.Duration(5). That’s not 5 seconds — that’s 5 nanoseconds. In other words: you blink — and it’s already gone.
Mini example: the time.Duration(5) trap
package main
import (
"fmt"
"time"
)
func main() {
a := time.Duration(5)
b := 5 * time.Second
fmt.Println(a) // 5ns
fmt.Println(b) // 5s
}
This trap is worth remembering as an “attention check”: if you see time.Duration(number), it’s almost certainly a bug (or a very rare case where the author really wanted nanoseconds and is willing to own it).
4. How time.Time and time.Duration combine into “proper math”
We need to lock in the basic math of meanings: you can shift an instant by a duration, and the difference between two instants is a duration. This is the core logic everything else grows from.
Shifting an instant is done via Add. The difference between instants is via Sub. And this is important: Sub returns time.Duration, not a number. Go forces you not to forget the unit of measure.
Mini example: Add and Sub
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
end := start.Add(2 * time.Second)
elapsed := end.Sub(start)
fmt.Println(elapsed) // 2s
}
If you try to add two time.Time values (for example, start + end), the compiler will stop you. This is a useful “protection against a creative approach to math”.
A small meaning diagram
time.Time — point: ●
time.Duration — segment: ─────
t.Add(d) — shift: ● + ───── = ●
t2.Sub(t1) — diff: ● - ● = ─────
5. Monotonic time: why it matters for measurements
It seems like measuring a duration is simple: take start := time.Now(), then call time.Now() again, subtract, and get “how much has passed”. But in reality the system clock can jump: NTP sync, an admin manually corrected the time, a leap second, a VM migrated, or the OS simply decided “I know better what time it is”.
That’s fine for a calendar, but unpleasant for duration measurement.
That’s why Go has the concept of a monotonic component of time: an internal counter that does not depend on what the “real” clock shows. Starting with Go 1.9, the time package transparently tracks monotonic time inside Time values, and computing a duration between two Time values becomes resilient to system clock adjustments.
In particular, the classic pattern start := time.Now(); ...; elapsed := time.Since(start) works correctly even when such jumps occur.
It’s important to understand the boundary: the monotonic part is for measuring durations, while “wall clock” time is for printing dates, storing “created at 10:30”, and showing it to the user.
Mini example: measure duration via time.Since
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
time.Sleep(20 * time.Millisecond)
fmt.Println(time.Since(start)) // ~20ms (give or take)
}
Why do we like time.Since(start) instead of time.Now().Sub(start)? Because it reads like a phrase (“time since start”) and is the idiomatic way to measure elapsed time.
One more useful intuition: the monotonic component is not about an “exact date”, it’s about “how much time has passed”. So when you format a time.Time into a string or store it as text, the monotonic part is not preserved. That’s not a bug — it’s the logic: the string “2026-01-16 10:30” should not contain a hidden CPU counter.
Mini piece of an app: CreatedAt and task “age”
In real applications you very often need to store “when an object was created”. For tasks it looks natural: CreatedAt time.Time. Then we can show the user “the task was created 3 minutes ago” or “the task has been sitting for 2 days already”.
Let’s make a small extension of the Task model: add CreatedAt and compute a duration for “how much time has passed since creation”. Note that we return and print a time.Duration, not an int, so we don’t lose the meaning of the units.
package main
import (
"fmt"
"time"
)
type Task struct {
Title string
CreatedAt time.Time
}
func main() {
t := Task{Title: "get comfortable with time", CreatedAt: time.Now()}
time.Sleep(10 * time.Millisecond)
fmt.Println(time.Since(t.CreatedAt)) // ~10ms
}
Now let’s add DoneAt and a careful “is the task completed or not” check via IsZero(). This will already look fairly grown-up, even without a database and a REST API.
package main
import "time"
type Task struct {
Title string
CreatedAt time.Time
DoneAt time.Time
}
func (t Task) IsDone() bool {
return !t.DoneAt.IsZero()
}
The key point here is that IsZero() is your best friend in the model. It’s much clearer than comparing t.DoneAt == time.Time{} (you can do that too, but it’s harder to read, especially for beginners).
6. Typical mistakes when working with time.Time and time.Duration
Error #1: writing time.Duration(5) and expecting “5 seconds”.
This is one of the most common problems because the code looks plausible. In reality it’s 5 nanoseconds. The correct form is almost always: 5 * time.Second, 250 * time.Millisecond, 3 * time.Minute. If you see time.Duration(number), double-check whether the author really wanted nanoseconds.
Error #2: storing instants as strings “because it’s easier”.
As long as you only print a date, it seems easier. But as soon as you need to compare, add an interval, compute a difference, or check “is the value set”, strings become a source of pain. Store an instant as time.Time, and convert to a string only for output or interchange (with a clear format).
Error #3: trying to “add two instants” or “subtract duration from duration” with the wrong type.
If you feel the urge to do something like t1 + t2, stop and ask yourself: “what does this mean in the real world?” Instant + instant makes no sense. Instant + duration does. Duration + duration does. Difference of instants is a duration.
Error #4: not using IsZero() and treating 0001-01-01... as a real date.
Printing the zero value of time.Time looks like a date, and a beginner may start processing it as “a very old task”. Usually it means “not set”. In data models, the habit “time may be absent” is nicely expressed via zero value + IsZero().
Error #5: measuring durations with “wall clock” time and being surprised by weird results.
If you manually do end := time.Now(); elapsed := end.Sub(start), that’s usually fine. But if the system time is adjusted, you can get unexpected effects. The idiomatic way to measure is start := time.Now(); ...; elapsed := time.Since(start). This pattern supports the idea of monotonic time and is designed to be resilient to clock corrections.
GO TO FULL VERSION