Working with Timestamps in Python: Complete Guide to datetime, pendulum, and pytz

Master Python datetime handling with the standard library, pendulum, pytz, and arrow. Learn timezone-aware programming, timestamp conversion, DST handling, and testing strategies for production Python applications.

Working with Timestamps in Python: Complete Guide

I'll be honest: Python's built-in datetime module is... quirky. It works, sure, but it's got some sharp edges that'll bite you if you're not careful. The good news? The Python ecosystem has produced some genuinely excellent libraries that make datetime handling a breeze—once you know which ones to reach for.

Whether you're building a simple script or a production API that needs to handle timezones correctly across the globe, this guide's got you covered. Let's dive in.

The datetime Standard Library

Let's start with what comes in the box. Python's standard library has everything you need for basic datetime work—it's just not always intuitive.

datetime Module Overview

Python's datetime module gives you four main types to work with:

from datetime import datetime, date, time, timedelta

# date: Year, month, day
today = date(2024, 1, 15)

# time: Hour, minute, second, microsecond
now_time = time(19, 0, 0)

# datetime: Combination of date and time
now = datetime(2024, 1, 15, 19, 0, 0)

# timedelta: Duration between two dates/times
duration = timedelta(days=1, hours=2, minutes=30)

Creating datetime Objects

from datetime import datetime
import time

# Current time (naive - no timezone)
now = datetime.now()
print(now)  # 2024-01-15 19:00:00.123456

# Current time (UTC aware)
utc_now = datetime.utcnow()
print(utc_now)  # 2024-01-15 19:00:00.123456

# From components
dt = datetime(2024, 1, 15, 19, 0, 0)

# From Unix timestamp (seconds)
timestamp = 1705341600
dt_from_ts = datetime.fromtimestamp(timestamp)
print(dt_from_ts)  # 2024-01-15 14:00:00 (local time)

# From Unix timestamp (UTC)
dt_from_ts_utc = datetime.utcfromtimestamp(timestamp)
print(dt_from_ts_utc)  # 2024-01-15 19:00:00

# From ISO 8601 string
dt_from_iso = datetime.fromisoformat('2024-01-15T19:00:00')

# Current time with high precision
precise_now = time.time()  # Returns float with microseconds
print(precise_now)  # 1705341600.123456

Converting to Timestamps

from datetime import datetime

dt = datetime(2024, 1, 15, 19, 0, 0)

# To Unix timestamp (seconds, as float)
timestamp = dt.timestamp()
print(timestamp)  # 1705341600.0

# To ISO 8601 string
iso_string = dt.isoformat()
print(iso_string)  # '2024-01-15T19:00:00'

# To formatted string
formatted = dt.strftime('%Y-%m-%d %H:%M:%S')
print(formatted)  # '2024-01-15 19:00:00'

# To Unix timestamp (integer seconds)
timestamp_int = int(dt.timestamp())

Parsing Strings

from datetime import datetime

# Parse with strptime (string parse time)
date_str = '2024-01-15 19:00:00'
dt = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')

# Parse ISO format
iso_str = '2024-01-15T19:00:00'
dt = datetime.fromisoformat(iso_str)

# Common format codes
formats = {
    '%Y': 'Year (4 digits)',      # 2024
    '%m': 'Month (01-12)',         # 01
    '%d': 'Day (01-31)',           # 15
    '%H': 'Hour 24h (00-23)',      # 19
    '%I': 'Hour 12h (01-12)',      # 07
    '%M': 'Minute (00-59)',        # 00
    '%S': 'Second (00-59)',        # 00
    '%f': 'Microsecond',           # 123456
    '%p': 'AM/PM',                 # PM
    '%z': 'UTC offset',            # +0000
    '%Z': 'Timezone name',         # UTC
}

# Example with complex format
date_str = '15-Jan-2024 07:00:00 PM'
dt = datetime.strptime(date_str, '%d-%b-%Y %I:%M:%S %p')

Datetime Arithmetic

from datetime import datetime, timedelta

now = datetime(2024, 1, 15, 19, 0, 0)

