Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 074 / 100

Working with time in Go: real-world lessons and examples

Durations, time zones and formatting.

Expona Admin7 min read

Working with time in Go: real-world lessons and examples

Time handling in production systems is deceptively complex. We've all debugged mysterious test failures at midnight, shipped features that broke across daylight saving transitions, or spent hours tracking down a timezone bug in reporting. Working with time in Go: real-world lessons and examples gives us the patterns and mental models that prevent these disasters.

Why time handling matters in production

Time operations touch nearly every part of a production system: scheduling background jobs, tracking request latency, storing audit trails, calculating SLAs, and handling time-sensitive business logic. A timezone mistake in one service can cascade into incorrect billing, missed notifications, or compliance violations. Go's time package is well-designed, but it requires discipline and clear conventions to use correctly across a team and codebase.

time.Duration for intervals and elapsed measurements

The time.Duration type represents a signed time interval measured in nanoseconds. This is your primary tool for measuring elapsed time, setting timeouts, and managing intervals. Unlike raw integers, a Duration is type-safe and self-documenting.

When you need to know how long an operation took, use time.Since(). This is far more readable and less error-prone than manually tracking start and end times yourself:

go
func processPayment(orderID string) error {
	start := time.Now()
	defer func() {
		elapsed := time.Since(start)
		log.Printf("Payment processing took %v", elapsed)
	}()

	// Process payment...
	return nil
}

Durations shine when setting timeouts and deadlines. Rather than computing milliseconds by hand, use the named constructors:

go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Or for a deadline:
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()

In real-world APIs, you'll often need to configure durations via environment variables or config files. Parse them explicitly rather than guessing units:

go
// In your config
type Config struct {
	RequestTimeout   time.Duration
	CacheTTL         time.Duration
}

// Parse from env
func loadConfig() *Config {
	timeout, _ := time.ParseDuration(os.Getenv("REQUEST_TIMEOUT")) // "30s"
	ttl, _ := time.ParseDuration(os.Getenv("CACHE_TTL"))           // "5m"
	
	return &Config{
		RequestTimeout: timeout,
		CacheTTL:       ttl,
	}
}

We've found that storing durations as strings in config files and parsing them at startup prevents off-by-one errors in unit conversions. Your teammate reading the config sees 5m, not 300000000000.

Store UTC, always

This is the cardinal rule: store timestamps in UTC. Never store local time. Never store a time alongside a timezone string hoping to reconstruct it later. Always UTC.

In your database schema, timestamps should be TIMESTAMP WITH TIME ZONE (PostgreSQL) or the UTC-aware equivalent. In Go, always use time.Time values in UTC:

go
type Order struct {
	ID        string
	CreatedAt time.Time // Always UTC
	UpdatedAt time.Time // Always UTC
}

// When storing:
order := &Order{
	ID:        "ord_123",
	CreatedAt: time.Now().UTC(),
	UpdatedAt: time.Now().UTC(),
}

// Persist to database
db.Create(order)

PostgreSQL and most ORMs (like GORM) handle this automatically: when you write a time.Time to a TIMESTAMP WITH TIME ZONE column, it stores UTC and returns UTC when you read it back. The database is your source of truth.

Display to users in their local timezone only at the presentation layer:

go
func formatOrderTime(t time.Time, userTZ string) string {
	loc, _ := time.LoadLocation(userTZ)
	localTime := t.In(loc)
	return localTime.Format("2006-01-02 15:04 MST")
}

This separation—UTC in the database and APIs, local time only in UI—prevents timezone bugs from spreading. We've seen teams store local time, discover a user is in the wrong timezone, then have to backfill production data. Store UTC from day one.

Layouts use the reference date 2006-01-02

Go's time formatting is unusual compared to other languages. Instead of format strings like %Y-%m-%d, Go uses a reference date: January 2, 2006, 15:04:05 MST (UTC-0700). Whatever pattern you write using those specific values becomes your format layout.

This takes getting used to, but it's actually more readable once you internalize it:

