Parsing dates in Go
You're building a scraper that pulls job listings from a board. Each listing has a "Posted on" field: 2024-05-12. You need to calculate how many days ago the job was posted. You grab the string, pass it to a parse function, and the program crashes. The error says you cannot parse a hyphen as a slash. You check your code. You used a layout with slashes. The input has hyphens. You fix the layout, run again, and it works.
Go's time parsing looks strange compared to other languages. It doesn't use format codes like %Y-%m-%d or YYYY-MM-DD. It uses a reference time. The layout string is a specific timestamp written in the format you want to parse. Once you see the reference time, the pattern clicks. You never memorize format codes again. You just write the reference time in the shape of your input.
The reference time is the layout
Go parses dates by matching a template against a fixed moment in time. That moment is always November 10, 2006, at 15:04:05.05 MST. The layout string is that timestamp formatted exactly how your input string looks.
The digits in the reference time map to components:
2006is the year.01is the month.02is the day of the month.15is the hour.04is the minute.05is the second.05is also the fractional second.MSTis the timezone abbreviation.Monis the weekday name.Janis the month name.PMis the meridiem indicator.
If your input is 2023-10-25, the layout is 2006-01-02. If your input is 25/10/2023, the layout is 02/01/2006. If your input includes a timezone like 2023-10-25 14:30:00 UTC, the layout is 2006-01-02 15:04:05 MST.
Think of the layout as a stencil. The reference time is the master key. You cut holes in the stencil where the digits of the key go. When you hold the stencil up to your input string, the digits must line up with the holes. Literal characters in the layout, like hyphens or slashes, must match the input exactly.
The reference time is a mnemonic. Once you know it, you write layouts from memory.
Minimal example
Here's the simplest parser. You define the layout using the reference time digits, then call time.Parse.
package main
import (
"fmt"
"time"
)
func main() {
// Layout uses the reference time: 2006-01-02
// This matches ISO 8601 date format YYYY-MM-DD
layout := "2006-01-02"
// Parse returns a Time value and an error
// The error is non-nil if the string doesn't match the layout
t, err := time.Parse(layout, "2023-10-25")
if err != nil {
// Handle the error immediately
// In real code, return or log, don't panic
fmt.Println("parse failed:", err)
return
}
// t is now a time.Time value
// You can use methods like Year(), Month(), or Format()
fmt.Println("parsed time:", t)
}
time.Parse takes two arguments: the layout and the value string. It returns a time.Time and an error. The function checks the input string character by character against the layout. Digits in the layout must match digits in the input. Literal characters must match exactly.
The result is a time.Time in UTC by default if no timezone is specified in the layout. If you parse 2023-10-25, the resulting time is 2023-10-25 00:00:00 +0000 UTC. The time part is zeroed out because the layout didn't ask for it. The date is correct. The timezone is UTC. This is a common trap. You parse a date and assume it's local time. It's not. It's UTC.
Realistic parsing with fallbacks
Real code rarely deals with a single format. User input varies. APIs might send different layouts. A robust parser tries multiple layouts in order of preference.
// TryParseDate attempts to parse a date string using common formats.
// It returns the first successful parse or the last error encountered.
func TryParseDate(s string) (time.Time, error) {
// Define layouts in order of preference
// ISO 8601 is standard for APIs
// US format is common in user input
layouts := []string{
"2006-01-02",
"01/02/2006",
"Jan 2, 2006",
}
var lastErr error
// Iterate over layouts and try each one
for _, layout := range layouts {
t, err := time.Parse(layout, s)
if err == nil {
// Success: return immediately
return t, nil
}
// Store error to report if all fail
lastErr = err
}
// Return zero time and the last error
return time.Time{}, lastErr
}
This function loops through a slice of layouts. It tries each one. If time.Parse succeeds, it returns the time. If it fails, it stores the error and tries the next layout. If all layouts fail, it returns the zero value of time.Time and the last error. The zero value is 0001-01-01 00:00:00 +0000 UTC. You can check for this using t.IsZero().
Try specific formats first. Fall back to generic only if you must.
Timezones and locations
When you parse a string without timezone info, Go assumes UTC. This is safe but often wrong for user data. If a user enters 2023-10-25 in New York, they mean midnight in New York, not midnight in UTC. Use time.ParseInLocation to fix this.
// ParseLocalDate parses a date string assuming the given location.
// This sets the timezone on the result to match the location.
func ParseLocalDate(s string, loc *time.Location) (time.Time, error) {
// Layout matches YYYY-MM-DD
layout := "2006-01-02"
// ParseInLocation uses the layout and string, plus the location
// The result has the location set to loc
t, err := time.ParseInLocation(layout, s, loc)
if err != nil {
return time.Time{}, err
}
return t, nil
}
time.ParseInLocation takes the layout, the string, and a *time.Location. You get a location using time.LoadLocation("America/New_York"). The returned time has the correct timezone offset. If you format this time later, it shows the local time, not UTC.
Parse returns UTC. Use ParseInLocation when the input implies a local time.
Pitfalls and errors
time.Parse is strict. It doesn't guess. If the input has extra characters, it fails. If the input is missing characters, it fails. The error message tells you exactly what went wrong.
If you use the wrong layout, the compiler won't catch it. The error happens at runtime. time.Parse returns an error like parsing time "2023-10-25T14:30:00Z" as "2006-01-02": cannot parse "T14:30:00Z" as "-". The error shows the input, the layout, and the mismatch. It says the parser expected a hyphen but found a T. This makes debugging fast.
Leading zeros are flexible. If the layout has 01, the input can have 1 or 01. Go handles both. If the layout has 1, the input can have 1 or 01. The layout digit determines the minimum width, but Go is lenient on input width for numbers. Literal characters are not lenient. A slash in the layout requires a slash in the input.
Another pitfall is time.Format. Formatting uses the same layout system. If you parse with 2006-01-02 and format with 01/02/2006, you get the date in the new format. The symmetry is intentional. You use the reference time for both parsing and formatting.
Error handling is mandatory. Dates break silently if you ignore errors. Always check the error from time.Parse. The community convention is if err != nil { return err }. This boilerplate makes the unhappy path visible. Don't drop the error.
Trust the error message. It pinpoints the mismatch.
Decision matrix
Use time.Parse when you have a fixed format string and need to convert it to a time.Time value.
Use time.ParseInLocation when the input string lacks timezone info but you know the time belongs to a specific location like time.LoadLocation("America/New_York").
Use time.RFC3339 constant when parsing ISO 8601 timestamps from APIs or JSON, since it matches the standard layout exactly.
Use time.ParseDuration when you have a string like 2h30m representing a length of time, not a point in time.
Use a third-party library like dateparse only when you must handle arbitrary user input with unknown formats, and you accept the performance cost of guessing.