Go time Package: Comprehensive Tutorial for Timestamps and Timezones

Master Go's time package with time.Time, time.Duration, timezone handling, formatting, parsing, and production best practices. Complete guide for Go datetime operations.

Go time Package: Comprehensive Tutorial

There's something refreshingly straightforward about Go's approach to time. No inheritance hierarchies, no twelve different classes to choose from—just the time package and its beautifully designed types. It's type-safe, it's efficient, and once you get past that quirky date format syntax, it's honestly a joy to work with. Let's dive in.

The time.Time Type

Creating Time Values

package main

import (
    "fmt"
    "time"
)

func main() {
    // Current time (local timezone)
    now := time.Now()
    fmt.Println(now)  // 2024-01-15 19:00:00.123456789 -0500 EST

    // Current time in UTC
    utcNow := time.Now().UTC()
    fmt.Println(utcNow)  // 2024-01-15 19:00:00.123456789 +0000 UTC

    // From components
    date := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)
    // Args: year, month, day, hour, min, sec, nsec, location

    // From Unix timestamp (seconds)
    timestamp := int64(1705341600)
    fromUnix := time.Unix(timestamp, 0)
    fmt.Println(fromUnix)  // 2024-01-15 19:00:00 +0000 UTC

    // From Unix timestamp with nanoseconds
    fromUnixNano := time.Unix(timestamp, 123456789)

    // Parse from string
    parsed, err := time.Parse(time.RFC3339, "2024-01-15T19:00:00Z")
    if err != nil {
        panic(err)
    }
    fmt.Println(parsed)

    // Parse with custom layout
    layout := "2006-01-02 15:04:05"
    custom, err := time.Parse(layout, "2024-01-15 19:00:00")
    if err != nil {
        panic(err)
    }
    fmt.Println(custom)
}

Converting to Timestamps

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)

    // To Unix timestamp (seconds)
    unixSec := t.Unix()
    fmt.Println(unixSec)  // 1705341600

    // To Unix timestamp (milliseconds)
    unixMilli := t.UnixMilli()
    fmt.Println(unixMilli)  // 1705341600000

    // To Unix timestamp (microseconds)
    unixMicro := t.UnixMicro()
    fmt.Println(unixMicro)  // 1705341600000000

    // To Unix timestamp (nanoseconds)
    unixNano := t.UnixNano()
    fmt.Println(unixNano)  // 1705341600000000000

    // To RFC3339 string (ISO 8601)
    rfc3339 := t.Format(time.RFC3339)
    fmt.Println(rfc3339)  // 2024-01-15T19:00:00Z

    // To RFC3339Nano (with nanoseconds)
    rfc3339Nano := t.Format(time.RFC3339Nano)
    fmt.Println(rfc3339Nano)

    // Custom formatting
    formatted := t.Format("2006-01-02 15:04:05")
    fmt.Println(formatted)  // 2024-01-15 19:00:00
}

Go's "Magic" Date Format (Yeah, It's Weird at First)

Here's where Go gets... unique. Instead of %Y-%m-%d or yyyy-MM-dd, Go uses an actual reference date. Why? Because the Go team decided memorizing format codes was silly. Is it weird? Yes. Will you love it once you get used to it? Also yes.

