Working with dates and times in Python: a practical guide

#python #datetime #timezones #zoneinfo #dst #python3 #standard library

Python's datetime module ships with the standard library, costs nothing to install, and handles most of what you'll ever need. It also has a trap that bites almost every new developer within the first week: naive datetimes. This guide walks you through the full picture, from creating your first date object to handling Daylight Saving Time correctly, so you learn the right habits from the start.

All examples target Python 3.9+ (the minimum for zoneinfo). Python 3.12+ is strongly preferred because it deprecates a dangerous old function we'll cover shortly. The current stable releases are Python 3.14 (October 7, 2025) and Python 3.13.14 (June 10, 2026).

The complete, runnable examples live on GitHub: https://github.com/MichaelStaHelena/pythonista-rocks-codes/tree/main/examples/python-datetime-101


Setup

The datetime module needs no installation:

import datetime

If you're on Windows and want to use real IANA timezone names like "America/New_York", install the tzdata package (version 2026.2 as of this writing). Linux and macOS read timezone data from the system automatically.

pip install tzdata

Third-party libraries covered later in the article each have their own install command, shown where they're introduced.


The four core classes

The datetime module gives you four classes. Each does one job.

import datetime

today = datetime.date.today()
print("date      :", today)
# => date      : 2026-07-10

t = datetime.time(14, 30, 0)
print("time      :", t)
# => time      : 14:30:00

dt = datetime.datetime(2025, 3, 9, 14, 30, 0)
print("datetime  :", dt)
# => datetime  : 2025-03-09 14:30:00

delta = datetime.timedelta(days=7, hours=3)
print("timedelta :", delta)
# => timedelta : 7 days, 3:00:00

datetime.date stores only a calendar date (year, month, day). datetime.time stores only a wall-clock time. datetime.datetime combines both and is what you'll use the most. datetime.timedelta represents a duration and drives all the arithmetic.

You rarely use datetime.time alone. It exists mostly as an ingredient: datetime.datetime.combine(date, time) glues them together when you have the two parts separately.


Creating datetimes

Three common ways to get a datetime:

import datetime

# Naive - no timezone info (tzinfo is None)
now_naive = datetime.datetime.now()
print("naive tzinfo :", now_naive.tzinfo)
# => naive tzinfo : None

# Aware UTC - the preferred default for all new code (Python 3.12+)
now_utc = datetime.datetime.now(datetime.UTC)
print("UTC   tzinfo :", now_utc.tzinfo)
# => UTC   tzinfo : UTC

# Constructor with explicit UTC
dt = datetime.datetime(2025, 7, 4, 9, 0, 0, tzinfo=datetime.UTC)
print("constructed  :", dt)
# => constructed  : 2025-07-04 09:00:00+00:00

Calling datetime.datetime.now() without arguments gives you local time with no timezone attached. That's the naive form. The tzinfo attribute is None, which means Python has no idea what timezone this datetime belongs to.

Passing datetime.UTC as the tz argument gives you a UTC-aware datetime. That +00:00 at the end is the proof: the object carries its offset.

For quick "today's date only" use cases, datetime.date.today() skips the time component entirely.


Formatting and parsing

Two methods do the heavy lifting here. strftime turns a datetime into a string. strptime turns a string into a datetime. The format codes are the same in both directions, documented in full at docs.python.org.

import datetime

dt = datetime.datetime(2025, 7, 4, 9, 5, 0)

# ⚠️  %Y (four-digit) vs %y (two-digit)
print("4-digit year :", dt.strftime("%Y-%m-%d"))
# => 4-digit year : 2025-07-04
print("2-digit year :", dt.strftime("%y-%m-%d"))
# => 2-digit year : 25-07-04

# ⚠️  %m (month 01-12) vs %M (minutes 00-59) - very common bug
print("month vs min :", dt.strftime("month=%m  minute=%M"))
# => month vs min : month=07  minute=05

# 12-hour clock with AM/PM
print("12-hour      :", dt.strftime("%I:%M %p"))
# => 12-hour      : 09:05 AM

# Human-readable full date
print("human        :", dt.strftime("%A, %B %d %Y"))
# => human        : Friday, July 04 2025

# strptime: parse a string into a datetime
parsed = datetime.datetime.strptime("30, July 2025", "%d, %B %Y")
print("strptime     :", parsed)
# => strptime     : 2025-07-30 00:00:00

Two format code mistakes show up constantly. First: %y gives a two-digit year (25), not a four-digit year. Always use %Y unless you specifically want the abbreviated form. Second: %m is the month (01-12) and %M is minutes (00-59). One wrong case and your date turns into gibberish with no error message - Python will happily format or parse the wrong field.