# Add duration
tomorrow = now + timedelta(days=1)
next_week = now + timedelta(weeks=1)
in_2_hours = now + timedelta(hours=2)

# Subtract duration
yesterday = now - timedelta(days=1)

# Difference between datetimes
dt1 = datetime(2024, 1, 15, 19, 0, 0)
dt2 = datetime(2024, 1, 20, 21, 30, 0)

diff = dt2 - dt1
print(diff)  # 5 days, 2:30:00
print(diff.days)  # 5
print(diff.seconds)  # 9000 (2.5 hours in seconds)
print(diff.total_seconds())  # 441000.0

# Check if date is in the past/future
is_past = now < datetime.now()
is_future = now > datetime.now()

Timezone-Aware Programming

The Problem with Naive Datetimes

from datetime import datetime

# ❌ NAIVE datetime (no timezone info)
naive = datetime.now()
print(naive.tzinfo)  # None
print(naive.isoformat())  # '2024-01-15T19:00:00'

# Problem: Ambiguous - which timezone?
# Can't compare with other timezones reliably

Using pytz for Timezone Support

from datetime import datetime
import pytz

# Create timezone-aware datetime
utc = pytz.UTC
eastern = pytz.timezone('America/New_York')
tokyo = pytz.timezone('Asia/Tokyo')

# Current time in UTC
utc_now = datetime.now(utc)
print(utc_now)  # 2024-01-15 19:00:00+00:00

# Current time in specific timezone
ny_now = datetime.now(eastern)
print(ny_now)  # 2024-01-15 14:00:00-05:00

# Create aware datetime from naive
naive = datetime(2024, 1, 15, 19, 0, 0)
aware_utc = utc.localize(naive)
print(aware_utc)  # 2024-01-15 19:00:00+00:00

# ⚠️ IMPORTANT: Use localize(), not replace()
# ❌ WRONG
wrong = naive.replace(tzinfo=eastern)  # Doesn't handle DST!

# ✅ CORRECT
correct = eastern.localize(naive)  # Handles DST properly

Converting Between Timezones

import pytz
from datetime import datetime

# Create datetime in UTC
utc = pytz.UTC
utc_time = datetime(2024, 1, 15, 19, 0, 0, tzinfo=utc)

# Convert to different timezones
eastern = pytz.timezone('America/New_York')
london = pytz.timezone('Europe/London')
tokyo = pytz.timezone('Asia/Tokyo')

ny_time = utc_time.astimezone(eastern)
london_time = utc_time.astimezone(london)
tokyo_time = utc_time.astimezone(tokyo)

print(f"UTC:    {utc_time}")      # 2024-01-15 19:00:00+00:00
print(f"NY:     {ny_time}")       # 2024-01-15 14:00:00-05:00
print(f"London: {london_time}")   # 2024-01-15 19:00:00+00:00
print(f"Tokyo:  {tokyo_time}")    # 2024-01-16 04:00:00+09:00

List Available Timezones

import pytz

# All available timezones
all_timezones = pytz.all_timezones
print(f"Total timezones: {len(all_timezones)}")

# Common timezones
common = pytz.common_timezones
print(common[:10])

# Find timezone by name
for tz in pytz.all_timezones:
    if 'New_York' in tz:
        print(tz)  # America/New_York

pendulum: Better Datetime Library

Okay, real talk: if you're doing anything serious with datetimes in Python, just use pendulum. It's what the standard library should have been.

Why pendulum?

Pendulum improves upon the standard library with:

  • Automatic timezone handling (no more naive datetimes!)
  • Better DST support (it just works)
  • Simpler API (way more intuitive)
  • Human-readable differences ("2 hours ago" instead of "timedelta(hours=2)")
pip install pendulum

Basic Usage

import pendulum

# Current time (timezone-aware by default!)
now = pendulum.now()
print(now)  # 2024-01-15T19:00:00+00:00

# Current time in specific timezone
ny_now = pendulum.now('America/New_York')
print(ny_now)  # 2024-01-15T14:00:00-05:00

# Create datetime
dt = pendulum.datetime(2024, 1, 15, 19, 0, 0, tz='UTC')

# From Unix timestamp
dt = pendulum.from_timestamp(1705341600)

# From string
dt = pendulum.parse('2024-01-15T19:00:00Z')

Timezone Conversion

import pendulum

# Create in one timezone, convert to another
ny = pendulum.datetime(2024, 1, 15, 14, 0, 0, tz='America/New_York')
tokyo = ny.in_timezone('Asia/Tokyo')

print(ny)     # 2024-01-15T14:00:00-05:00
print(tokyo)  # 2024-01-16T04:00:00+09:00

# Convert to UTC
utc = ny.in_utc()
print(utc)    # 2024-01-15T19:00:00+00:00

Datetime Arithmetic

import pendulum

now = pendulum.now()

# Add/subtract with natural language
tomorrow = now.add(days=1)
next_week = now.add(weeks=1)
in_2_hours = now.add(hours=2)

yesterday = now.subtract(days=1)

# Chaining
future = now.add(days=5).add(hours=3).add(minutes=30)

# Period (human-readable difference)
start = pendulum.datetime(2024, 1, 15, tz='UTC')
end = pendulum.datetime(2024, 1, 20, tz='UTC')

period = end - start
print(period.in_days())    # 5
print(period.in_hours())   # 120
print(period.in_seconds()) # 432000

# Human-readable diff
print(start.diff_for_humans(end))  # "5 days before"

Handling DST with pendulum

import pendulum

# Spring forward (2024-03-10 in New York)
# 2:00 AM doesn't exist!
try:
    # pendulum handles this gracefully
    dt = pendulum.datetime(2024, 3, 10, 2, 30, tz='America/New_York')
    print(dt)  # Automatically adjusted to 3:30 AM EDT
except pendulum.exceptions.NonExistingTime as e:
    print(f"Time doesn't exist: {e}")

# Fall back (2024-11-03 in New York)
# 1:30 AM occurs twice
dt = pendulum.datetime(2024, 11, 3, 1, 30, tz='America/New_York')
print(dt)  # Uses first occurrence (EDT)

arrow: Another Alternative

arrow Features

pip install arrow
import arrow

# Current time
now = arrow.now()
utc_now = arrow.utcnow()

# Create arrow object
dt = arrow.get('2024-01-15T19:00:00Z')
dt = arrow.get(1705341600)  # From timestamp

# From datetime
from datetime import datetime
dt = arrow.get(datetime(2024, 1, 15, 19, 0, 0))

# Timezone conversion
ny = arrow.now('America/New_York')
tokyo = ny.to('Asia/Tokyo')

# Humanize differences
past = arrow.now().shift(hours=-2)
print(past.humanize())  # "2 hours ago"

# Formatting
now = arrow.now()
print(now.format('YYYY-MM-DD HH:mm:ss'))
print(now.format('dddd, MMMM D, YYYY'))  # "Monday, January 15, 2024"

python-dateutil

Parsing Flexible Date Strings

from dateutil import parser

# Parse various formats automatically
dates = [
    "2024-01-15",
    "January 15, 2024",
    "15-Jan-2024",
    "01/15/2024",
    "2024-01-15T19:00:00Z",
]

for date_str in dates:
    dt = parser.parse(date_str)
    print(f"{date_str:30} → {dt}")

# Parse with timezone
dt = parser.parse("2024-01-15T19:00:00+00:00")
print(dt.tzinfo)  # tzutc()

# Fuzzy parsing (extract date from text)
text = "The meeting is on January 15, 2024 at 2pm"
dt = parser.parse(text, fuzzy=True)
print(dt)  # 2024-01-15 14:00:00

Relative Deltas

from dateutil.relativedelta import relativedelta
from datetime import datetime

now = datetime(2024, 1, 15)

# Add months (handles different month lengths)
next_month = now + relativedelta(months=1)  # 2024-02-15
last_year = now + relativedelta(years=-1)   # 2023-01-15

# Add specific units
future = now + relativedelta(
    years=1,
    months=2,
    days=3,
    hours=4,
    minutes=5
)