The Reference Date: Mon Jan 2 15:04:05 MST 2006 (Remember This!)

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)

    /*
    Reference date components:
    Mon Jan 2 15:04:05 MST 2006

    Year:   2006 (or 06)
    Month:  01 (or 1, Jan, January)
    Day:    02 (or 2, _2)
    Hour:   15 (or 03, 3)
    Minute: 04 (or 4)
    Second: 05 (or 5)
    Weekday: Mon (or Monday)
    Timezone: MST (or -0700, -07:00, Z07:00)
    */

    // Date formats
    fmt.Println(t.Format("2006"))           // 2024 - Year (4 digit)
    fmt.Println(t.Format("06"))             // 24   - Year (2 digit)
    fmt.Println(t.Format("01"))             // 01   - Month (2 digit)
    fmt.Println(t.Format("1"))              // 1    - Month (1 digit)
    fmt.Println(t.Format("Jan"))            // Jan  - Month short
    fmt.Println(t.Format("January"))        // January - Month full
    fmt.Println(t.Format("02"))             // 15   - Day (2 digit)
    fmt.Println(t.Format("2"))              // 15   - Day (1 digit)
    fmt.Println(t.Format("_2"))             // 15   - Day (space padded)

    // Time formats
    fmt.Println(t.Format("15"))             // 19   - Hour 24h
    fmt.Println(t.Format("03"))             // 07   - Hour 12h
    fmt.Println(t.Format("3"))              // 7    - Hour 12h (no leading 0)
    fmt.Println(t.Format("04"))             // 00   - Minute
    fmt.Println(t.Format("05"))             // 00   - Second
    fmt.Println(t.Format("PM"))             // PM   - AM/PM
    fmt.Println(t.Format("pm"))             // pm   - am/pm

    // Weekday
    fmt.Println(t.Format("Mon"))            // Mon    - Weekday short
    fmt.Println(t.Format("Monday"))         // Monday - Weekday full

    // Timezone
    fmt.Println(t.Format("MST"))            // UTC  - Timezone name
    fmt.Println(t.Format("-0700"))          // +0000 - Offset
    fmt.Println(t.Format("-07:00"))         // +00:00 - Offset with colon
    fmt.Println(t.Format("Z0700"))          // Z    - UTC as Z, else offset
    fmt.Println(t.Format("Z07:00"))         // Z    - UTC as Z, else offset with colon

    // Common patterns
    fmt.Println(t.Format("2006-01-02"))                    // 2024-01-15
    fmt.Println(t.Format("2006-01-02 15:04:05"))          // 2024-01-15 19:00:00
    fmt.Println(t.Format("January 2, 2006"))              // January 15, 2024
    fmt.Println(t.Format("Monday, January 2, 2006"))      // Monday, January 15, 2024
    fmt.Println(t.Format("Mon Jan _2 15:04:05 MST 2006")) // Mon Jan 15 19:00:00 UTC 2024
}

Predefined Layouts

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Now()

    // Predefined constants
    fmt.Println(t.Format(time.Layout))      // 01/02 03:04:05PM '06 -0700
    fmt.Println(t.Format(time.ANSIC))       // Mon Jan _2 15:04:05 2006
    fmt.Println(t.Format(time.UnixDate))    // Mon Jan _2 15:04:05 MST 2006
    fmt.Println(t.Format(time.RubyDate))    // Mon Jan 02 15:04:05 -0700 2006
    fmt.Println(t.Format(time.RFC822))      // 02 Jan 06 15:04 MST
    fmt.Println(t.Format(time.RFC822Z))     // 02 Jan 06 15:04 -0700
    fmt.Println(t.Format(time.RFC850))      // Monday, 02-Jan-06 15:04:05 MST
    fmt.Println(t.Format(time.RFC1123))     // Mon, 02 Jan 2006 15:04:05 MST
    fmt.Println(t.Format(time.RFC1123Z))    // Mon, 02 Jan 2006 15:04:05 -0700
    fmt.Println(t.Format(time.RFC3339))     // 2006-01-02T15:04:05Z07:00
    fmt.Println(t.Format(time.RFC3339Nano)) // 2006-01-02T15:04:05.999999999Z07:00
    fmt.Println(t.Format(time.Kitchen))     // 3:04PM
    fmt.Println(t.Format(time.Stamp))       // Jan _2 15:04:05
    fmt.Println(t.Format(time.StampMilli))  // Jan _2 15:04:05.000
    fmt.Println(t.Format(time.StampMicro))  // Jan _2 15:04:05.000000
    fmt.Println(t.Format(time.StampNano))   // Jan _2 15:04:05.000000000
}

Parsing Time Strings

Basic Parsing

package main

import (
    "fmt"
    "time"
)

func main() {
    // Parse with layout
    layout := "2006-01-02 15:04:05"
    str := "2024-01-15 19:00:00"

    parsed, err := time.Parse(layout, str)
    if err != nil {
        panic(err)
    }
    fmt.Println(parsed)

    // Parse in location
    loc, _ := time.LoadLocation("America/New_York")
    inLocation, err := time.ParseInLocation(layout, str, loc)
    if err != nil {
        panic(err)
    }
    fmt.Println(inLocation)

    // Parse RFC3339 (ISO 8601)
    iso := "2024-01-15T19:00:00Z"
    isoTime, err := time.Parse(time.RFC3339, iso)
    if err != nil {
        panic(err)
    }
    fmt.Println(isoTime)

    // Parse with timezone
    withTz := "2024-01-15T19:00:00-05:00"
    tzTime, err := time.Parse(time.RFC3339, withTz)
    if err != nil {
        panic(err)
    }
    fmt.Println(tzTime)
}