For ISO 8601 strings (the 2025-07-04T09:00:00+00:00 format that databases and APIs use), there's a cleaner pair of methods:

import datetime

dt = datetime.datetime(2025, 7, 4, 9, 0, 0, tzinfo=datetime.UTC)

iso = dt.isoformat()
print("isoformat        :", iso)
# => isoformat        : 2025-07-04T09:00:00+00:00

back = datetime.datetime.fromisoformat(iso)
print("fromisoformat    :", back)
# => fromisoformat    : 2025-07-04 09:00:00+00:00

# Python 3.11+ understands the trailing Z
back_z = datetime.datetime.fromisoformat("2025-07-04T09:00:00Z")
print("fromisoformat Z  :", back_z)
# => fromisoformat Z  : 2025-07-04 09:00:00+00:00

fromisoformat got a significant upgrade in Python 3.11: it now handles the trailing Z that many APIs return for UTC. On Python 3.10 and below, "2025-07-04T09:00:00Z" would raise a ValueError. That's one more reason to run 3.11+.


The naive-vs-aware wall

This is the mistake that trips up nearly every Python developer. It's worth reading carefully.

A naive datetime has tzinfo=None. Python does not know what timezone it represents. It could be UTC, it could be Tokyo Standard Time, Python cannot tell. A naive datetime is fine for pure calculations where timezone doesn't matter (how many days until Christmas, if you're working in one known timezone throughout).

An aware datetime carries timezone info. Python knows its absolute position on the timeline. Aware datetimes can be safely compared, stored in databases, and converted to other timezones.

Mixing them in arithmetic raises a hard error:

import datetime

naive = datetime.datetime(2025, 7, 4, 12, 0, 0)
aware = datetime.datetime(2025, 7, 4, 12, 0, 0, tzinfo=datetime.UTC)

print("naive tzinfo :", naive.tzinfo)
# => naive tzinfo : None
print("aware tzinfo :", aware.tzinfo)
# => aware tzinfo : UTC

try:
    diff = aware - naive          # mixing naive and aware -> TypeError
except TypeError as e:
    print("TypeError    :", e)
# => TypeError    : can't subtract offset-naive and offset-aware datetimes

The rule to follow: always work with UTC-aware datetimes internally. Convert to local time only at the last moment, for display. Store UTC in your database. Pass UTC between functions. This eliminates an entire class of bugs.


Stop using datetime.utcnow()

If you've been writing datetime.datetime.utcnow(), stop. It was deprecated in Python 3.12 and is scheduled for removal. The reason is subtle but important: utcnow() returns a naive datetime that happens to contain UTC values. Nothing in the object says "this is UTC." Code that receives it cannot tell the difference between UTC and any other naive datetime.

import datetime

# ❌  Old - deprecated since Python 3.12, returns a NAIVE datetime
# datetime.datetime.utcnow()   # DeprecationWarning

# ✅  New - always use this instead
now = datetime.datetime.now(datetime.UTC)
print("tzinfo :", now.tzinfo)
# => tzinfo : UTC
print("aware  :", now.utcoffset())
# => aware  : 0:00:00

datetime.datetime.now(datetime.UTC) returns an aware datetime. The UTC offset is baked into the object. Simon Willison's TIL has a quick cheatsheet for the other deprecated functions (utcfromtimestamp -> fromtimestamp(ts, tz=datetime.UTC), etc.).


Real timezones with zoneinfo

datetime.UTC only covers the UTC offset. For real IANA timezone names like "America/New_York" or "Europe/London" (which understand Daylight Saving Time rules), use the zoneinfo module. It's been built into Python since 3.9 (PEP 615) and is faster and lighter than pytz according to PSF 2024 benchmarks.

import datetime
from zoneinfo import ZoneInfo

# Windows users: pip install tzdata
# Linux / macOS: works out of the box

ny     = ZoneInfo("America/New_York")
london = ZoneInfo("Europe/London")

# A UTC moment ...
meeting_utc = datetime.datetime(2025, 7, 4, 17, 0, tzinfo=datetime.UTC)

# ... displayed in different local times
meeting_ny  = meeting_utc.astimezone(ny)
meeting_lon = meeting_utc.astimezone(london)

print("UTC    :", meeting_utc.strftime("%H:%M %Z"))
# => UTC    : 17:00 UTC
print("NY     :", meeting_ny.strftime("%H:%M %Z"))
# => NY     : 13:00 EDT
print("London :", meeting_lon.strftime("%H:%M %Z"))
# => London : 18:00 BST

# Attach a zone at construction time
dt_ny = datetime.datetime(2025, 7, 4, 13, 0, tzinfo=ny)
print("NY ctor:", dt_ny.strftime("%H:%M %Z"))
# => NY ctor: 13:00 EDT
print("as UTC :", dt_ny.astimezone(datetime.UTC).strftime("%H:%M %Z"))
# => as UTC : 17:00 UTC

