The reference time is a template, not a date
You're building a Go service that needs to log timestamps or return dates in JSON. You reach for the formatting function, expecting something familiar like %Y-%m-%d or YYYY-MM-DD. Instead, you find a string that looks like a specific moment in history: Mon Jan 2 15:04:05 MST 2006. It feels wrong. It feels like a magic constant. It is a magic constant, but it's the key to the whole system.
Go doesn't use escape codes for time formatting. Other languages use letters to represent parts of a date. Python uses %Y for the year. JavaScript uses YYYY. Go uses the actual values of a specific reference time. The reference time is January 2, 15:04:05, zone MST, in the year 2006. Every part of that string maps to a component of the time. The month is January, so Jan stands for the month. The day is 2, so 2 stands for the day. The hour is 15, so 15 stands for the hour. You build your format string by rearranging these pieces. If you want the year first, you put 2006 first. If you want a slash between the month and day, you write Jan/2. The parser reads your layout string, finds the reference values, and replaces them with the actual values from your time.Time object.
The reference time follows a hidden sequence that makes it easier to remember. The month is January, which is 1. The day is 2. The hour is 15, which is 3 PM. The minute is 04. The second is 05. The year is 2006. The numbers 1, 2, 3, 4, 5 appear in order across the month, day, hour, minute, and second. You don't need to memorize the full string. You just need to remember the sequence and the year.
Minimal example
Here's the simplest usage. Create a time, format it with the full reference, and print it.
package main
import (
"fmt"
"time"
)
func main() {
// time.Now captures the current instant with nanosecond precision.
now := time.Now()
// The layout string uses the reference time values to define the output structure.
// "Mon Jan 2 15:04:05 MST 2006" produces a full timestamp like "Tue Oct 10 14:30:00 UTC 2023".
layout := "Mon Jan 2 15:04:05 MST 2006"
result := now.Format(layout)
fmt.Println(result)
}
How the runtime processes the layout
When you call Format, the runtime scans your layout string character by character. It looks for substrings that match the reference time. It identifies 2006 as the year placeholder. It identifies Jan as the month. It identifies 2 as the day. It identifies 15 as the hour. It identifies 04 as the minute. It identifies 05 as the second. It identifies Mon as the weekday. It identifies MST as the timezone. Any character that isn't part of the reference time is treated as a literal. Spaces, slashes, dashes, and colons pass through unchanged. The function replaces each placeholder with the corresponding value from the time.Time instance and returns the resulting string.
The reference time is hard-coded in the standard library. You can't change it. You can't define a custom reference. The system relies on this single constant to parse and format every time value in the language. This design choice eliminates ambiguity. There's only one way to write a layout. There's no confusion between %d and %e or DD and dd. The layout is self-documenting because it shows exactly what the output looks like, just with fixed values.
The reference time is a template. Treat it as a pattern, not a date.
Realistic usage with standard constants
Real code rarely uses the full reference time. You usually need ISO 8601 for APIs or a compact format for logs. The standard library provides constants for common formats. time.RFC3339 is the ISO 8601 layout. time.RFC1123 is the HTTP header format. Use these constants instead of typing the layout string manually. It reduces typos and makes the intent clear. The convention is to use the constant when available. Only write a custom layout when you need a non-standard format.
Here's how to format an ISO 8601 timestamp for an API response.
package main
import (
"fmt"
"time"
)
// FormatISO8601 converts a time to the standard ISO 8601 format used in most APIs.
func FormatISO8601(t time.Time) string {
// ISO 8601 requires year-month-day, T separator, hour:minute:second, and timezone offset.
// The layout maps 2006 to year, 1 to month, 2 to day, 15 to hour, 04 to minute, 05 to second.
// -0700 represents the numeric timezone offset like +0000 or -0500.
// Using the constant time.RFC3339 is preferred over hard-coding the layout.
const isoLayout = "2006-01-02T15:04:05-07:00"
return t.Format(isoLayout)
}
func main() {
now := time.Now()
// Output looks like "2023-10-10T14:30:00+00:00".
fmt.Println(FormatISO8601(now))
}
Parsing uses the same logic
Formatting turns a time into a string. Parsing turns a string into a time. time.Parse uses the exact same reference time logic. You provide the layout and the string. The function returns the time.Time and an error. The layout must match the string exactly. If the string has a timezone offset, the layout must have a timezone placeholder. If the string has Z, the layout must have Z or MST. This symmetry is powerful. The same string formats and parses. You can write a helper that formats and parses back to verify correctness.
The compiler won't catch layout errors. Format always succeeds. It returns a string. If your layout is wrong, you get garbage output, not a panic. time.Parse returns an error if the string doesn't match. The error message is verbose and tells you exactly where the mismatch occurred. If you pass a string with a missing timezone to a layout that expects one, the compiler rejects the program at runtime with parsing time "2023-10-10T14:30:00": cannot parse "" as "-". Handle the error immediately. The convention is if err != nil { return err } to make the unhappy path visible.
package main
import (
"fmt"
"time"
)
// ParseTimestamp converts an ISO 8601 string back to a time.Time value.
func ParseTimestamp(s string) (time.Time, error) {
// time.Parse uses the same reference time logic as Format.
// The layout defines the expected structure of the input string.
// Returns an error if the string does not match the layout.
// Always check the error. A bad input string causes a parse failure.
return time.Parse(time.RFC3339, s)
}
func main() {
t, err := ParseTimestamp("2023-10-10T14:30:00Z")
if err != nil {
// Handle the error. The input string might be malformed or use a different format.
fmt.Println("parse failed:", err)
return
}
fmt.Println(t)
}
Format never fails. Validate your layouts in tests.
Timezone placeholders control output
Timezones are the hardest part of the reference time. The reference time uses MST. This stands for Mountain Standard Time, but in the layout, it acts as a placeholder for the timezone name. If your time has a named zone, MST prints the name. If your time is UTC, MST prints UTC. If your time has a numeric offset, MST prints the offset. You can also use -0700 in the layout to force a numeric offset. Z forces the letter Z for UTC. Z0700 prints Z for UTC or the offset otherwise. Z07:00 prints Z or the offset with a colon. Choose the placeholder based on what your consumer expects. APIs usually want Z or +00:00. Logs might want the zone name.
Formatting doesn't change the time. It just displays it. If you want to display a time in a different timezone, you must convert the time first. Use t.In(loc) to convert to a location. time.LoadLocation loads a timezone by name. time.UTC is the UTC location. time.Local is the system local time. Formatting MST doesn't convert to MST. It just shows the zone. Convert then format.
package main
import (
"fmt"
"time"
)
func main() {
// time.Now returns the current time in the local timezone.
now := time.Now()
// LoadLocation returns a location by name. "America/New_York" is a common zone.
// Always check the error from LoadLocation. Invalid zone names cause a runtime error.
loc, err := time.LoadLocation("America/New_York")
if err != nil {
fmt.Println("failed to load location:", err)
return
}
// In converts the time to the target location without changing the instant.
// The hour and minute change to reflect the new timezone.
nycTime := now.In(loc)
// Format with MST to show the zone name, or -0700 to show the offset.
fmt.Println(nycTime.Format("2006-01-02 15:04:05 MST"))
}
Common pitfalls
The reference time hides traps. The day is 2, not 02. If you write 02 in your layout, Go pads the day with a zero. If you write 2, Go omits the leading zero. The month is Jan, 1, or 01. Jan gives the name. 1 gives the number without padding. 01 gives the number with padding. The hour is 15 for 24-hour time. 3 gives 12-hour time. PM adds the meridian. If you mix 15 and PM, you get a weird result. The hour prints as 24-hour, and PM prints based on the hour value. Use 15 for 24-hour formats. Use 3 and PM for 12-hour formats.
The zero value of time.Time is January 1, year 1. If you format a zero time, you get 0001-01-01T00:00:00Z. This often leaks into logs or JSON when a time field is missing. Always check t.IsZero() before formatting if the time might be unset. The compiler won't warn you. The zero value is a valid time.
package main
import (
"fmt"
"time"
)
func main() {
// The zero value of time.Time is year 1, month 1, day 1.
var zeroTime time.Time
// IsZero returns true if the time is the zero value.
// Check this before formatting to avoid leaking 0001-01-01 into output.
if zeroTime.IsZero() {
fmt.Println("time is unset")
} else {
fmt.Println(zeroTime.Format(time.RFC3339))
}
}
Decision matrix
Use time.RFC3339 when you need a standard timestamp for APIs or databases. Use time.RFC1123 when you are parsing or generating HTTP headers. Use a custom layout string when you need a specific format that isn't covered by the standard constants. Use time.Unix() when you need a numeric timestamp for storage or comparison, not a human-readable string. Use time.Time.String() when you need a debug representation; it produces a verbose output that includes nanoseconds and is not meant for parsing.
Standard constants for standards. Custom layouts for custom needs.