Safe Parsing Helper

package main

import (
    "fmt"
    "time"
)

// ParseTime safely parses various datetime formats
func ParseTime(input string) (time.Time, error) {
    // Try common formats in order
    formats := []string{
        time.RFC3339,           // 2006-01-02T15:04:05Z07:00
        time.RFC3339Nano,       // With nanoseconds
        "2006-01-02 15:04:05",  // SQL datetime
        "2006-01-02",           // Date only
        time.RFC1123Z,          // RFC 1123 with timezone
        time.RFC822Z,           // RFC 822 with timezone
    }

    for _, format := range formats {
        if t, err := time.Parse(format, input); err == nil {
            return t, nil
        }
    }

    return time.Time{}, fmt.Errorf("unable to parse time: %s", input)
}

func main() {
    examples := []string{
        "2024-01-15T19:00:00Z",
        "2024-01-15 19:00:00",
        "2024-01-15",
        "Mon, 15 Jan 2024 19:00:00 +0000",
    }

    for _, example := range examples {
        t, err := ParseTime(example)
        if err != nil {
            fmt.Printf("Error parsing '%s': %v\n", example, err)
        } else {
            fmt.Printf("Parsed '%s' → %v\n", example, t)
        }
    }
}

Time Arithmetic

Go's Duration type is where things get really nice. It's just an int64 of nanoseconds under the hood, but the API makes it feel natural.

Duration Type (Nanoseconds All The Way Down)

package main

import (
    "fmt"
    "time"
)

func main() {
    // Create durations
    second := time.Second
    minute := time.Minute
    hour := time.Hour

    // Duration constants
    fmt.Println(time.Nanosecond)   // 1ns
    fmt.Println(time.Microsecond)  // 1µs = 1000ns
    fmt.Println(time.Millisecond)  // 1ms = 1000µs
    fmt.Println(time.Second)       // 1s  = 1000ms
    fmt.Println(time.Minute)       // 1m  = 60s
    fmt.Println(time.Hour)         // 1h  = 60m

    // Create custom durations
    twoHours := 2 * time.Hour
    thirtyMinutes := 30 * time.Minute
    fiveSeconds := 5 * time.Second

    // Complex duration
    complex := 2*time.Hour + 30*time.Minute + 15*time.Second
    fmt.Println(complex)  // 2h30m15s

    // Duration arithmetic
    sum := time.Hour + 30*time.Minute          // 1h30m
    diff := 2*time.Hour - 30*time.Minute       // 1h30m
    doubled := 2 * time.Hour                   // 2h
    half := time.Hour / 2                      // 30m

    // Convert duration to units
    d := 90 * time.Minute
    fmt.Println(d.Hours())       // 1.5
    fmt.Println(d.Minutes())     // 90
    fmt.Println(d.Seconds())     // 5400
    fmt.Println(d.Milliseconds()) // 5400000
    fmt.Println(d.Microseconds()) // 5400000000
    fmt.Println(d.Nanoseconds())  // 5400000000000

    // Truncate and round
    d2 := 125 * time.Minute
    fmt.Println(d2.Truncate(time.Hour))  // 2h
    fmt.Println(d2.Round(time.Hour))     // 2h
}

