Skip to main content
โšก Calmops

Working with Dates and Times: Time Zones and Localization

Introduction

Date and time handling is notoriously difficult in software development. Between time zones, Daylight Saving Time (DST) transitions, leap years, leap seconds, and different cultural conventions, there’s enormous complexity lurking in what seems like a simple concept. Get it wrong, and you’ll have users puzzled by “appointments” at 2 AM or payments that “disappeared” for an hour.

This comprehensive guide covers everything you need to handle dates and times correctlyโ€”from storing timestamps to displaying localized formats, from handling DST transitions to managing recurring events.

The Golden Rule

Store UTC. Display local.

This single principle solves 90% of datetime problems. Store everything in UTC, convert to local time only when displaying to users.

Time Zone Fundamentals

Understanding Time Zones

# Time zones are offsets from UTC
"""
UTC: +00:00
US Eastern (EST): -05:00 (standard)
US Eastern (EDT): -04:00 (daylight saving)
Los Angeles (PST): -08:00 (standard)
Los Angeles (PDT): -07:00 (daylight saving)
London (GMT): +00:00 (standard)
London (BST): +01:00 (daylight saving)
Tokyo (JST): +09:00 (no DST)
"""

UTC Storage

# ALWAYS use UTC for storage
from datetime import datetime, timezone, timedelta

# โŒ BAD: Naive datetime - no timezone info
created_at = datetime.now()  # Loses timezone context!

# โœ… GOOD: UTC-aware datetime
created_at = datetime.now(timezone.utc)

# โœ… GOOD: Parse ISO 8601 with timezone
created_at = datetime.fromisoformat("2026-03-12T10:30:00+00:00")

# โœ… GOOD: Explicit UTC
created_at = datetime(2026, 3, 12, 10, 30, 0, tzinfo=timezone.utc)

Converting Between Time Zones

from datetime import datetime, timezone
import pytz

# Get UTC time
utc_now = datetime.now(timezone.utc)

# Convert to specific timezone
eastern = pytz.timezone('US/Eastern')
pacific = pytz.timezone('US/Pacific')
tokyo = pytz.timezone('Asia/Tokyo')

eastern_time = utc_now.astimezone(eastern)
pacific_time = utc_now.astimezone(pacific)
tokyo_time = utc_now.astimezone(tokyo)

print(f"UTC: {utc_now}")
print(f"Eastern: {eastern_time}")
print(f"Pacific: {pacific_time}")
print(f"Tokyo: {tokyo_time}")

# Output:
# UTC: 2026-03-12 15:30:00+00:00
# Eastern: 2026-03-12 11:30:00-04:00
# Pacific: 2026-03-12 08:30:00-07:00
# Tokyo: 2026-03-13 00:30:00+09:00

Working with Local Time

# Convert naive datetime to UTC
local_time = datetime(2026, 3, 12, 10, 30, 0)

# Need to know the source timezone!
eastern = pytz.timezone('US/Eastern')

# localize() attaches timezone to naive datetime
aware_time = eastern.localize(local_time)

# Now convert to UTC
utc_time = aware_time.astimezone(pytz.utc)

# Get system's local timezone
local_tz = datetime.now().astimezone().tzinfo

ISO 8601: The Standard Format

Why ISO 8601 Matters

# ISO 8601 format: YYYY-MM-DDTHH:MM:SS.sssยฑHH:MM

# Benefits:
# 1. Unambiguous - includes timezone
# 2. Chronologically sortable as strings
# 3. International standard
# 4. Parsable by most languages

from datetime import datetime, timezone

# Creating ISO 8601 strings
now = datetime.now(timezone.utc)

print(now.isoformat())
# "2026-03-12T15:30:00.000000+00:00"

# With timezone offset
tokyo = pytz.timezone('Asia/Tokyo')
tokyo_time = now.astimezone(tokyo)
print(tokyo_time.isoformat())
# "2026-03-13T00:30:00.000000+09:00"

# Parsing ISO 8601
parsed = datetime.fromisoformat("2026-03-12T10:30:00+05:30")
print(parsed)
# 2026-03-12 10:30:00+05:30

JSON Serialization

import json
from datetime import datetime, timezone

class DateTimeEncoder(json.JSONEncoder):
    """Serialize datetime to ISO 8601 for JSON."""
    
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)

# Usage
data = {
    "created_at": datetime.now(timezone.utc),
    "name": "Test"
}

json_string = json.dumps(data, cls=DateTimeEncoder)
# '{"created_at": "2026-03-12T15:30:00.000000+00:00", "name": "Test"}'

Daylight Saving Time

The DST Problem

# DST transitions cause edge cases

# In the US, DST starts at 2:00 AM
# Time "jumps forward" from 2:00 AM to 3:00 AM

# This means times between 2:00 AM and 3:00 AM DON'T EXIST
# On DST end, times "repeat" - 1:00 AM happens twice

import pytz
from datetime import datetime

eastern = pytz.timezone('US/Eastern')

# DST start (Spring forward) - time jumps ahead
# March 12, 2026 - clocks move forward at 2:00 AM
# 2:30 AM doesn't exist!

# โŒ BAD: Using pytz without localize()
# This might give unexpected results
dt = datetime(2026, 3, 12, 2, 30, 0)
print(eastern.localize(dt))  # Ambiguous!

# โœ… GOOD: is_dst parameter
spring_forward = eastern.localize(
    datetime(2026, 3, 12, 2, 30, 0),
    is_dst=True  # Before transition
)
# 2026-03-12 03:30:00-04:00 (EDT)

# DST end (Fall back) - time repeats
fall_back = datetime(2026, 11, 5, 1, 30, 0)
# This hour happens twice!

eastern.localize(fall_back, is_dst=True)   # First occurrence (EDT)
eastern.localize(fall_back, is_dst=False)  # Second occurrence (EST)

Handling DST in Applications

# Always store UTC, convert to local for display
# This avoids most DST headaches

from datetime import datetime, timezone

def store_timestamp(dt: datetime) -> str:
    """Store any datetime as UTC ISO string."""
    if dt.tzinfo is None:
        # Assume naive datetime is in local time
        dt = dt.astimezone()
    return dt.astimezone(timezone.utc).isoformat()

def display_timestamp(utc_string: str, user_timezone: str) -> str:
    """Display timestamp in user's timezone."""
    utc_dt = datetime.fromisoformat(utc_string)
    user_tz = pytz.timezone(user_timezone)
    local_dt = utc_dt.astimezone(user_tz)
    return local_dt.strftime("%B %d, %Y at %I:%M %p %Z")

# Usage
stored = store_timestamp(datetime.now())  # Always in UTC
displayed = display_timestamp(stored, "America/New_York")  # User's time

JavaScript Date Handling

Native Date Objects

// โŒ BAD: Ambiguous local time
const date1 = new Date("2026-03-12");
const date2 = new Date("2026-03-12T10:30:00");

// These are interpreted as local time!
// Behavior varies by browser

// โœ… GOOD: ISO 8601 with timezone
const date3 = new Date("2026-03-12T10:30:00Z");  // Z = UTC
const date4 = new Date("2026-03-12T10:30:00+05:30");  // Explicit offset

Using date-fns

import { 
  parseISO, 
  format, 
  formatDistance, 
  isAfter, 
  isBefore,
  addDays,
  subMonths 
} from 'date-fns';

// Parse ISO string
const date = parseISO('2026-03-12T10:30:00Z');

// Format for display
format(date, 'PPpp');        // "March 12, 2026 at 10:30 AM"
format(date, 'MMM d, yyyy'); // "Mar 12, 2026"
format(date, 'h:mm a');       // "10:30 AM"

// Relative time
formatDistance(date, new Date(), { addSuffix: true }); 
// "2 hours ago" or "in 3 days"

// Date arithmetic
const nextWeek = addDays(new Date(), 7);
const lastMonth = subMonths(new Date(), 1);

// Comparisons
const isFuture = isAfter(nextWeek, new Date());
const isPast = isBefore(lastMonth, new Date());

Time Zones with date-fns-tz

import { format } from 'date-fns';
import { toZonedTime, fromZonedTime } from 'date-fns-tz';