astimezone() converts an aware datetime to a new timezone. The underlying moment in time does not change, only the display. ZoneInfo objects know about DST transitions automatically because they read from the IANA database.

A word on pytz (version 2026.2): it's still maintained, but its own documentation now recommends zoneinfo for Python 3.9+. If you encounter pytz in existing code, the critical thing to know is that you must use tz.localize(dt) to attach a timezone, never dt.replace(tzinfo=tz). The replace() approach gives the wrong UTC offset for many zones during DST transitions. In new code, use zoneinfo and avoid this problem entirely.


Timedelta arithmetic and the DST edge case

timedelta drives all date math. It accepts days, seconds, microseconds, milliseconds, minutes, hours, and weeks:

import datetime
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")

# Basic arithmetic
base = datetime.datetime(2025, 7, 4, 12, 0, 0, tzinfo=datetime.UTC)
print("in 2 weeks  :", (base + datetime.timedelta(weeks=2)).date())
# => in 2 weeks  : 2025-07-18
print("3 hours ago :", (base - datetime.timedelta(hours=3)).strftime("%H:%M %Z"))
# => 3 hours ago : 09:00 UTC

# ── DST worked example: "spring forward" night in New York ───────────────────
# 2025-03-09: clocks jump from 02:00 EST -> 03:00 EDT (one hour is skipped)
start = datetime.datetime(2025, 3, 9, 0, 0, tzinfo=ny)
print("\nStart       :", start.strftime("%Y-%m-%d %H:%M %Z (UTC%z)"))
# => Start       : 2025-03-09 00:00 EST (UTC-0500)

# timedelta(days=1) always means exactly 86 400 real seconds - correct
plus_one = start + datetime.timedelta(days=1)
print("+1 day      :", plus_one.strftime("%Y-%m-%d %H:%M %Z (UTC%z)"))
# => +1 day      : 2025-03-10 00:00 EDT (UTC-0400)

# ⚠️  The dangerous anti-pattern: strip tz -> add -> re-attach with replace()
naive_stripped = start.replace(tzinfo=None)                  # loses zone
naive_plus1    = naive_stripped + datetime.timedelta(days=1)
wrong_result   = naive_plus1.replace(tzinfo=ny)              # blindly stamps zone
print("\n❌ wrong (replace):", wrong_result.strftime("%Y-%m-%d %H:%M %Z (UTC%z)"))
# => ❌ wrong (replace): 2025-03-10 00:00 EDT (UTC-0400)

# ✅  Safe pattern: work in UTC throughout, convert to local only for display
safe_result = (
    start.astimezone(datetime.UTC)          # convert to UTC
    + datetime.timedelta(days=1)            # add 24 real hours in UTC
).astimezone(ny)                            # back to local only at the end
print("✅ safe  (UTC)    :", safe_result.strftime("%Y-%m-%d %H:%M %Z (UTC%z)"))
# => ✅ safe  (UTC)    : 2025-03-10 01:00 EDT (UTC-0400)

The DST output is worth unpacking. On March 9, 2025, New York clocks spring forward: 2:00 AM EST becomes 3:00 AM EDT, so that day has only 23 real hours. When you add timedelta(days=1) to midnight EST and display in local time, zoneinfo correctly shows midnight EDT the next day (the UTC offset shifted from -05:00 to -04:00, proof that DST was applied).

The dangerous pattern strips the timezone, adds a day naively, then re-stamps the zone with replace(). Here both paths produce the same visual output by coincidence (midnight is unambiguous), but the "safe via UTC" route produces a different result (01:00 EDT) because it preserves the exact 24-real-hour span measured from the correctly converted UTC value. If your starting time were 1:30 AM on the spring-forward night, the wrong approach would land in a time that does not exist on the clock, and Python would silently give you a misleading offset. Do the math in UTC. Convert to local only for display.


python-dateutil: fuzzy parsing and month arithmetic

timedelta cannot do "add one month" cleanly because months have different lengths. And strptime requires an exact format string. python-dateutil (2.9.0.post0) solves both:

pip install python-dateutil
# pip install python-dateutil
from dateutil import parser as du_parser
from dateutil.relativedelta import relativedelta
import datetime

# Fuzzy / human-readable string parsing (no exact format needed)
dt1 = du_parser.parse("July 30, 2025 3pm")
print("parse 1 :", dt1)
# => parse 1 : 2025-07-30 15:00:00

dt2 = du_parser.parse("30 Jul 2025 15:00")
print("parse 2 :", dt2)
# => parse 2 : 2025-07-30 15:00:00