# Go to specific day of month
first_of_month = now + relativedelta(day=1)
last_of_month = now + relativedelta(day=31)  # Handles month length

# Go to specific weekday
from dateutil.relativedelta import MO, TU, WE, TH, FR, SA, SU

next_monday = now + relativedelta(weekday=MO)
next_friday = now + relativedelta(weekday=FR(1))  # Next friday
second_tuesday = now + relativedelta(day=1, weekday=TU(2))  # 2nd Tuesday of month

Best Practices

These are the patterns I wish someone had drilled into my head when I first started working with Python datetimes. Would've saved me so many production bugs.

1. Always Use UTC for Storage

import pendulum

# ✅ CORRECT: Store in UTC
def save_event(title, scheduled_time):
    """
    Save event with UTC timestamp

    Args:
        scheduled_time: pendulum datetime or ISO string
    """
    if isinstance(scheduled_time, str):
        scheduled_time = pendulum.parse(scheduled_time)

    # Convert to UTC for storage
    utc_time = scheduled_time.in_utc()

    db.save({
        'title': title,
        'scheduled_at': utc_time.timestamp(),  # Unix timestamp
        'scheduled_at_iso': utc_time.to_iso8601_string()
    })

# Display in user's timezone
def display_event(event, user_timezone='America/New_York'):
    utc_time = pendulum.from_timestamp(event['scheduled_at'])
    local_time = utc_time.in_timezone(user_timezone)

    return {
        'title': event['title'],
        'local_time': local_time.format('YYYY-MM-DD HH:mm:ss ZZ'),
        'relative': local_time.diff_for_humans()
    }

2. Validate Datetime Inputs

import pendulum
from pendulum.exceptions import ParserError

def parse_datetime_safe(date_input, timezone='UTC'):
    """
    Safely parse datetime input with validation

    Args:
        date_input: Unix timestamp (int/float), ISO string, or pendulum datetime
        timezone: Target timezone

    Returns:
        pendulum datetime or raises ValueError
    """
    try:
        if isinstance(date_input, (int, float)):
            # Unix timestamp
            if date_input < 0:
                raise ValueError("Timestamp cannot be negative")
            if date_input > 2**31:  # Year 2038 check
                # Assume milliseconds
                date_input = date_input / 1000

            dt = pendulum.from_timestamp(date_input, tz=timezone)

        elif isinstance(date_input, str):
            # ISO string or other format
            dt = pendulum.parse(date_input, tz=timezone)

        elif isinstance(date_input, pendulum.DateTime):
            dt = date_input.in_timezone(timezone)

        else:
            raise ValueError(f"Unsupported datetime type: {type(date_input)}")

        # Validate reasonable range (not too far past/future)
        now = pendulum.now()
        if dt < now.subtract(years=100):
            raise ValueError("Date too far in the past")
        if dt > now.add(years=100):
            raise ValueError("Date too far in the future")

        return dt

    except ParserError as e:
        raise ValueError(f"Invalid datetime format: {e}")

3. Use Context Managers for Testing

import pendulum
from freezegun import freeze_time

# Test time-sensitive code
@freeze_time("2024-01-15 19:00:00")
def test_expiry():
    now = pendulum.now()
    expires_at = now.add(hours=1)

    assert not is_expired(expires_at)

    # Move time forward
    with freeze_time("2024-01-15 20:00:01"):
        assert is_expired(expires_at)

4. Handle DST Explicitly

import pendulum

def schedule_recurring_event(base_time, timezone, occurrences=5):
    """
    Schedule recurring event, handling DST correctly

    Args:
        base_time: Start time in specified timezone
        timezone: IANA timezone name
        occurrences: Number of occurrences

    Returns:
        List of pendulum datetimes
    """
    events = []
    current = pendulum.parse(base_time).in_timezone(timezone)

    for i in range(occurrences):
        events.append({
            'occurrence': i + 1,
            'utc': current.in_utc().to_iso8601_string(),
            'local': current.to_iso8601_string(),
            'offset': current.format('ZZ'),
            'dst': current.is_dst()
        })

        # Add 1 week (handles DST automatically)
        current = current.add(weeks=1)

    return events

# Example spanning DST transition
events = schedule_recurring_event(
    '2024-03-03 02:00:00',  # Week before DST
    'America/New_York',
    occurrences=3
)

for event in events:
    print(f"Week {event['occurrence']}: {event['local']} (DST: {event['dst']})")

# Output:
# Week 1: 2024-03-03T02:00:00-05:00 (DST: False)
# Week 2: 2024-03-10T03:00:00-04:00 (DST: True)  ← Automatically adjusted!
# Week 3: 2024-03-17T02:00:00-04:00 (DST: True)

Working with Databases

Storing Timestamps

import pendulum
import psycopg2

# PostgreSQL with timezone support
def save_to_postgres(conn, event):
    """Save event with timezone-aware timestamp"""
    utc_time = pendulum.parse(event['scheduled_at']).in_utc()

    with conn.cursor() as cur:
        cur.execute("""
            INSERT INTO events (title, scheduled_at, timezone)
            VALUES (%s, %s, %s)
        """, (
            event['title'],
            utc_time.to_datetime_string(),  # '2024-01-15 19:00:00'
            event['timezone']
        ))
        conn.commit()

# Retrieving with timezone conversion
def get_from_postgres(conn, event_id, user_timezone='America/New_York'):
    """Retrieve event and convert to user's timezone"""
    with conn.cursor() as cur:
        cur.execute("""
            SELECT title, scheduled_at AT TIME ZONE 'UTC', timezone
            FROM events
            WHERE id = %s
        """, (event_id,))

        row = cur.fetchone()
        if row:
            title, scheduled_at_utc, original_tz = row

            # Convert to user's timezone
            utc = pendulum.instance(scheduled_at_utc, tz='UTC')
            local = utc.in_timezone(user_timezone)

            return {
                'title': title,
                'utc': utc.to_iso8601_string(),
                'local': local.to_iso8601_string(),
                'original_tz': original_tz
            }

MongoDB

import pendulum
from pymongo import MongoClient

client = MongoClient()
db = client['myapp']

# Store as Unix timestamp
def save_to_mongo(event):
    """Save event with Unix timestamp"""
    dt = pendulum.parse(event['scheduled_at']).in_utc()

    db.events.insert_one({
        'title': event['title'],
        'scheduled_at': dt.timestamp(),  # Unix timestamp
        'timezone': event['timezone'],
        'created_at': pendulum.now().timestamp()
    })

# Query by date range
def query_events_in_range(start_date, end_date):
    """Query events between two dates"""
    start_ts = pendulum.parse(start_date).in_utc().timestamp()
    end_ts = pendulum.parse(end_date).in_utc().timestamp()

    events = db.events.find({
        'scheduled_at': {
            '$gte': start_ts,
            '$lt': end_ts
        }
    }).sort('scheduled_at', 1)

    return [
        {
            'title': e['title'],
            'scheduled_at': pendulum.from_timestamp(e['scheduled_at'])
                .in_timezone(e['timezone'])
                .to_iso8601_string()
        }
        for e in events
    ]

Testing Time-Dependent Code

Using freezegun

from freezegun import freeze_time
import pendulum

@freeze_time("2024-01-15 19:00:00")
def test_session_expiry():
    """Test session expiry logic"""
    # Create session that expires in 1 hour
    session = create_session(expires_in=3600)

    assert session.is_valid() is True

    # Move time forward 59 minutes (still valid)
    with freeze_time("2024-01-15 19:59:00"):
        assert session.is_valid() is True

    # Move time forward 61 minutes (expired)
    with freeze_time("2024-01-15 20:01:00"):
        assert session.is_valid() is False

def test_dst_transition():
    """Test DST handling"""
    # Before DST (EST)
    with freeze_time("2024-03-09 12:00:00"):
        dt = pendulum.now('America/New_York')
        assert dt.offset_hours == -5
        assert dt.is_dst() is False

    # After DST (EDT)
    with freeze_time("2024-03-11 12:00:00"):
        dt = pendulum.now('America/New_York')
        assert dt.offset_hours == -4
        assert dt.is_dst() is True

