Dates, Times, and Timezones
1 · The lesson
readAlmost 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
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 asdt.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
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:
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.
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:
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:
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:
| Code | Meaning | Example |
|---|---|---|
%Y | 4-digit year | 2026 |
%m | 2-digit month | 05 |
%d | 2-digit day | 14 |
%H | 2-digit hour 0-23 | 09 |
%M | 2-digit minute | 30 |
%S | 2-digit second | 00 |
Two more worth knowing:
| Code | Meaning | Example |
|---|---|---|
%z | UTC offset | +0530 |
%A | Full weekday name | Thursday |
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.
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:
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:
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:
# 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():
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, notdatetime. Birthdays don't have timezones. - ISO 8601 for serialization.
isoformat()out,fromisoformat()in. pendulumandarroware 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 withtz=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. Usetime.perf_counter(). - Confusing
%mand%M.%mis month,%Mis 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.
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 adate 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
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 raisesTypeError. - Use
datetime.now(tz=timezone.utc), not plaindatetime.now()or the deprecatedutcnow(). zoneinfo.ZoneInfo("Area/City")for named timezones, andastimezone()to convert.strptime/strftimefor human formats, andfromisoformat/isoformatfor storage. ISO 8601 is the best format for storage.timedeltaarithmetic withdays/hours/minutes/seconds. It has no months or years (userelativedelta).time.perf_counter()for measurements, nevertime.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.inShort 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.