# relativedelta: "add 1 month" - timedelta(days=31) would overshoot February
start = datetime.datetime(2025, 1, 31)
print("Jan 31 + 1 month :", (start + relativedelta(months=1)).date())
# => Jan 31 + 1 month : 2025-02-28

# Add 1 year
print("anniversary +1yr :", (start + relativedelta(years=1)).date())
# => anniversary +1yr : 2026-01-31

relativedelta(months=1) added to January 31 gives February 28, not March 3. That's the correct calendar behavior. timedelta(days=31) would give March 3, which is wrong for any logic that means "end of next month" or "same day next month."

dateutil.parser.parse is convenient for user input or log files with inconsistent formatting. The risk: it makes guesses. For machine-generated strings, pass an explicit format to strptime instead. Guessing can silently misparse "04/05/2025" as April 5 or May 4 depending on locale.


arrow and pendulum: when the standard library isn't enough

Two third-party libraries go beyond what dateutil offers. Both wrap standard datetime objects with a cleaner API.

arrow (1.4.0, October 2025) gives you a single unified object that handles creation, shifting, conversion, and human-readable output:

pip install arrow
# pip install arrow
import arrow

# UTC now and ISO formatting
now = arrow.utcnow()
print("type     :", type(now).__name__)
# => type     : Arrow

# Parse a string - always pass the format to avoid DD/MM vs MM/DD ambiguity
a = arrow.get("2025-07-04", "YYYY-MM-DD")
print("parsed   :", a.isoformat())
# => parsed   : 2025-07-04T00:00:00+00:00

# Shift (returns a new Arrow object)
shifted = a.shift(hours=+9)
print("shift +9h:", shifted.isoformat())
# => shift +9h: 2025-07-04T09:00:00+00:00

# Convert to another timezone
print("-> Tokyo  :", a.to("Asia/Tokyo").format("YYYY-MM-DD HH:mm ZZZ"))
# => -> Tokyo  : 2025-07-04 09:00 JST

# Humanize (relative description between two fixed Arrow objects)
ref  = arrow.Arrow(2025, 7, 4, 12, 0, 0, tzinfo="UTC")
past = arrow.Arrow(2025, 7, 4,  9, 0, 0, tzinfo="UTC")
print("humanize :", past.humanize(ref))
# => humanize : 3 hours ago

One gotcha worth calling out: arrow.get("04/05/2025") can silently infer the wrong order of day and month. Always pass the format string explicitly, as the example shows.

pendulum (3.2.0, January 2026) takes a stricter approach. It subclasses datetime.datetime directly, so it's fully interoperable with any code that accepts a standard datetime. Naive datetimes are simply not creatable through the normal API, which removes the entire naive-vs-aware problem by design:

pip install pendulum
# pip install pendulum
import pendulum

# Always timezone-aware - naive datetimes are a non-issue by design
p = pendulum.datetime(2025, 7, 4, 12, 0, 0, tz="America/New_York")
print("datetime :", p.isoformat())
# => datetime : 2025-07-04T12:00:00-04:00
print("tz name  :", p.timezone_name)
# => tz name  : America/New_York

# Month arithmetic respects DST and calendar boundaries
print("+1 month :", p.add(months=1).isoformat())
# => +1 month : 2025-08-04T12:00:00-04:00

# Human-readable diff
ref_p  = pendulum.datetime(2025, 7, 4, 12, 0, 0, tz="UTC")
past_p = pendulum.datetime(2025, 6, 4, 12, 0, 0, tz="UTC")
print("diff     :", past_p.diff_for_humans(ref_p))
# => diff     : 1 month ago

# Convert zone
print("-> Berlin :", p.in_timezone("Europe/Berlin").isoformat())
# => -> Berlin : 2025-07-04T18:00:00+02:00

Choosing the right tool

Need Reach for
Basic date/time creation and arithmetic datetime stdlib
Real IANA timezones, DST-aware zoneinfo (stdlib, 3.9+)
"Add 1 month" / "add 1 year" dateutil.relativedelta
Parse human-written strings dateutil.parser.parse
Clean API, humanized output, quick scripts arrow 1.4.0
Production code, strict timezone safety pendulum 3.2.0
Legacy code on Python < 3.9 pytz (read-only; prefer migrating)

The clearest advice: use datetime with zoneinfo for everything new, reach for dateutil when you need month-level arithmetic or flexible string parsing, and consider pendulum if you want the naive-datetime footgun removed from the equation entirely. Arrow is excellent for scripts and quick tooling where .humanize() and .shift() save real time.

The one thing to nail before anything else: make datetime.datetime.now(datetime.UTC) your muscle memory, and treat every naive datetime as a bug until proven otherwise.