PythonMastery
intermediate 17 min read · lesson 12 of 13 in Python Intermediate

Dates, Times, and Timezones

1 · The lesson

read

Every non-trivial program eventually deals with time — log entries, schedules, expiry, billing periods, "5 minutes ago" labels. The datetime module gives you the primitives. The danger isn't the API; it's timezones. A datetime without a timezone is a number without a unit — looks fine until two systems disagree about what it means.

This lesson covers the four types, the naive-vs-aware distinction that causes most timezone bugs, parsing/formatting with strftime/strptime, the modern zoneinfo module, and timedelta arithmetic. By the end, "store UTC, display local" will feel like the only sensible rule.


1. The Four Types

python
from datetime import date, time, datetime, timedelta

d = date(2026, 5, 14)               # year-month-day, no time
t = time(9, 30, 0)                  # hour-minute-second, no date
dt = datetime(2026, 5, 14, 9, 30)   # both
td = timedelta(days=7, hours=3)     # a *duration*, not a moment

print(d, t, dt, td)
  • date — a calendar day. Use this when "the time of day" is meaningless (birthdays, due dates, holidays).
  • time — a clock reading without a date. Rare on its own; mostly appears as dt.time().
  • datetime — a specific moment. The workhorse.
  • timedelta — a span between two moments. The result of subtracting two datetimes.

Pick the smallest type that fits. A date can't be off-by-an-hour because of DST — a datetime can.


2. "Now" — and the One You Should Use

python
from datetime import datetime, timezone

# Three ways to get "now". Only one is safe.
a = datetime.now()                          # naive  — no timezone attached
b = datetime.utcnow()                       # naive  — deprecated in 3.12
c = datetime.now(tz=timezone.utc)           # aware  — explicit UTC. Use this.

print(a)        # 2026-05-14 09:30:00.123
print(c)        # 2026-05-14 09:30:00.123+00:00
print(c.tzinfo) # datetime.timezone.utc

datetime.utcnow() is a trap — it returns a naive datetime that happens to contain UTC values, with no timezone tag. Pass it into anything timezone-aware and you'll get wrong answers silently. Python 3.12 deprecated it for exactly this reason.

Always use datetime.now(tz=timezone.utc) for "right now" in code that will outlive a single machine.


3. Naive vs Aware — The Source of Most Bugs

A datetime is aware if it has a tzinfo attached, naive otherwise. Mix them and Python refuses:

python
from datetime import datetime, timezone

naive = datetime(2026, 5, 14, 9, 30)
aware = datetime(2026, 5, 14, 9, 30, tzinfo=timezone.utc)

print(aware - aware)            # 0:00:00 — fine
# print(aware - naive)          # TypeError: can't subtract offset-naive from offset-aware

The rule: decide at the system boundary, then stay aware everywhere internally. Anything that crosses a network, a database, or another process should be timezone-aware. The minute you let a naive datetime escape into a shared system, somebody three time zones away will see the wrong hour.


4. Named Timezones with zoneinfo

Since Python 3.9, the stdlib ships the IANA timezone database via zoneinfo. No third-party pytz needed.

python
from datetime import datetime
from zoneinfo import ZoneInfo

india = ZoneInfo("Asia/Kolkata")
launch = datetime(2026, 5, 14, 9, 30, tzinfo=india)
print(launch)                               # 2026-05-14 09:30:00+05:30
print(launch.tzinfo)                        # Asia/Kolkata

Convert between zones with astimezone — Python does the maths:

python
from zoneinfo import ZoneInfo

launch_in_india = datetime(2026, 5, 14, 9, 30, tzinfo=ZoneInfo("Asia/Kolkata"))
launch_in_utc   = launch_in_india.astimezone(ZoneInfo("UTC"))
launch_in_nyc   = launch_in_india.astimezone(ZoneInfo("America/New_York"))

print(launch_in_utc)        # 2026-05-14 04:00:00+00:00
print(launch_in_nyc)        # 2026-05-14 00:00:00-04:00
+ setup added so this can run · defines datetime
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def datetime(*_a, **_kw):
    print('-> datetime() called')
    return _AutoMock('datetime()')

The instant in time is the same — only the wall-clock representation changes. zoneinfo knows about DST too, so "America/New_York" gives -04:00 in summer and -05:00 in winter automatically.


5. Parsing — strptime

strptime ("string parse time") turns a string into a datetime using a format string:

python
from datetime import datetime

dt = datetime.strptime("2026-05-14 09:30:00", "%Y-%m-%d %H:%M:%S")
print(dt)                                   # 2026-05-14 09:30:00
print(type(dt))                             # <class 'datetime.datetime'>

The six format codes you'll use 90% of the time:

CodeMeaningExample
%Y4-digit year2026
%m2-digit month05
%d2-digit day14
%H2-digit hour 0-2309
%M2-digit minute30
%S2-digit second00

Two more worth knowing:

CodeMeaningExample
%zUTC offset+0530
%AFull weekday nameThursday
python
dt = datetime.strptime("2026-05-14 09:30 +0530", "%Y-%m-%d %H:%M %z")
print(dt.tzinfo)                            # UTC+05:30
+ setup added so this can run · defines datetime
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

datetime = _AutoMock('datetime')

6. Formatting — strftime and isoformat

The reverse trip: datetime → string.

python
from datetime import datetime, timezone

now = datetime.now(tz=timezone.utc)

print(now.strftime("%Y-%m-%d %H:%M:%S"))    # 2026-05-14 09:30:00
print(now.strftime("%A, %d %B %Y"))         # Thursday, 14 May 2026
print(now.isoformat())                      # 2026-05-14T09:30:00.123456+00:00

For storage, prefer isoformat() — ISO 8601 is unambiguous, sortable as a string, and parseable by every language in existence. strftime is for display.

The parsing counterpart is even better:

python
from datetime import datetime

dt = datetime.fromisoformat("2026-05-14T09:30:00+00:00")
print(dt)                                   # 2026-05-14 09:30:00+00:00

fromisoformat is much faster than strptime and needs no format string. If you control both sides of a serialization boundary, use ISO 8601 always.


7. timedelta Arithmetic

Datetimes minus datetimes give timedeltas. Datetimes plus timedeltas give datetimes. That's the whole calculus:

python
from datetime import datetime, timedelta, timezone

now = datetime.now(tz=timezone.utc)
week_from_now = now + timedelta(days=7)
two_hours_ago = now - timedelta(hours=2)

duration = week_from_now - now              # timedelta(days=7)
print(duration.total_seconds())             # 604800.0
print(duration.days)                        # 7

timedelta accepts weeks, days, hours, minutes, seconds, milliseconds, microseconds. No months or years — those aren't fixed durations (a "month" is anywhere from 28 to 31 days). For calendar-aware additions, reach for dateutil.relativedelta:

python
# pip install python-dateutil
from dateutil.relativedelta import relativedelta
from datetime import date

print(date(2026, 1, 31) + relativedelta(months=1))   # 2026-02-28  — handles short months

8. Measuring Elapsed Time

Don't use time.time() to measure how long something took — it's the wall clock, which jumps when NTP corrects it. Use time.perf_counter():

python
import time

start = time.perf_counter()
sum(range(1_000_000))                       # do the work
elapsed = time.perf_counter() - start
print(f"{elapsed:.4f}s")                    # ~0.0150s

time.sleep(seconds) pauses execution — handy for polling intervals or rate limiting. Both functions ship with the stdlib; no install needed.


9. Best Practices

  • Store UTC. Display local. Persist datetimes in UTC (or as ISO 8601 with offset); convert with astimezone(ZoneInfo(user_tz)) only when rendering for a human.
  • Be explicit about timezone. Every aware datetime is safer than every "I'm pretty sure this is UTC" datetime.
  • Date-only operations use date, not datetime. Birthdays don't have timezones.
  • ISO 8601 for serialization. isoformat() out, fromisoformat() in.
  • pendulum and arrow are popular third-party libraries with friendlier APIs. The modern stdlib (datetime + zoneinfo) is enough for 95% of cases — reach for them only if you're doing heavy duration arithmetic.