go
// ISO 8601 date
const ISO8601 = "2006-01-02T15:04:05Z07:00"
t := time.Now().UTC()
fmt.Println(t.Format(ISO8601)) // 2024-01-15T14:30:45Z

// US format: Jan 2, 2006
const USFormat = "01/02/2006"
fmt.Println(t.Format(USFormat)) // 01/15/2024

// Parsing: layout must match structure
dateStr := "2024-01-15"
parsed, _ := time.Parse("2006-01-02", dateStr)
fmt.Println(parsed) // 2024-01-15 00:00:00 +0000 UTC

A common gotcha: forgetting that the layout is positional. "2006-01-02" is year-month-day. "01-02-2006" is month-day-year. If your layout and input don't align, parsing fails silently or produces wrong results.

For APIs, standardize on RFC 3339 (ISO 8601 with timezone). Go provides a constant:

go
// Serialize to API response
resp := map[string]interface{}{
	"created_at": time.Now().UTC().Format(time.RFC3339),
}

// Expected output: "2024-01-15T14:30:45Z"

Define layouts as package-level constants so your whole codebase uses the same formats:

go
const (
	TimestampLayout = time.RFC3339           // "2006-01-02T15:04:05Z07:00"
	DateOnly        = "2006-01-02"
	TimeOnly        = "15:04:05"
)

Use time.Since for elapsed time

We mentioned time.Since() earlier, but it deserves emphasis. Use it instead of manually subtracting times. It's clearer, handles nanosecond precision, and is idiomatic Go:

go
// Good
start := time.Now()
doWork()
elapsed := time.Since(start)

// Avoid
start := time.Now()
doWork()
elapsed := time.Now().Sub(start) // Redundant call to time.Now()

In production systems, measuring elapsed time accurately is crucial for observability. Use it for request latencies, cache hit timing, and database query duration:

go
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	defer func() {
		duration := time.Since(start)
		h.metrics.RecordRequestLatency("GetUser", duration)
	}()

	userID := r.URL.Query().Get("id")
	user, err := h.db.GetUser(r.Context(), userID)
	if err != nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}

	json.NewEncoder(w).Encode(user)
}

For background jobs and periodic tasks, combine time.Ticker with time.Since to track job duration and next run time:

go
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()

for range ticker.C {
	start := time.Now()
	err := syncData()
	duration := time.Since(start)
	
	if err != nil {
		log.Printf("Sync failed after %v: %v", duration, err)
	} else {
		log.Printf("Sync completed in %v", duration)
	}
}

Common mistakes

Forgetting to call UTC(): time.Now() returns local time. Always call .UTC() before storing. A helper function prevents this oversight:

go
func nowUTC() time.Time {
	return time.Now().UTC()
}

Parsing without checking error: time.Parse() can fail silently if your layout doesn't match. Always handle the error, even in tests:

go
t, err := time.Parse("2006-01-02", "invalid")
if err != nil {
	log.Fatal(err)
}

Comparing times with different timezones: Go's time comparison works correctly because time.Time stores both the instant and the location. But displaying and parsing across timezones remains error-prone. Stick to UTC for logic, convert only for display.

Using time.Sleep in tests: Tests that sleep are fragile and slow. Use fake clocks or context deadlines instead. If you must measure elapsed time in tests, use time.Since() and assert on duration ranges, not exact values.

Storing time strings in JSON without RFC 3339: Custom timestamps in JSON responses cause parsing headaches downstream. Stick to RFC 3339; it's language-agnostic and widely supported.

Key takeaways

  • Use time.Duration for intervals and always use time.Since() to measure elapsed time—it's clearer and avoids mistakes.
  • Store all timestamps in UTC. Convert to local timezone only at the presentation layer, never in business logic or storage.
  • Understand Go's reference date layout system (2006-01-02). Define format constants across your codebase and default to RFC 3339 for APIs.
  • Call .UTC() when capturing the current time before storing. Make this automatic with a helper function if your team needs the reminder.
  • Test time-dependent code using context deadlines and fake clocks rather than time.Sleep().
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles