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

Dates, Times, and Timezones

1 · The lesson

read

Almost every real program deals with time sooner or later: log entries, schedules, expiry dates, billing periods, "5 minutes ago" labels. The datetime module gives you the basic types. The API itself is not the hard part. Timezones are. A datetime without a timezone is like a number without a unit. It looks fine until two systems understand it differently.

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, you will understand why the rule "store UTC, display local" matters.


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 doesn't matter (birthdays, due dates, holidays).
  • time: a clock time without a date. It is rare on its own, and you mostly see it as dt.time().
  • datetime: a specific moment. This is the type you will use most.
  • timedelta: a duration between two moments. It is the result of subtracting two datetimes.

Pick the smallest type that fits. A date can't be off by an hour because of daylight saving time (DST), but a datetime can.


2. "Now": Which Version to 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, and deprecated since Python 3.12
c = datetime.now(tz=timezone.utc)           # aware: the timezone is UTC. Use this.

print(a)        # 2026-05-14 09:30:00.123456
print(c)        # 2026-05-14 09:30:00.123456+00:00
print(c.tzinfo) # UTC

Avoid datetime.utcnow(). It returns a naive datetime that contains UTC values but has no timezone attached. If you pass it to timezone-aware code, you can get wrong answers without any error. Python 3.12 deprecated it for this reason.

Always use datetime.now(tz=timezone.utc) for "right now" in code that will run on more than one machine.


3. Naive vs Aware: The Source of Most Bugs

A datetime is aware if it has a tzinfo attached, naive otherwise. If you mix them, Python raises an error:

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  (this works)
# print(aware - naive)          # TypeError: can't subtract offset-naive and offset-aware datetimes

The rule: decide the timezone where data enters your system, then use aware datetimes everywhere inside it. Anything that goes over a network, into a database, or to another process should be timezone-aware. If a naive datetime gets into a shared system, someone three time zones away will see the wrong hour.


4. Named Timezones with zoneinfo

Since Python 3.9, the standard library has the zoneinfo module, which uses the IANA timezone database. You don't need the third-party pytz package.

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 calculation:

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 · re-runs 4 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from datetime import date, time, datetime, timedelta
    from datetime import datetime, timezone
    from datetime import datetime
    from zoneinfo import ZoneInfo
except Exception:
    pass