pytest Fixtures

import pytest
import pendulum
from freezegun import freeze_time

@pytest.fixture
def fixed_time():
    """Fixture providing consistent test time"""
    with freeze_time("2024-01-15 19:00:00"):
        yield pendulum.now()

@pytest.fixture
def mock_clock():
    """Fixture for advancing time in tests"""
    class MockClock:
        def __init__(self):
            self.frozen_time = freeze_time("2024-01-15 19:00:00")
            self.frozen_time.start()

        def advance(self, **kwargs):
            """Advance time by specified duration"""
            current = pendulum.now()
            new_time = current.add(**kwargs)
            self.frozen_time.stop()
            self.frozen_time = freeze_time(new_time.to_datetime_string())
            self.frozen_time.start()

        def stop(self):
            self.frozen_time.stop()

    clock = MockClock()
    yield clock
    clock.stop()

def test_with_mock_clock(mock_clock):
    """Test using mock clock"""
    start = pendulum.now()

    mock_clock.advance(hours=2)

    end = pendulum.now()
    assert (end - start).in_hours() == 2

Common Pitfalls

Let me save you some debugging time. These are the mistakes everyone makes (including me, repeatedly).

1. Comparing Naive and Aware Datetimes

import pendulum
from datetime import datetime

# ❌ WRONG: Can't compare naive and aware
naive = datetime.now()
aware = pendulum.now()

try:
    if naive < aware:
        pass
except TypeError as e:
    print(e)  # can't compare offset-naive and offset-aware datetimes

# ✅ CORRECT: Make both aware
aware_naive = pendulum.instance(naive, tz='UTC')
if aware_naive < aware:
    pass

2. Using datetime.now() Instead of UTC

from datetime import datetime

# ❌ WRONG: Server-dependent
now = datetime.now()  # Depends on server timezone!

# ✅ CORRECT: Always use UTC
import pendulum
now_utc = pendulum.now('UTC')

3. Forgetting to Handle DST

from datetime import datetime, timedelta

# ❌ WRONG: Naive arithmetic breaks with DST
dt = datetime(2024, 3, 9, 12, 0, 0)  # Before DST
tomorrow = dt + timedelta(days=1)    # DST transition!
# Result: Time might be off by 1 hour

# ✅ CORRECT: Use pendulum
import pendulum
dt = pendulum.datetime(2024, 3, 9, 12, 0, 0, tz='America/New_York')
tomorrow = dt.add(days=1)  # Handles DST automatically

Performance Tips

Caching Timezone Objects

import pytz
from functools import lru_cache

@lru_cache(maxsize=128)
def get_timezone(tz_name):
    """Cache timezone objects for better performance"""
    return pytz.timezone(tz_name)

# Use cached version
tz = get_timezone('America/New_York')

Batch Conversions

import pendulum

def convert_batch(timestamps, target_tz='America/New_York'):
    """Convert multiple timestamps efficiently"""
    tz = pendulum.timezone(target_tz)  # Create timezone object once

    return [
        pendulum.from_timestamp(ts, tz=tz)
        for ts in timestamps
    ]

# More efficient than converting timezone in each iteration
timestamps = [1705341600, 1705428000, 1705514400]
results = convert_batch(timestamps)

Conclusion

Python's datetime ecosystem is actually pretty great once you know what to reach for.

Start Here: If you're doing anything beyond basic date math, install pendulum. It's timezone-aware by default, handles DST gracefully, and has an API that makes sense. For flexible date parsing, add python-dateutil. For testing, use freezegun.

My Setup: In production code, I use pendulum for everything. In tests, I freeze time with freezegun. I only reach for the standard library datetime when I'm writing something that absolutely can't have dependencies.

Golden Rules: Always store in UTC. Always use timezone-aware datetimes. Validate inputs. Test DST transitions. Treat time like the complex, weird thing it is—because it is.

The best Python apps (Instagram, Spotify, Dropbox) didn't get datetime handling right by accident—they used the right tools and followed these patterns religiously. Now you can too.

Further Reading


Building Python applications with datetime? Contact us for consultation.