Build a log aggregator in Go from scratch. Tail files with inotify, survive log rotation, parse …
Timezones in Production: From Linux Commands to Go Timezones in Production: From Linux Commands to Go

Summary
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.
Deepen your understanding in Remote Server Configuration: From SSH Loops to a Go Config Tool
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.
| Wrong | Right |
|---|---|
EST | America/New_York |
PST | America/Los_Angeles |
IST | Asia/Kolkata |
CST | America/Chicago |
JST | Asia/Tokyo |
The IANA names are the same strings stored in /usr/share/zoneinfo/ on Linux. They are portable across systems.
Explore this further in Terraform From Scratch: Provision AWS Infrastructure Step by Step
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.
Discover related concepts in Terraform From Scratch: Provision AWS Infrastructure Step by Step
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.
Uncover more details in Terraform From Scratch: Provision AWS Infrastructure Step by Step
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.
Journey deeper into this topic with Terraform From Scratch: Provision AWS Infrastructure Step by Step
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.
Enrich your learning with CPU Monitoring: From Linux Commands to a Go Dashboard
What We Built
Here is what we covered, step by step:
Gain comprehensive insights from Deploy Jenkins on Amazon EKS: Complete Tutorial for Pods and Deployments
- timedatectl / date → Go time zones — learned that
time.Parsedefaults to UTC, not your local timezone - 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
- DST spring-forward → Go silent time adjustment — learned that Go silently pushes nonexistent times forward without any error
- Log formats → Go multi-format parser — learned that syslog has no year and
time.Parsegives you year 0000 - date -u → Go UTC normalizer with
-tzflag — learned that timestamps without zone info are ambiguous - 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
- Store UTC, display local. Every timestamp in your database, logs, and messages should be UTC.
- Always use IANA names.
"America/New_York", never"EST". - Always use
time.ParseInLocation. Plaintime.Parsegives you UTC regardless of your system timezone. - Check for DST gaps. Go silently adjusts nonexistent times. Use the round-trip check if you need to detect this.
- Know your source timezone. A timestamp without timezone info is ambiguous. The
-tzflag pattern solves this.
Keep Reading
- Config Templating: From envsubst to Go — use Go’s text/template for config files, the same template engine used here for timezone dashboards.
- Bulletproof Bash Scripts: Mastering Error Handling — handle errors properly in the bash scripts that call these timezone commands.
- Nginx Log Analysis: From grep to a Go Log Parser — parse timestamps in real log files using the multi-format parser from this article.
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
Related Content
More from devops
Learn Terraform with AWS from scratch. Start with a single S3 bucket, hit real errors, fix them, …
Learn nginx log analysis step by step — start with grep and awk one-liners for quick answers, then …
You Might Also Like
Learn AWS automation step by step. Start with AWS CLI commands for S3, EC2, and IAM, then build the …
