Skip main navigation
/user/kayd @ devops :~$ cat how-to-create-time-change-function-bash.md

Timezones in Production: From Linux Commands to Go Timezones in Production: From Linux Commands to Go

QR Code linking to: Timezones in Production: From Linux Commands to Go
Karandeep Singh
Karandeep Singh
• 17 minutes

Summary

Timezone handling from command line to Go code — each step shows the Linux command first, then builds it in Go. Hit the DST parsing trap, fix it, normalize log timestamps, end with a multi-zone clock.

Every production incident involving timestamps comes down to one problem: timezones. Cron jobs fire an hour late after DST. Logs from different servers show different times for the same event. We will learn timezone handling the way production systems actually work — start with Linux commands, build the same thing in Go, and hit every trap along the way.

Prerequisites

  • A Linux system (native, WSL, or SSH)
  • Go 1.21+ installed

Step 1: What Timezone Is Your Server In?

First question on any server: what timezone is it running?

The Linux commands

timedatectl gives you everything at once:

timedatectl

Expected output:

               Local time: Sat 2026-02-15 13:30:05 EST
           Universal time: Sat 2026-02-15 18:30:05 UTC
                 RTC time: Sat 2026-02-15 18:30:05
                Time zone: America/New_York (EST, -0500)
System clock synchronized: yes
              NTP service: active
          RTC in local TZ: no

The key fields: your timezone is America/New_York, the offset is -0500 (five hours behind UTC), and NTP is syncing your clock.

Now compare local time and UTC:

date
# Sat Feb 15 13:30:05 EST 2026

date -u
# Sat Feb 15 18:30:05 UTC 2026

Where does Linux actually store the timezone? Two places:

cat /etc/timezone
# America/New_York

ls -la /etc/localtime
# /etc/localtime -> /usr/share/zoneinfo/America/New_York

The timezone is a symlink to a binary file in /usr/share/zoneinfo/. That directory has every timezone in the IANA timezone database. This matters for Go too — Go reads these same files.

Build it in Go

Write a program that prints the same information — current time, timezone name, and UTC offset.

package main

import (
	"fmt"
	"time"
)

func main() {
	now := time.Now()
	zone, offset := now.Zone()

	fmt.Println("Current time:", now.Format("2006-01-02 15:04:05"))
	fmt.Println("Timezone:", zone)
	fmt.Printf("UTC offset: %+d seconds (%+d hours)\n", offset, offset/3600)
	fmt.Println("UTC time:", now.UTC().Format("2006-01-02 15:04:05"))
}

Expected output:

Current time: 2026-02-15 13:30:05
Timezone: EST
UTC offset: -18000 seconds (-5 hours)
UTC time: 2026-02-15 18:30:05

time.Now() picks up the system timezone automatically. Zone() returns the abbreviation and the offset in seconds.

The bug

Now try parsing a timestamp string. Imagine a log entry says 2026-03-08 02:30:00 and you want to parse it:

package main

import (
	"fmt"
	"time"
)

func main() {
	raw := "2026-03-08 02:30:00"
	t, err := time.Parse("2006-01-02 15:04:05", raw)
	if err != nil {
		fmt.Println("Parse error:", err)
		return
	}

	zone, offset := t.Zone()
	fmt.Println("Parsed time:", t)
	fmt.Println("Timezone:", zone)
	fmt.Printf("UTC offset: %d\n", offset)
}

Expected output:

Parsed time: 2026-03-08 02:30:00 +0000 UTC
Timezone: UTC
UTC offset: 0

The timestamp came from a server in EST. But time.Parse returned UTC. It did not attach your local timezone. It did not even try. time.Parse always defaults to UTC when the input string has no timezone info.

This is wrong — but we will fix it in Step 3. For now, remember: time.Parse does not care about your system timezone.

Step 2: Converting Between Timezones

Your monitoring system shows an alert at 18:30 UTC. Your team in New York wants to know: what time was that locally? Your team in Mumbai asks the same question.