_pm_earlier = [
    'from datetime import date, time, datetime, timedelta\n\nd = date(2026, 5, 14)               # year-month-day, no time\nt = time(9, 30, 0)                  # hour-minute-second, no date\ndt = datetime(2026, 5, 14, 9, 30)   # both\ntd = timedelta(days=7, hours=3)     # a *duration*, not a moment\n\nprint(d, t, dt, td)',
    'from datetime import datetime, timezone\n\n# Three ways to get "now". Only one is safe.\na = datetime.now()                          # naive: no timezone attached\nb = datetime.utcnow()                       # naive, and deprecated since Python 3.12\nc = datetime.now(tz=timezone.utc)           # aware: the timezone is UTC. Use this.\n\nprint(a)        # 2026-05-14 09:30:00.123456\nprint(c)        # 2026-05-14 09:30:00.123456+00:00\nprint(c.tzinfo) # UTC',
    "from datetime import datetime, timezone\n\nnaive = datetime(2026, 5, 14, 9, 30)\naware = datetime(2026, 5, 14, 9, 30, tzinfo=timezone.utc)\n\nprint(aware - aware)            # 0:00:00  (this works)\n# print(aware - naive)          # TypeError: can't subtract offset-naive and offset-aware datetimes",
    'from datetime import datetime\nfrom zoneinfo import ZoneInfo\n\nindia = ZoneInfo("Asia/Kolkata")\nlaunch = datetime(2026, 5, 14, 9, 30, tzinfo=india)\nprint(launch)                               # 2026-05-14 09:30:00+05:30\nprint(launch.tzinfo)                        # Asia/Kolkata',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

The moment in time is the same. Only the local clock time 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'>

These are the six format codes you'll use most often:

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 · re-runs 5 earlier blocks on this page first
# 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')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from datetime import date, time, datetime, timedelta
    from datetime import datetime, timezone
    from datetime import datetime
    from zoneinfo import ZoneInfo
except Exception:
    pass
_pm_earlier = [
    'from datetime import date, time, datetime, timedelta\n\nd = date(2026, 5, 14)               # year-month-day, no time\nt = time(9, 30, 0)                  # hour-minute-second, no date\ndt = datetime(2026, 5, 14, 9, 30)   # both\ntd = timedelta(days=7, hours=3)     # a *duration*, not a moment\n\nprint(d, t, dt, td)',
    'from datetime import datetime, timezone\n\n# Three ways to get "now". Only one is safe.\na = datetime.now()                          # naive: no timezone attached\nb = datetime.utcnow()                       # naive, and deprecated since Python 3.12\nc = datetime.now(tz=timezone.utc)           # aware: the timezone is UTC. Use this.\n\nprint(a)        # 2026-05-14 09:30:00.123456\nprint(c)        # 2026-05-14 09:30:00.123456+00:00\nprint(c.tzinfo) # UTC',
    "from datetime import datetime, timezone\n\nnaive = datetime(2026, 5, 14, 9, 30)\naware = datetime(2026, 5, 14, 9, 30, tzinfo=timezone.utc)\n\nprint(aware - aware)            # 0:00:00  (this works)\n# print(aware - naive)          # TypeError: can't subtract offset-naive and offset-aware datetimes",
    'from datetime import datetime\nfrom zoneinfo import ZoneInfo\n\nindia = ZoneInfo("Asia/Kolkata")\nlaunch = datetime(2026, 5, 14, 9, 30, tzinfo=india)\nprint(launch)                               # 2026-05-14 09:30:00+05:30\nprint(launch.tzinfo)                        # Asia/Kolkata',
    'from datetime import datetime\n\ndt = datetime.strptime("2026-05-14 09:30:00", "%Y-%m-%d %H:%M:%S")\nprint(dt)                                   # 2026-05-14 09:30:00\nprint(type(dt))                             # <class \'datetime.datetime\'>',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

6. Formatting: strftime and isoformat

The other direction: 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 has only one meaning, it sorts correctly as a string, and almost every programming language can parse it. strftime is for display.

Its parsing partner is even more convenient:

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 the code that writes the data and the code that reads it, always use ISO 8601.


7. timedelta Arithmetic

A datetime minus a datetime gives a timedelta. A datetime plus a timedelta gives a datetime. Those are the basic rules:

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. There are no months or years, because those are not fixed durations (a "month" is anywhere from 28 to 31 days). To add calendar months or years, use 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  (relativedelta handles short months)

8. Measuring Elapsed Time

Don't use time.time() to measure how long something took. It reads the system clock, which can jump when the system corrects its time (for example, through NTP). 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, which is useful for polling intervals or rate limiting. Both functions are in the standard library, so you don't need to install anything.


9. Best Practices

  • Store UTC. Display local. Persist datetimes in UTC (or as ISO 8601 with offset), and convert with astimezone(ZoneInfo(user_tz)) only when rendering for a human.
  • Be explicit about the timezone. An aware datetime is always safer than a naive one that you think is probably UTC.
  • 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 standard library (datetime + zoneinfo) is enough for most cases. Use those libraries only if you do a lot of duration arithmetic.

Common Mistakes

  • Naive datetime.now() passed to a timezone-aware system. The database may treat it as UTC, and users in other zones see times that are wrong by several hours. Always tag with tz=timezone.utc.
  • Subtracting a naive from an aware datetime. This raises TypeError. Make both aware, or both naive. Never mix them.
  • Storing local time in a database. DST changes break the order of times (an hour disappears in spring and repeats in autumn). Always store UTC.
  • Using time.time() for measurements. It reads the system clock, which can jump when the time is corrected. Use time.perf_counter().
  • Confusing %m and %M. %m is month, %M is minute. This is a very common mistake.
  • Adding "30 days" to mean "one month". Months have different lengths. 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. Ignore holidays, and skip only weekends.

Use date, not datetime. This is calendar arithmetic, with no clock times.

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 and including `end`. If end <= start, return 0.
    """
    # TODO 1: handle the case where end is not after start
    # 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, skipping Sat and 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 over 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, which takes well under a millisecond).


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), not plain datetime.now() or the deprecated utcnow().
  • zoneinfo.ZoneInfo("Area/City") for named timezones, and astimezone() to convert.
  • strptime/strftime for human formats, and fromisoformat/isoformat for storage. ISO 8601 is the best format for storage.
  • timedelta arithmetic with days/hours/minutes/seconds. It has no months or years (use relativedelta).
  • time.perf_counter() for measurements, never time.time().
  • Store UTC, display local.

Next: collections: Counter, defaultdict, deque, and other standard-library tools you will use in almost every project.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.