Add and Sub

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)

    // Add duration
    oneHourLater := t.Add(time.Hour)
    oneDayLater := t.Add(24 * time.Hour)
    twoWeeksLater := t.Add(14 * 24 * time.Hour)

    fmt.Println(oneHourLater)   // 2024-01-15 20:00:00 +0000 UTC
    fmt.Println(oneDayLater)    // 2024-01-16 19:00:00 +0000 UTC
    fmt.Println(twoWeeksLater)  // 2024-01-29 19:00:00 +0000 UTC

    // Subtract duration (add negative)
    oneHourEarlier := t.Add(-time.Hour)
    fmt.Println(oneHourEarlier)  // 2024-01-15 18:00:00 +0000 UTC

    // Difference between times
    start := time.Now()
    time.Sleep(2 * time.Second)
    end := time.Now()

    elapsed := end.Sub(start)
    fmt.Printf("Elapsed: %v\n", elapsed)  // Elapsed: 2.001234s
    fmt.Printf("Seconds: %.2f\n", elapsed.Seconds())

    // Until and Since
    future := time.Now().Add(5 * time.Minute)
    past := time.Now().Add(-5 * time.Minute)

    untilFuture := time.Until(future)    // Duration until future
    sincePast := time.Since(past)        // Duration since past

    fmt.Printf("Until future: %v\n", untilFuture)
    fmt.Printf("Since past: %v\n", sincePast)
}

AddDate for Calendar Arithmetic

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)

    // AddDate(years, months, days)
    nextYear := t.AddDate(1, 0, 0)
    nextMonth := t.AddDate(0, 1, 0)
    nextWeek := t.AddDate(0, 0, 7)
    tomorrow := t.AddDate(0, 0, 1)

    fmt.Println(nextYear)   // 2025-01-15 19:00:00 +0000 UTC
    fmt.Println(nextMonth)  // 2024-02-15 19:00:00 +0000 UTC
    fmt.Println(nextWeek)   // 2024-01-22 19:00:00 +0000 UTC
    fmt.Println(tomorrow)   // 2024-01-16 19:00:00 +0000 UTC

    // Subtract (use negative values)
    lastYear := t.AddDate(-1, 0, 0)
    lastMonth := t.AddDate(0, -1, 0)
    yesterday := t.AddDate(0, 0, -1)

    // Complex calendar arithmetic
    complex := t.AddDate(1, 2, 15)  // 1 year, 2 months, 15 days
    fmt.Println(complex)  // 2025-03-30 19:00:00 +0000 UTC

    // Handles month boundaries correctly
    jan31 := time.Date(2024, 1, 31, 0, 0, 0, 0, time.UTC)
    feb := jan31.AddDate(0, 1, 0)
    fmt.Println(feb)  // 2024-03-02 (Feb has 29 days in 2024)

    // Last day of month calculation
    firstOfNextMonth := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
    lastOfThisMonth := firstOfNextMonth.AddDate(0, 0, -1)
    fmt.Println(lastOfThisMonth)  // 2024-01-31
}

Timezone Handling

time.Location Type

package main

import (
    "fmt"
    "time"
)

func main() {
    // Load timezone
    nyc, err := time.LoadLocation("America/New_York")
    if err != nil {
        panic(err)
    }

    tokyo, _ := time.LoadLocation("Asia/Tokyo")
    london, _ := time.LoadLocation("Europe/London")

    // UTC and Local are predefined
    utc := time.UTC
    local := time.Local

    // Create time in specific timezone
    nycTime := time.Date(2024, 1, 15, 14, 0, 0, 0, nyc)
    fmt.Println(nycTime)  // 2024-01-15 14:00:00 -0500 EST

    // Current time in timezone
    nycNow := time.Now().In(nyc)
    tokyoNow := time.Now().In(tokyo)

    fmt.Printf("NYC:   %s\n", nycNow.Format("15:04:05 MST"))
    fmt.Printf("Tokyo: %s\n", tokyoNow.Format("15:04:05 MST"))

    // Get location from time
    loc := nycTime.Location()
    fmt.Println(loc.String())  // America/New_York
}

Converting Between Timezones

package main

import (
    "fmt"
    "time"
)

func main() {
    // Create time in UTC
    utcTime := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)

    // Load timezones
    nyc, _ := time.LoadLocation("America/New_York")
    tokyo, _ := time.LoadLocation("Asia/Tokyo")
    london, _ := time.LoadLocation("Europe/London")

    // Convert to different timezones
    nycTime := utcTime.In(nyc)
    tokyoTime := utcTime.In(tokyo)
    londonTime := utcTime.In(london)

    // Same moment in time, different representation
    fmt.Println("UTC:    ", utcTime.Format("2006-01-02 15:04:05 MST"))
    fmt.Println("NYC:    ", nycTime.Format("2006-01-02 15:04:05 MST"))
    fmt.Println("Tokyo:  ", tokyoTime.Format("2006-01-02 15:04:05 MST"))
    fmt.Println("London: ", londonTime.Format("2006-01-02 15:04:05 MST"))

    // Output:
    // UTC:     2024-01-15 19:00:00 UTC
    // NYC:     2024-01-15 14:00:00 EST
    // Tokyo:   2024-01-16 04:00:00 JST
    // London:  2024-01-15 19:00:00 GMT

    // Verify same moment
    fmt.Println("Same moment?", utcTime.Equal(nycTime))  // true
    fmt.Println("Unix:", utcTime.Unix())                 // Same timestamp
    fmt.Println("Unix:", nycTime.Unix())                 // Same timestamp
}

Handling DST Transitions

package main

import (
    "fmt"
    "time"
)

func main() {
    nyc, _ := time.LoadLocation("America/New_York")

    // Spring forward: March 10, 2024, 2:00 AM → 3:00 AM
    // Attempting to create 2:30 AM (doesn't exist)
    nonExistent := time.Date(2024, 3, 10, 2, 30, 0, 0, nyc)
    fmt.Println(nonExistent.Format("2006-01-02 15:04:05 MST"))
    // 2024-03-10 03:30:00 EDT (automatically adjusted to 3:30 AM)

    // Fall back: November 3, 2024, 2:00 AM → 1:00 AM
    // 1:30 AM occurs twice
    ambiguous := time.Date(2024, 11, 3, 1, 30, 0, 0, nyc)
    fmt.Println(ambiguous.Format("2006-01-02 15:04:05 MST"))
    // 2024-11-03 01:30:00 EDT (first occurrence)

    // Check DST status
    summer := time.Date(2024, 7, 15, 12, 0, 0, 0, nyc)
    winter := time.Date(2024, 1, 15, 12, 0, 0, 0, nyc)

    _, summerOffset := summer.Zone()
    _, winterOffset := winter.Zone()

    fmt.Printf("Summer offset: %d hours\n", summerOffset/3600)  // -4 (EDT)
    fmt.Printf("Winter offset: %d hours\n", winterOffset/3600)  // -5 (EST)

    // Get timezone name and offset
    zone, offset := summer.Zone()
    fmt.Printf("Zone: %s, Offset: %d seconds\n", zone, offset)
}

Time Comparison

Compare Methods

package main

import (
    "fmt"
    "time"
)

func main() {
    now := time.Now()
    past := now.Add(-time.Hour)
    future := now.Add(time.Hour)

    // Equal (checks if same moment)
    fmt.Println(now.Equal(now))    // true
    fmt.Println(now.Equal(past))   // false

    // Before
    fmt.Println(past.Before(now))   // true
    fmt.Println(now.Before(past))   // false

    // After
    fmt.Println(future.After(now))  // true
    fmt.Println(now.After(future))  // false

    // Compare (returns -1, 0, or 1)
    fmt.Println(past.Compare(now))    // -1 (past < now)
    fmt.Println(now.Compare(now))     // 0  (equal)
    fmt.Println(future.Compare(now))  // 1  (future > now)

    // IsZero (check for zero value)
    var zero time.Time
    fmt.Println(zero.IsZero())  // true
    fmt.Println(now.IsZero())   // false

    // Equal handles timezone differences
    utc := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)
    nyc, _ := time.LoadLocation("America/New_York")
    est := time.Date(2024, 1, 15, 14, 0, 0, 0, nyc)

    fmt.Println(utc.Equal(est))  // true (same moment, different zones)
}

Working with Time Components

Extracting Components

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, 1, 15, 19, 30, 45, 123456789, time.UTC)

    // Date components
    year := t.Year()       // 2024
    month := t.Month()     // January (time.Month type)
    day := t.Day()         // 15
    yearDay := t.YearDay() // 15 (day of year)
    weekday := t.Weekday() // Monday (time.Weekday type)

    // Time components
    hour := t.Hour()        // 19
    minute := t.Minute()    // 30
    second := t.Second()    // 45
    nanosec := t.Nanosecond() // 123456789

    fmt.Printf("Date: %d-%s-%d\n", year, month, day)
    fmt.Printf("Time: %02d:%02d:%02d.%09d\n", hour, minute, second, nanosec)
    fmt.Printf("Weekday: %s\n", weekday)
    fmt.Printf("Day of year: %d\n", yearDay)

    // ISO week
    isoYear, isoWeek := t.ISOWeek()
    fmt.Printf("ISO week: %d-W%02d\n", isoYear, isoWeek)

    // Date() returns year, month, day
    y, m, d := t.Date()
    fmt.Printf("Date: %d-%s-%d\n", y, m, d)

    // Clock() returns hour, min, sec
    h, min, s := t.Clock()
    fmt.Printf("Clock: %02d:%02d:%02d\n", h, min, s)

    // Month and Weekday are typed enums
    fmt.Println(month == time.January)    // true
    fmt.Println(weekday == time.Monday)   // true
}

Truncate and Round

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Date(2024, 1, 15, 19, 34, 56, 123456789, time.UTC)

    // Truncate (round down)
    fmt.Println(t.Truncate(time.Hour))   // 2024-01-15 19:00:00
    fmt.Println(t.Truncate(time.Minute)) // 2024-01-15 19:34:00
    fmt.Println(t.Truncate(time.Second)) // 2024-01-15 19:34:56

    // Round (nearest)
    fmt.Println(t.Round(time.Hour))   // 2024-01-15 20:00:00
    fmt.Println(t.Round(time.Minute)) // 2024-01-15 19:35:00
    fmt.Println(t.Round(time.Second)) // 2024-01-15 19:34:56

    // Round to specific durations
    fmt.Println(t.Round(15 * time.Minute))  // Round to nearest 15 min
    fmt.Println(t.Round(5 * time.Minute))   // Round to nearest 5 min

    // Truncate to day
    dayStart := t.Truncate(24 * time.Hour)
    fmt.Println(dayStart)  // 2024-01-15 00:00:00
}

Database Integration

Storing Timestamps

package main

import (
    "database/sql"
    "fmt"
    "time"

    _ "github.com/lib/pq" // PostgreSQL driver
)

type Event struct {
    ID          int
    Title       string
    ScheduledAt time.Time
    Timezone    string
    CreatedAt   time.Time
}

func saveEvent(db *sql.DB, event Event) error {
    query := `
        INSERT INTO events (title, scheduled_at, timezone, created_at)
        VALUES ($1, $2, $3, $4)
        RETURNING id
    `

    // Store as UTC
    utcScheduled := event.ScheduledAt.UTC()
    utcCreated := time.Now().UTC()

    err := db.QueryRow(
        query,
        event.Title,
        utcScheduled,
        event.Timezone,
        utcCreated,
    ).Scan(&event.ID)

    return err
}

func findEvent(db *sql.DB, id int, timezone string) (*Event, error) {
    query := `
        SELECT id, title, scheduled_at, timezone, created_at
        FROM events
        WHERE id = $1
    `

    var event Event
    err := db.QueryRow(query, id).Scan(
        &event.ID,
        &event.Title,
        &event.ScheduledAt,
        &event.Timezone,
        &event.CreatedAt,
    )

    if err != nil {
        return nil, err
    }

    // Convert to requested timezone
    loc, err := time.LoadLocation(timezone)
    if err != nil {
        return nil, err
    }

    event.ScheduledAt = event.ScheduledAt.In(loc)
    event.CreatedAt = event.CreatedAt.In(loc)

    return &event, nil
}

func findEventsInRange(db *sql.DB, start, end time.Time) ([]Event, error) {
    query := `
        SELECT id, title, scheduled_at, timezone
        FROM events
        WHERE scheduled_at BETWEEN $1 AND $2
        ORDER BY scheduled_at ASC
    `

    rows, err := db.Query(query, start.UTC(), end.UTC())
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var events []Event
    for rows.Next() {
        var event Event
        err := rows.Scan(
            &event.ID,
            &event.Title,
            &event.ScheduledAt,
            &event.Timezone,
        )
        if err != nil {
            return nil, err
        }
        events = append(events, event)
    }

    return events, rows.Err()
}

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    // Create event
    nyc, _ := time.LoadLocation("America/New_York")
    event := Event{
        Title:       "Team Meeting",
        ScheduledAt: time.Date(2024, 1, 15, 14, 0, 0, 0, nyc),
        Timezone:    "America/New_York",
    }

    if err := saveEvent(db, event); err != nil {
        panic(err)
    }

    // Retrieve in user's timezone
    retrieved, err := findEvent(db, event.ID, "America/Los_Angeles")
    if err != nil {
        panic(err)
    }

    fmt.Printf("Event: %s at %s\n",
        retrieved.Title,
        retrieved.ScheduledAt.Format("2006-01-02 15:04:05 MST"),
    )
}