// Convert UTC to user's timezone
const utcDate = new Date('2026-03-12T10:30:00Z');
const newYorkDate = toZonedTime(utcDate, 'America/New_York');
const tokyoDate = toZonedTime(utcDate, 'Asia/Tokyo');

// Format in timezone
format(newYorkDate, 'PPpp z', { timeZone: 'America/New_York' });
// "March 12, 2026 at 6:30 AM EDT"

format(tokyoDate, 'PPpp z', { timeZone: 'Asia/Tokyo' });
// "March 13, 2026 at 7:30 AM JST"

// Convert from local time to UTC
const localDate = new Date('2026-03-12T10:30:00');
const utcFromLocal = fromZonedTime(localDate, 'America/New_York');

Day.js (Lightweight Alternative)

import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import relativeTime from 'dayjs/plugin/relativeTime';

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);

// Parse and format
dayjs('2026-03-12T10:30:00Z').format('YYYY-MM-DD HH:mm');

// Convert timezone
dayjs.tz.setDefault('America/New_York');
dayjs('2026-03-12T10:30:00').tz('Asia/Tokyo').format();

// Relative time
dayjs('2026-03-12').fromNow(); // "2 days ago"
dayjs('2026-03-12').toNow();   // "in 3 days"

Database Date/Time Storage

PostgreSQL

-- Always use timestamptz for timezone-aware storage

-- โŒ BAD: timestamp without timezone
CREATE TABLE bad_example (
    created_at timestamp  -- No timezone info!
);

-- โœ… GOOD: timestamptz with timezone
CREATE TABLE good_example (
    created_at timestamptz  -- Always UTC internally
);

-- PostgreSQL stores timestamptz in UTC
-- Converts to session timezone on display

-- Set session timezone
SET timezone = 'America/New_York';

-- Insert as UTC
INSERT INTO events (created_at) VALUES ('2026-03-12T10:30:00Z');

-- Read in session timezone
SELECT created_at FROM events;
-- Returns: 2026-03-12 06:30:00-04:00 (in EST/EDT)
import psycopg2
from datetime import datetime, timezone

# Python datetime with timezone โ†’ PostgreSQL timestamptz
conn = psycopg2.connect("postgresql://localhost/db")
cursor = conn.cursor()

# Insert UTC timestamp
cursor.execute(
    "INSERT INTO events (created_at) VALUES (%s)",
    (datetime.now(timezone.utc),)  # Stored as UTC
)

# Query with timezone conversion
cursor.execute("""
    SELECT created_at AT TIME ZONE 'America/Los_Angeles'
    FROM events
""")

MySQL

-- MySQL 8.0+ has better timezone support

-- Use TIMESTAMP for automatic UTC conversion
CREATE TABLE events (
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- MySQL stores as UTC, converts to session timezone on SELECT

-- For explicit timezone storage, use DATETIME with manual handling
CREATE TABLE events (
    created_at DATETIME,
    timezone VARCHAR(50)
);

Best Practices Summary

Storage

# ALWAYS
โœ“ Store in UTC
โœ“ Use ISO 8601 format
โœ“ Include timezone offset or Z
โœ“ Use timezone-aware types

Display

# ONLY when displaying to users
โœ“ Convert to user's timezone
โœ“ Use locale-appropriate format
โœ“ Show timezone abbreviation
โœ“ Consider DST

Libraries

# Use established libraries
โœ“ Python: pytz, zoneinfo (Python 3.9+), arrow
โœ“ JavaScript: date-fns, dayjs, luxon
โœ“ Never implement timezone handling yourself

Testing

# Test around DST transitions
test_cases = [
    # DST start (Spring forward)
    "2026-03-12T02:30:00America/New_York",
    # DST end (Fall back)  
    "2026-11-05T01:30:00America/New_York",
    # Year boundary
    "2026-01-01T00:00:00UTC",
    "2026-12-31T23:59:59UTC",
]

Conclusion

Date and time handling is hard, but following these principles makes it manageable:

  1. Store everything in UTC - Never store local time
  2. Convert to local only for display - The user’s timezone is for their eyes only
  3. Use ISO 8601 - The universal language of timestamps
  4. Use established libraries - Don’t reinvent timezone handling
  5. Test DST transitions - Find the edge cases before users do

Resources

Comments