The Linux commands

The TZ environment variable overrides the timezone for a single command:

TZ="America/New_York" date
# Sat Feb 15 13:30:05 EST 2026

TZ="Asia/Kolkata" date
# Sat Feb 15 23:00:05 IST 2026

TZ="Asia/Tokyo" date
# Sun Feb 16 03:30:05 JST 2026

Same moment in time, three different local clocks.

To see when DST transitions happen in a specific timezone, use zdump:

zdump -v America/New_York | grep 2026

Expected output:

America/New_York  Sun Mar  8 06:59:59 2026 UT = Sun Mar  8 01:59:59 2026 EST isdst=0
America/New_York  Sun Mar  8 07:00:00 2026 UT = Sun Mar  8 03:00:00 2026 EDT isdst=1
America/New_York  Sun Nov  1 05:59:59 2026 UT = Sun Nov  1 01:59:59 2026 EDT isdst=1
America/New_York  Sun Nov  1 06:00:00 2026 UT = Sun Nov  1 01:00:00 2026 EST isdst=0

Read this carefully. On March 8, 2026 at 07:00 UTC, Eastern time jumps from 01:59:59 EST straight to 03:00:00 EDT. The hour from 2:00 to 2:59 does not exist. This is spring forward.

Build it in Go

Load timezones with time.LoadLocation and convert:

package main

import (
	"fmt"
	"log"
	"time"
)

func main() {
	zones := []string{
		"UTC",
		"America/New_York",
		"Europe/London",
		"Asia/Kolkata",
		"Asia/Tokyo",
	}

	now := time.Now().UTC()
	fmt.Printf("%-16s │ %s\n", "Zone", "Local Time")
	fmt.Println("─────────────────┼──────────────────────────")

	for _, z := range zones {
		loc, err := time.LoadLocation(z)
		if err != nil {
			log.Fatalf("bad timezone %q: %v", z, err)
		}
		local := now.In(loc)
		zone, _ := local.Zone()
		fmt.Printf("%-16s │ %s %s\n", z, local.Format("2006-01-02 15:04:05"), zone)
	}
}

Expected output:

Zone             │ Local Time
─────────────────┼──────────────────────────
UTC              │ 2026-02-15 18:30:05 UTC
America/New_York │ 2026-02-15 13:30:05 EST
Europe/London    │ 2026-02-15 18:30:05 GMT
Asia/Kolkata     │ 2026-02-16 00:00:05 IST
Asia/Tokyo       │ 2026-02-16 03:30:05 JST

The bug

Try loading a timezone with its abbreviation:

package main

import (
	"fmt"
	"log"
	"time"
)

func main() {
	// Use abbreviation instead of IANA name
	est, err := time.LoadLocation("EST")
	if err != nil {
		log.Fatal(err)
	}

	// A summer date — should be EDT (-4), not EST (-5)
	summer := time.Date(2026, 7, 15, 12, 0, 0, 0, est)
	zone, offset := summer.Zone()
	fmt.Printf("Using \"EST\": %s (zone=%s, offset=%d)\n", summer, zone, offset/3600)

	// Now try with the IANA name
	ny, err := time.LoadLocation("America/New_York")
	if err != nil {
		log.Fatal(err)
	}

	summerNY := time.Date(2026, 7, 15, 12, 0, 0, 0, ny)
	zone2, offset2 := summerNY.Zone()
	fmt.Printf("Using \"America/New_York\": %s (zone=%s, offset=%d)\n", summerNY, zone2, offset2/3600)
}

Expected output:

Using "EST": 2026-07-15 12:00:00 -0500 EST (zone=EST, offset=-5)
Using "America/New_York": 2026-07-15 12:00:00 -0400 EDT (zone=EDT, offset=-4)

"EST" is a fixed offset. It is always -5, all year. It does not know about DST. On July 15, Eastern time should be EDT (-4 hours). But "EST" still says -5. That is a one-hour error.

"America/New_York" is the IANA timezone name. It knows about DST. On July 15, it correctly reports EDT (-4 hours).

The fix

Always use IANA timezone names. Never use abbreviations.

WrongRight
ESTAmerica/New_York
PSTAmerica/Los_Angeles
ISTAsia/Kolkata
CSTAmerica/Chicago
JSTAsia/Tokyo

The IANA names are the same strings stored in /usr/share/zoneinfo/ on Linux. They are portable across systems.

Step 3: The DST Parsing Trap

This is the trap that causes real production bugs. March 8, 2026 is DST spring-forward day in the US. At 2:00 AM, clocks jump to 3:00 AM. The hour from 2:00 to 2:59 does not exist.

The Linux command

Try creating a time that falls in the gap:

TZ="America/New_York" date -d "2026-03-08 02:30:00"

Depending on your system, this might output Sun Mar 8 03:30:00 EDT 2026 or throw an error. GNU date silently pushes it forward.

Build it in Go — fix the Step 1 bug

Remember in Step 1, time.Parse returned UTC? Here is the fix: use time.ParseInLocation.

package main

import (
	"fmt"
	"log"
	"time"
)

func main() {
	ny, err := time.LoadLocation("America/New_York")
	if err != nil {
		log.Fatal(err)
	}

	raw := "2026-03-08 02:30:00"
	layout := "2006-01-02 15:04:05"

	// The fix from Step 1: use ParseInLocation
	t, err := time.ParseInLocation(layout, raw, ny)
	if err != nil {
		fmt.Println("Parse error:", err)
		return
	}

	fmt.Println("Input string:", raw)
	fmt.Println("Parsed time: ", t)
	fmt.Println("In UTC:      ", t.UTC())

	zone, offset := t.Zone()
	fmt.Printf("Zone: %s, Offset: %d hours\n", zone, offset/3600)
}

Expected output:

Input string: 2026-03-08 02:30:00
Parsed time:  2026-03-08 03:30:00 -0400 EDT
In UTC:       2026-03-08 07:30:00 +0000 UTC
Zone: EDT, Offset: -4 hours

Read that output carefully. We asked for 02:30. Go gave us 03:30 EDT. No error. No warning. It silently pushed the time forward by one hour because 02:30 does not exist on that day.

This is the DST parsing trap. Your log says 02:30. Go says 03:30. In production, this means log entries appear at the wrong time. Correlation across services breaks. You spend hours figuring out why event A seems to happen after event B when it really happened before.

Detect the DST gap

Write a function that checks if a time falls in the DST gap:

package main

import (
	"fmt"
	"log"
	"time"
)

// isDSTGap checks if the given time string falls in a DST transition gap.
// If Go silently adjusted the time, the round-trip will not match the input.
func isDSTGap(layout, value string, loc *time.Location) bool {
	t, err := time.ParseInLocation(layout, value, loc)
	if err != nil {
		return false
	}
	// Format the parsed time back using the same layout
	roundTrip := t.Format(layout)
	return roundTrip != value
}

func main() {
	ny, err := time.LoadLocation("America/New_York")
	if err != nil {
		log.Fatal(err)
	}

	layout := "2006-01-02 15:04:05"

	tests := []string{
		"2026-03-08 01:30:00", // Before spring forward — exists
		"2026-03-08 02:00:00", // Start of gap — does not exist
		"2026-03-08 02:30:00", // Middle of gap — does not exist
		"2026-03-08 02:59:59", // End of gap — does not exist
		"2026-03-08 03:00:00", // After spring forward — exists
		"2026-03-08 03:30:00", // After spring forward — exists
	}

	for _, ts := range tests {
		gap := isDSTGap(layout, ts, ny)
		parsed, _ := time.ParseInLocation(layout, ts, ny)
		status := "OK"
		if gap {
			status = fmt.Sprintf("DST GAP - adjusted to %s", parsed.Format(layout))
		}
		fmt.Printf("  %s  →  %s\n", ts, status)
	}
}

Expected output:

  2026-03-08 01:30:00  →  OK
  2026-03-08 02:00:00  →  DST GAP - adjusted to 2026-03-08 03:00:00
  2026-03-08 02:30:00  →  DST GAP - adjusted to 2026-03-08 03:30:00
  2026-03-08 02:59:59  →  DST GAP - adjusted to 2026-03-08 03:59:59
  2026-03-08 03:00:00  →  OK
  2026-03-08 03:30:00  →  OK

The trick: parse the time, format it back to a string, compare with the original. If they do not match, Go adjusted it, which means the original fell in a DST gap.

Step 4: Parsing Real Log Timestamps

Production logs do not come in one format. Every service uses its own timestamp format. You need to parse all of them.

The Linux commands — log formats

Here are the four most common formats you will see:

Syslog (/var/log/syslog):

Feb 15 14:30:05 server1 sshd[1234]: Accepted publickey

ISO 8601 (most modern applications):

2026-02-15T14:30:05-05:00

Nginx (/var/log/nginx/access.log):

172.16.0.1 - - [15/Feb/2026:14:30:05 +0000] "GET /api/health HTTP/1.1" 200

Docker JSON (docker logs --details):

{"log":"request handled","stream":"stdout","time":"2026-02-15T14:30:05.123456789Z"}

Build it in Go

A multi-format log timestamp parser that tries each format and returns the first one that works:

package main

import (
	"fmt"
	"time"
)

// parseTimestamp tries multiple formats and returns the first match.
func parseTimestamp(raw string) (time.Time, string, error) {
	formats := []struct {
		name   string
		layout string
	}{
		{"ISO 8601", time.RFC3339},
		{"ISO 8601 Nano", time.RFC3339Nano},
		{"Nginx", "02/Jan/2006:15:04:05 -0700"},
		{"Syslog", "Jan 2 15:04:05"},
		{"Syslog (padded)", "Jan  2 15:04:05"},
		{"Simple", "2006-01-02 15:04:05"},
	}

	for _, f := range formats {
		t, err := time.Parse(f.layout, raw)
		if err == nil {
			return t, f.name, nil
		}
	}

	return time.Time{}, "", fmt.Errorf("no format matched: %q", raw)
}

func main() {
	samples := []string{
		"2026-02-15T14:30:05-05:00",
		"2026-02-15T14:30:05.123456789Z",
		"15/Feb/2026:14:30:05 +0000",
		"Feb 15 14:30:05",
		"2026-02-15 14:30:05",
	}

	for _, s := range samples {
		t, format, err := parseTimestamp(s)
		if err != nil {
			fmt.Printf("FAIL: %s\n", err)
			continue
		}
		fmt.Printf("%-40s  format=%-16s  parsed=%s\n", s, format, t)
	}
}

Expected output:

2026-02-15T14:30:05-05:00                 format=ISO 8601         parsed=2026-02-15 14:30:05 -0500 EST
2026-02-15T14:30:05.123456789Z            format=ISO 8601 Nano    parsed=2026-02-15 14:30:05.123456789 +0000 UTC
15/Feb/2026:14:30:05 +0000                format=Nginx            parsed=2026-02-15 14:30:05 +0000 UTC
Feb 15 14:30:05                           format=Syslog           parsed=0000-02-15 14:30:05 +0000 UTC
2026-02-15 14:30:05                       format=Simple           parsed=2026-02-15 14:30:05 +0000 UTC

The bug

Look at the syslog line. The year is 0000. Syslog format does not include a year. Go’s time.Parse sets missing fields to zero, so the year becomes year 0.

If you try to calculate the time difference between this timestamp and now, you get a duration of about 2026 years. Not useful.

The fix

After parsing, check if the year is zero and fill it in:

package main

import (
	"fmt"
	"time"
)

func parseTimestamp(raw string) (time.Time, string, error) {
	formats := []struct {
		name   string
		layout string
	}{
		{"ISO 8601", time.RFC3339},
		{"ISO 8601 Nano", time.RFC3339Nano},
		{"Nginx", "02/Jan/2006:15:04:05 -0700"},
		{"Syslog", "Jan 2 15:04:05"},
		{"Syslog (padded)", "Jan  2 15:04:05"},
		{"Simple", "2006-01-02 15:04:05"},
	}

	for _, f := range formats {
		t, err := time.Parse(f.layout, raw)
		if err == nil {
			// Fix: if year is zero, use current year
			if t.Year() == 0 {
				now := time.Now()
				t = t.AddDate(now.Year(), 0, 0)
			}
			return t, f.name, nil
		}
	}

	return time.Time{}, "", fmt.Errorf("no format matched: %q", raw)
}

func main() {
	t, format, err := parseTimestamp("Feb 15 14:30:05")
	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Printf("Input:  Feb 15 14:30:05\n")
	fmt.Printf("Format: %s\n", format)
	fmt.Printf("Parsed: %s\n", t.Format("2006-01-02 15:04:05 MST"))
}

Expected output:

Input:  Feb 15 14:30:05
Format: Syslog
Parsed: 2026-02-15 14:30:05 UTC

Now the year is 2026 instead of 0000. One edge case to think about: if it is January 1 and you parse a syslog timestamp from December 31, time.Now().Year() would give you the wrong year. For production systems, you would also check if the parsed date is in the future and subtract a year if so.

Step 5: Normalize All Timestamps to UTC

When you have logs from servers in different timezones, the only way to correlate them is to convert everything to UTC. This is the rule every SRE team learns eventually: store UTC, display local.

The Linux command

Convert any timestamp to UTC:

date -u -d "2026-02-15 14:30:00 EST"
# Sun Feb 15 19:30:00 UTC 2026

date -u -d "2026-02-15 14:30:00 IST"
# Sun Feb 15 09:00:00 UTC 2026

Same clock reading, different source timezones, different UTC results. This is why you always need to know the source timezone.

Build it in Go

Build a log normalizer that reads lines from stdin, finds timestamps, and rewrites them in UTC:

package main

import (
	"bufio"
	"flag"
	"fmt"
	"log"
	"os"
	"strings"
	"time"
)

func parseTimestamp(raw string) (time.Time, string, error) {
	formats := []struct {
		name   string
		layout string
	}{
		{"ISO 8601", time.RFC3339},
		{"ISO 8601 Nano", time.RFC3339Nano},
		{"Nginx", "02/Jan/2006:15:04:05 -0700"},
		{"Syslog", "Jan 2 15:04:05"},
		{"Syslog (padded)", "Jan  2 15:04:05"},
	}

	for _, f := range formats {
		t, err := time.Parse(f.layout, raw)
		if err == nil {
			if t.Year() == 0 {
				t = t.AddDate(time.Now().Year(), 0, 0)
			}
			return t, f.name, nil
		}
	}

	return time.Time{}, "", fmt.Errorf("no format matched")
}

func normalizeToUTC(t time.Time) string {
	return t.UTC().Format(time.RFC3339)
}

func main() {
	sourceTZ := flag.String("tz", "", "source timezone for timestamps without zone info (e.g. America/New_York)")
	flag.Parse()

	var loc *time.Location
	if *sourceTZ != "" {
		var err error
		loc, err = time.LoadLocation(*sourceTZ)
		if err != nil {
			log.Fatalf("bad timezone %q: %v", *sourceTZ, err)
		}
	}

	scanner := bufio.NewScanner(os.Stdin)
	for scanner.Scan() {
		line := scanner.Text()

		// Try to find and replace a syslog-style timestamp at the start
		// Format: "Feb 15 14:30:05" (15 characters)
		if len(line) >= 15 {
			candidate := line[:15]
			t, name, err := parseTimestamp(candidate)
			if err == nil && name == "Syslog" || name == "Syslog (padded)" {
				if loc != nil {
					// Reparse in the specified timezone
					t = time.Date(t.Year(), t.Month(), t.Day(),
						t.Hour(), t.Minute(), t.Second(),
						t.Nanosecond(), loc)
				}
				utc := normalizeToUTC(t)
				fmt.Println(utc + line[15:])
				continue
			}
		}

		// Try to find an ISO 8601 timestamp
		for i := 0; i <= len(line)-25; i++ {
			candidate := line[i:]
			if idx := strings.Index(candidate, "T"); idx >= 4 && idx <= 10 {
				end := idx + 15
				if end > len(candidate) {
					continue
				}
				// Try progressively longer substrings
				for end <= len(candidate) {
					t, _, err := parseTimestamp(candidate[:end])
					if err == nil {
						utc := normalizeToUTC(t)
						fmt.Println(line[:i] + utc + line[i+end:])
						goto nextLine
					}
					end++
					if end-idx > 40 {
						break
					}
				}
			}
			break
		}

		// No timestamp found — print line as-is
		fmt.Println(line)
	nextLine:
	}
}

Run it on some sample input:

echo 'Feb 15 14:30:05 server1 sshd[1234]: connection accepted' | go run main.go -tz "America/New_York"

Expected output:

2026-02-15T19:30:05Z server1 sshd[1234]: connection accepted

The bug

Without the -tz flag, syslog timestamps have no timezone info. The normalizer assumes UTC:

echo 'Feb 15 14:30:05 server1 sshd[1234]: connection accepted' | go run main.go

Expected output:

2026-02-15T14:30:05Z server1 sshd[1234]: connection accepted

It says 14:30 UTC. But if the server is in New York (EST), the real UTC time is 19:30. That is a five-hour error. Correlating this log with a log from a UTC server will put events in the wrong order.

The fix

The -tz flag we already built is the fix. When a timestamp has no timezone information, you tell the tool where the log came from:

echo 'Feb 15 14:30:05 server1 sshd[1234]: connection accepted' | go run main.go -tz "America/New_York"

Expected output:

2026-02-15T19:30:05Z server1 sshd[1234]: connection accepted

Now it correctly shows 19:30 UTC. The lesson: timestamps without timezone info are ambiguous. You must know where they came from. This is why the best practice is to configure all your servers to log in UTC in the first place. Set TZ=UTC in your service files and you never have this problem.

Step 6: Build a Multi-Zone Dashboard

Let us put it all together. Build a live multi-timezone clock that a DevOps team can keep on a monitor. It shows current time in key zones, refreshing every second, with color coding for business hours.

package main

import (
	"fmt"
	"log"
	"os"
	"os/signal"
	"time"
)

type zone struct {
	label string
	name  string
	loc   *time.Location
}

func loadZones() []zone {
	entries := []struct {
		label string
		name  string
	}{
		{"UTC", "UTC"},
		{"New York", "America/New_York"},
		{"London", "Europe/London"},
		{"Mumbai", "Asia/Kolkata"},
		{"Tokyo", "Asia/Tokyo"},
	}

	zones := make([]zone, len(entries))
	for i, e := range entries {
		loc, err := time.LoadLocation(e.name)
		if err != nil {
			log.Fatalf("bad timezone %q: %v", e.name, err)
		}
		zones[i] = zone{label: e.label, name: e.name, loc: loc}
	}
	return zones
}

func hourStatus(hour int) (string, string) {
	switch {
	case hour >= 9 && hour < 17:
		return "\033[32m", "Business Hours" // Green
	case (hour >= 7 && hour < 9) || (hour >= 17 && hour < 20):
		return "\033[33m", "Early/Late" // Yellow
	default:
		return "\033[31m", "Overnight" // Red
	}
}

func main() {
	zones := loadZones()
	reset := "\033[0m"

	// Handle Ctrl+C gracefully
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt)
	go func() {
		<-sig
		// Move cursor below the dashboard and reset colors
		fmt.Printf("\033[%dB\n%s", len(zones)+3, reset)
		os.Exit(0)
	}()

	// Print header once
	fmt.Println("Production Timezone Dashboard (Ctrl+C to stop)")
	fmt.Println("────────────────────────────────────────────────────")

	for {
		// Move cursor to start of zone lines
		for range zones {
			fmt.Print("\033[K") // Clear line
		}
		fmt.Printf("\033[%dA", len(zones)) // Move up

		now := time.Now().UTC()
		for _, z := range zones {
			local := now.In(z.loc)
			hour := local.Hour()
			color, status := hourStatus(hour)

			fmt.Printf("\033[K%-12s │ %s  %s■ %s%s\n",
				z.label,
				local.Format("15:04:05"),
				color,
				status,
				reset,
			)
		}

		fmt.Print("\033[K────────────────────────────────────────────────────")

		time.Sleep(1 * time.Second)
		fmt.Print("\r")
	}
}

Expected output:

Production Timezone Dashboard (Ctrl+C to stop)
────────────────────────────────────────────────────
UTC          │ 19:30:05  ■ Business Hours
New York     │ 14:30:05  ■ Business Hours
London       │ 19:30:05  ■ Business Hours
Mumbai       │ 01:00:05  ■ Overnight
Tokyo        │ 04:30:05  ■ Overnight
────────────────────────────────────────────────────

Green means someone is in the office (9-17). Yellow means early morning or evening (7-9, 17-20). Red means overnight. When you need to page someone, check the dashboard first.

Build and run it:

go build -o tz-dashboard .
./tz-dashboard

The clock updates every second. Press Ctrl+C to stop.

What We Built

Here is what we covered, step by step:

  1. timedatectl / date → Go time zones — learned that time.Parse defaults to UTC, not your local timezone
  2. TZ variable / zdump → Go timezone conversion — learned that “EST” is a fixed offset and “America/New_York” is the real IANA name with DST transitions
  3. DST spring-forward → Go silent time adjustment — learned that Go silently pushes nonexistent times forward without any error
  4. Log formats → Go multi-format parser — learned that syslog has no year and time.Parse gives you year 0000
  5. date -u → Go UTC normalizer with -tz flag — learned that timestamps without zone info are ambiguous
  6. Live dashboard → Go multi-zone clock with business hours — put it all together

Cheat Sheet

Linux Commands

timedatectl                           # Full timezone status
date                                  # Local time
date -u                               # UTC time
TZ="America/New_York" date            # Time in a specific zone
zdump -v America/New_York | grep 2026 # DST transitions for a zone
cat /etc/timezone                     # System timezone name
date -u -d "14:30 EST"               # Convert to UTC

Go Patterns

// Load a timezone — always use IANA names
loc, err := time.LoadLocation("America/New_York")

// Convert to a timezone
local := t.In(loc)

// Parse with timezone — never use time.Parse for local times
t, err := time.ParseInLocation(layout, value, loc)

// Normalize to UTC
utc := t.UTC()

// Get zone name and offset
zone, offset := t.Zone()

// Detect DST gap (round-trip check)
parsed, _ := time.ParseInLocation(layout, value, loc)
if parsed.Format(layout) != value {
    // original time fell in DST gap
}

The Rules

  1. Store UTC, display local. Every timestamp in your database, logs, and messages should be UTC.
  2. Always use IANA names. "America/New_York", never "EST".
  3. Always use time.ParseInLocation. Plain time.Parse gives you UTC regardless of your system timezone.
  4. Check for DST gaps. Go silently adjusts nonexistent times. Use the round-trip check if you need to detect this.
  5. Know your source timezone. A timestamp without timezone info is ambiguous. The -tz flag pattern solves this.

Keep Reading

Question

What timezone bugs have bitten you in production? The ones where logs showed the wrong time and you spent hours figuring out why?

Similar Articles

More from devops