The instant is the truth
Your scheduler fires at 9 AM Tokyo time. Your server sits in Frankfurt. You call time.Now(), print the result, and the output shifts depending on the machine's configuration. You need to translate a moment in time across zones without altering the instant itself.
Go splits time into two pieces. The instant is a raw count of nanoseconds since January 1, 1970. This number never changes. The location is a set of rules that turns that count into hours, minutes, and seconds, including daylight saving adjustments. Converting a time keeps the instant identical and swaps the location. The clock reading changes. The moment in history does not.
Think of a flight departing at 10:00 UTC. In New York, that is 5:00 AM. In London, it is 10:00 AM. The plane leaves the ground at the same nanosecond. A time.Time value holds the nanosecond count and a pointer to a *time.Location. Converting the time creates a new time.Time with the same count and a different pointer. The underlying reality stays fixed.
The instant is the truth. The location is just a label.
Loading a location
To convert a time, you need a *time.Location. Go reads these from the IANA timezone database, the same data source used by Linux, macOS, and most web browsers. Zone names follow the Area/City convention, like America/New_York or Europe/Berlin.
The function time.LoadLocation parses the database and returns a location object. It also returns an error. If the zone name is misspelled or missing from the database, you get an error and a nil location. Ignoring this error leads to a panic later. The compiler will not stop you from discarding the error, but the runtime will crash when you try to use a nil location.
Here's the core pattern: load a location, create a time, convert it.
package main
import (
"fmt"
"time"
)
func main() {
// LoadLocation reads the IANA database.
// It caches results, so repeated calls are fast.
loc, err := time.LoadLocation("America/New_York")
if err != nil {
// LoadLocation returns an error for invalid zone names.
// Panicking here stops the program.
// In a library, return the error to the caller.
panic(err)
}
// UTC is a built-in constant.
// No loading is required for time.UTC.
t := time.Now().UTC()
// In returns a new Time with the same instant but the new location.
// The nanosecond count inside t and nyTime is identical.
nyTime := t.In(loc)
fmt.Println("UTC:", t)
fmt.Println("NY:", nyTime)
}
Go makes the error path visible. The if err != nil block is verbose by design. The community accepts the boilerplate because it forces you to handle the failure case immediately. Go code follows a single formatting style. Run gofmt to align your code. Most editors run it on save. Don't argue about indentation.
time.LoadLocation does heavy parsing on the first call. The standard library caches the resulting *time.Location objects. Calling LoadLocation repeatedly with the same name is fast after the initial hit. You can load a location once at startup and reuse it across the entire application. The location object is immutable and safe for concurrent use.
Store UTC. Convert at the edge.
Converting with In
The In method is the workhorse. It takes a *time.Location and returns a new time.Time. It does not modify the original value. Go's time values are value types, so assignment copies them. You can pass t around safely without worrying about side effects.
// ConvertToZone takes an instant and a zone name, returning the localized time.
// It returns the original time if the zone is invalid.
func ConvertToZone(t time.Time, zoneName string) time.Time {
// LoadLocation handles parsing and caching.
loc, err := time.LoadLocation(zoneName)
if err != nil {
// Fall back to UTC if the zone is bad.
// This prevents a nil pointer panic on the In call.
return t.UTC()
}
// In creates a new Time value.
// The result has the same instant as t but displays in the new zone.
return t.In(loc)
}
The method name In reflects the semantic meaning. You are asking "What time is it in this location?" The answer preserves the moment. If you have a time in America/Los_Angeles and call In with Asia/Tokyo, the result shows the Tokyo clock reading for that exact instant.
Go provides two built-in locations you can use without loading. time.UTC is the zero-offset zone. time.Local is the zone configured on the server's operating system. Calling t.UTC() is equivalent to t.In(time.UTC). Calling t.Local() is equivalent to t.In(time.Local).
You can construct times explicitly with time.Date. This function takes year, month, day, hour, minute, second, nanosecond, and location. It normalizes the values. If you pass hour 25, it rolls over to the next day. This is useful for testing or generating schedules.
Never trust time.Now() without knowing the server's clock.
Parsing strings with location
Converting an existing time.Time is straightforward. Parsing a string is where bugs hide. If you receive a timestamp string without timezone info, Go needs to know where it came from.
The function time.Parse assumes the string is in UTC if no zone is present. This is often wrong. If you read 2024-05-15T09:00:00 from a user's browser, that is likely their local time, not UTC. You must use time.ParseInLocation to attach the correct zone during parsing.
Here's a realistic parser that handles user input with a known source zone.
// ParseUserInput parses a time string assuming it is in the user's local zone.
// It returns the time anchored to the provided location.
func ParseUserInput(layout, value, zoneName string) (time.Time, error) {
// LoadLocation handles parsing and caching.
loc, err := time.LoadLocation(zoneName)
if err != nil {
// Return the error immediately.
// The caller must decide how to handle a bad zone name.
return time.Time{}, err
}
// ParseInLocation interprets the string using the given location.
// The resulting time has the location attached.
t, err := time.ParseInLocation(layout, value, loc)
if err != nil {
// Return the parse error.
// The caller can distinguish between a bad zone and a bad format.
return time.Time{}, err
}
return t, nil
}
Go uses a reference time for layout strings: Mon Jan 2 15:04:05 MST 2006. This maps to 01/02/2006 3:04:05 PM. The numbers are fixed and must appear in that order. You cannot use 2024-01-01 as a layout. You must use 2006-01-02. This convention is unique to Go. The layout string tells Go how to map the reference time to your input format.
If the input string contains a timezone offset like Z or +09:00, time.Parse extracts it and sets the location automatically. You do not need ParseInLocation for ISO 8601 strings with offsets. Use ParseInLocation only when the string lacks zone info and you know the source.
When you call time.Parse, you get a time and an error. If you are sure the format is correct, you might be tempted to discard the error with _. Don't. Time parsing fails silently if you ignore the error, leading to zero-value times that crash later.
Validate the zone name. A bad string crashes the conversion.
Pitfalls and comparisons
Comparing times in different zones requires care. The == operator checks value equality, which includes the location. Two times can represent the same instant but return false from == if their locations differ.
t1 := time.Date(2024, 5, 15, 12, 0, 0, 0, time.UTC)
t2 := time.Date(2024, 5, 15, 14, 0, 0, 0, time.FixedZone("CET", 2*3600))
// t1 and t2 are the same instant.
// t1 == t2 is false because the locations differ.
// t1.Equal(t2) is true because the instants match.
Use the Equal method to compare instants across zones. Equal checks the underlying nanosecond count. It returns true if the moments match, regardless of location. Use == only when you need to check that both the instant and the location are identical, which is rare.
If you pass a string to In, the compiler rejects this with cannot use loc (variable of type string) as *time.Location value in argument. The compiler catches type mismatches early. If you compare with == and the locations differ, the result is false even if the instants match. The compiler does not warn about this. You must choose Equal explicitly.
Every Go type has a zero value. For time.Time, the zero value is January 1, year 1, 00:00:00 UTC. If a parse fails and you ignore the error, you get this zero value. Comparing a zero time to a real time with == can produce surprising results. Use the IsZero method to check for this state.
// CheckZero demonstrates the zero value behavior.
func CheckZero(t time.Time) {
// IsZero returns true if the time is the zero value.
// This is safer than comparing against time.Time{}.
if t.IsZero() {
fmt.Println("Time is zero")
}
}
Daylight saving transitions create ambiguity. In some zones, the clock falls back, creating two 2:00 AMs. In others, it springs forward, skipping an hour. Go handles this by storing the offset in the time value. When you convert across zones, Go applies the correct offset for that instant. You do not need to calculate DST manually. The location object contains the transition rules.
time.FixedZone creates a location with a constant offset. It does not handle daylight saving. Use this only for legacy systems or zones that never change. Most modern applications should use LoadLocation to get the full transition rules.
The worst time bug is the one that looks correct until DST hits.
Decision matrix
Use time.LoadLocation and In when you need to display a stored instant in a user's preferred timezone.
Use time.UTC when you need a stable, zero-offset reference for storage, logging, or comparison.
Use time.Local when you want the behavior to match the server's operating system configuration.
Use time.FixedZone when you have a static offset that never changes, like a legacy system that ignores daylight saving.
Use time.ParseInLocation when you are reading a string that lacks timezone info and you know the source zone.
Use time.Parse when the input string contains a timezone offset or you want to assume UTC.
Store UTC. Convert at the edge.