Common Mistakes

  • Naive datetime.now() passed to a timezone-aware system. Database stores it as if it's UTC; users in other zones see times shifted by hours. Always tag with tz=timezone.utc.
  • Subtracting a naive from an aware datetime. TypeError. Make both aware, or both naive — never mix.
  • Storing local time in a database. DST jumps break ordering (an hour disappears in spring, repeats in autumn). Always store UTC.
  • Using time.time() for measurements. Wall clock; jumps with NTP corrections. Use time.perf_counter().
  • Confusing %m and %M. %m is month, %M is minute. The number of times this bites people is embarrassing.
  • Adding "30 days" to mean "one month". Months are variable. Use dateutil.relativedelta(months=1) for true calendar arithmetic.

🎯 Your Turn — Business Days Between Two Dates

Write business_days_between(start, end) that returns the count of weekdays (Mon-Fri) between two date objects, exclusive of the start, inclusive of the end. Holidays don't count — just weekends.

Use date, not datetime — this is calendar arithmetic, no clocks involved.

python
from datetime import date, timedelta

def business_days_between(start, end):
    """Return the number of weekdays (Mon-Fri) strictly after `start`
    and up to & including `end`. If end <= start, return 0.
    """
    # TODO 1: handle the empty/reversed case
    # TODO 2: iterate from start+1 day to end, counting weekdays
    # TODO 3: weekdays have weekday() in 0..4 (Mon=0, Sun=6)
    pass

# Tests
print(business_days_between(date(2026, 5, 11), date(2026, 5, 15)))   # 4  (Tue, Wed, Thu, Fri)
print(business_days_between(date(2026, 5, 15), date(2026, 5, 18)))   # 1  (Mon — skips Sat/Sun)
print(business_days_between(date(2026, 5, 14), date(2026, 5, 14)))   # 0  (same day)
print(business_days_between(date(2026, 5, 20), date(2026, 5, 14)))   # 0  (reversed)
Hint 1 — Iterating days You can't iterate a date directly, but you can step with timedelta(days=1). Start at start + timedelta(days=1) and increment until you pass end.
Hint 2 — Weekdays only d.weekday() returns 0 for Monday through 6 for Sunday. Saturday is 5, Sunday is 6. A weekday satisfies d.weekday() < 5.
Show full solution
python
from datetime import date, timedelta

def business_days_between(start, end):
    if end <= start:
        return 0
    count = 0
    current = start + timedelta(days=1)
    while current <= end:
        if current.weekday() < 5:           # Mon-Fri
            count += 1
        current += timedelta(days=1)
    return count

print(business_days_between(date(2026, 5, 11), date(2026, 5, 15)))   # 4
print(business_days_between(date(2026, 5, 15), date(2026, 5, 18)))   # 1
print(business_days_between(date(2026, 5, 14), date(2026, 5, 14)))   # 0
print(business_days_between(date(2026, 5, 20), date(2026, 5, 14)))   # 0

A faster O(1) version exists using divmod on the day count, but the loop is clear and fast enough for typical ranges (a year is 365 iterations — microseconds).


What You Learned

  • The four types: date (calendar day), time (clock reading), datetime (moment), timedelta (span).
  • Naive vs aware: aware has a tzinfo, naive doesn't. Mix them and Python raises TypeError.
  • Use datetime.now(tz=timezone.utc) — never bare datetime.now() or the deprecated utcnow().
  • zoneinfo.ZoneInfo("Area/City") for named timezones; astimezone() to convert.
  • strptime/strftime for human formats; fromisoformat/isoformat for storage. ISO 8601 wins.
  • timedelta arithmetic with days/hours/minutes/seconds — no months or years (use relativedelta).
  • time.perf_counter() for measurements, never time.time().
  • Store UTC, display local. Repeat as needed.

Next: collections — Counter, defaultdict, deque, and the stdlib power tools you'll reach for in every project.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.