Testing Time-Dependent Code

Want to know a secret? The best Go developers don't call time.Now() directly in their business logic. They inject a clock. It sounds fancy, but it's actually super simple and makes testing a breeze.

Clock Interface Pattern (Your Testing Superpower)

package main

import (
    "testing"
    "time"
)

// Clock interface for testability
type Clock interface {
    Now() time.Time
}

// RealClock uses actual system time
type RealClock struct{}

func (RealClock) Now() time.Time {
    return time.Now()
}

// MockClock allows setting fixed time
type MockClock struct {
    current time.Time
}

func (m *MockClock) Now() time.Time {
    return m.current
}

func (m *MockClock) Set(t time.Time) {
    m.current = t
}

func (m *MockClock) Advance(d time.Duration) {
    m.current = m.current.Add(d)
}

// Service with injectable clock
type SessionService struct {
    clock Clock
}

func NewSessionService(clock Clock) *SessionService {
    return &SessionService{clock: clock}
}

func (s *SessionService) IsExpired(expiresAt time.Time) bool {
    return s.clock.Now().After(expiresAt)
}

// Tests
func TestSessionExpiry(t *testing.T) {
    // Create mock clock at specific time
    fixedTime := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)
    mockClock := &MockClock{current: fixedTime}

    service := NewSessionService(mockClock)

    // Session expires in 1 hour
    expiresAt := fixedTime.Add(time.Hour)

    // Should not be expired yet
    if service.IsExpired(expiresAt) {
        t.Error("Expected session to be valid")
    }

    // Advance 59 minutes (still valid)
    mockClock.Advance(59 * time.Minute)
    if service.IsExpired(expiresAt) {
        t.Error("Expected session to still be valid")
    }

    // Advance 2 more minutes (now expired)
    mockClock.Advance(2 * time.Minute)
    if !service.IsExpired(expiresAt) {
        t.Error("Expected session to be expired")
    }
}

func TestDSTTransition(t *testing.T) {
    nyc, _ := time.LoadLocation("America/New_York")
    mockClock := &MockClock{}

    // Before DST (EST)
    beforeDST := time.Date(2024, 3, 9, 12, 0, 0, 0, nyc)
    mockClock.Set(beforeDST)

    _, offset := mockClock.Now().Zone()
    if offset != -5*3600 {
        t.Errorf("Expected EST offset -5h, got %d", offset/3600)
    }

    // After DST (EDT)
    afterDST := time.Date(2024, 3, 11, 12, 0, 0, 0, nyc)
    mockClock.Set(afterDST)

    _, offset = mockClock.Now().Zone()
    if offset != -4*3600 {
        t.Errorf("Expected EDT offset -4h, got %d", offset/3600)
    }
}

// Production usage with real clock
func main() {
    service := NewSessionService(RealClock{})
    expiresAt := time.Now().Add(time.Hour)
    println(service.IsExpired(expiresAt))
}

Best Practices

1. Always Store UTC

// ✅ CORRECT: Store as UTC
func SaveEvent(db *sql.DB, title string, scheduledAt time.Time, tz string) error {
    // Convert to UTC for storage
    utc := scheduledAt.UTC()

    _, err := db.Exec(
        "INSERT INTO events (title, scheduled_at, timezone) VALUES (?, ?, ?)",
        title, utc, tz,
    )
    return err
}

// Display in user's timezone
func DisplayEvent(db *sql.DB, id int, userTz string) (string, error) {
    var scheduledAt time.Time
    var title string

    err := db.QueryRow("SELECT title, scheduled_at FROM events WHERE id = ?", id).
        Scan(&title, &scheduledAt)
    if err != nil {
        return "", err
    }

    loc, _ := time.LoadLocation(userTz)
    local := scheduledAt.In(loc)

    return fmt.Sprintf("%s at %s", title, local.Format("2006-01-02 15:04:05 MST")), nil
}

2. Use time.Time, Not Pointers

// ❌ BAD: Pointer to time
type Event struct {
    ScheduledAt *time.Time
}

// ✅ GOOD: Value type
type Event struct {
    ScheduledAt time.Time
}

// time.Time is safe to pass by value
// Zero value (time.Time{}) is useful
// Check with IsZero() if needed

3. Handle Timezones Explicitly

// ❌ BAD: Implicit local timezone
t := time.Now()  // Uses local timezone

// ✅ GOOD: Explicit timezone
utc := time.Now().UTC()
nyc, _ := time.LoadLocation("America/New_York")
nycTime := time.Now().In(nyc)

4. Use Clock Interface for Testing

// Make time testable
type Service struct {
    clock Clock
}

// Production uses real time
service := &Service{clock: RealClock{}}

// Tests use mock time
mockClock := &MockClock{current: fixedTime}
service := &Service{clock: mockClock}

Common Pitfalls

1. Comparing Times from Different Zones

// ❌ WRONG: Using == (checks representation, not moment)
utc := time.Date(2024, 1, 15, 19, 0, 0, 0, time.UTC)
nyc, _ := time.LoadLocation("America/New_York")
est := time.Date(2024, 1, 15, 14, 0, 0, 0, nyc)

fmt.Println(utc == est)  // false (different zones)

// ✅ CORRECT: Using Equal() (checks moment)
fmt.Println(utc.Equal(est))  // true (same moment)

2. Forgetting Nanosecond Precision

// time.Time has nanosecond precision
t1 := time.Unix(1705341600, 123456789)
t2 := time.Unix(1705341600, 987654321)

fmt.Println(t1.Unix())      // Same: 1705341600
fmt.Println(t1.Equal(t2))   // false (different nanoseconds)

3. Using Local Time in APIs

// ❌ BAD: Ambiguous
type Response struct {
    Timestamp time.Time `json:"timestamp"`
}

// ✅ GOOD: Always UTC, format as ISO 8601
type Response struct {
    Timestamp string `json:"timestamp"`
}

func NewResponse(t time.Time) Response {
    return Response{
        Timestamp: t.UTC().Format(time.RFC3339),
    }
}

Performance Tips

Avoid Repeated LoadLocation

// ❌ SLOW: Load timezone every iteration
for i := 0; i < 1000; i++ {
    loc, _ := time.LoadLocation("America/New_York")
    t := time.Now().In(loc)
}

// ✅ FAST: Load once
loc, _ := time.LoadLocation("America/New_York")
for i := 0; i < 1000; i++ {
    t := time.Now().In(loc)
}

Use time.After for Timeouts

select {
case result := <-ch:
    // Process result
case <-time.After(5 * time.Second):
    // Timeout
}

Wrapping Up: Go Time Patterns That Work

Look, I'll level with you. When I first saw Go's date format (remember: Mon Jan 2 15:04:05 MST 2006), I thought it was ridiculous. But you know what? After using it for a while, I actually prefer it. It's memorable, it's unambiguous, and you never have to look up whether it's %m or %M for minutes.

Your Go Time Essentials:

  • time.Time is your timestamp (value type, not pointer—this matters!)
  • time.Duration is how you measure, well, durations
  • time.Location keeps your timezones straight

The Patterns That Actually Work:

  1. UTC for storage—no compromises here
  2. Pass time.Time by value, not pointer (it's designed for this)
  3. Use time.Location explicitly—implicit is where bugs hide
  4. Equal() for comparisons (seriously, don't use ==)
  5. Clock interface pattern for testing (game-changer)
  6. Validate inputs early and often
  7. Cache those LoadLocation() calls (they're not cheap)

The beautiful thing about Go's time package? It's simple without being simplistic. Once you internalize the reference date and understand the value types, everything else just clicks. You'll be writing correct, timezone-aware code without thinking about it. And isn't that exactly what we want from our tools?

Further Reading


Building Go applications with datetime? Contact us for consultation.