Python Datetime

The datetime module is Python’s built-in toolkit for working with dates, times, and the arithmetic between them. Almost every real program that logs events, schedules tasks, calculates ages, or talks to an API with timestamps needs it sooner or later. This lesson covers the module A to Z: how its objects are built internally, how to format and parse dates as text, how to do date math safely with timedelta, and how to handle time zones without falling into the classic naive-vs-aware trap.

Overview / How datetime Works

The datetime module ships four main object types, and understanding how they relate to each other is the key to using the module well:

  • date — a calendar date: year, month, day. No time-of-day information at all.
  • time — a time-of-day: hour, minute, second, microsecond. No date information, and by itself it is not tied to any particular day.
  • datetime — a combination of date and time in a single object. This is the class you will use most often.
  • timedelta — a duration, i.e. the difference between two points in time. You add or subtract a timedelta from a date or datetime to shift it forward or backward.

There are also two smaller but important pieces: tzinfo, an abstract base class that represents time zone information, and timezone, a concrete, simple implementation of tzinfo that represents a fixed UTC offset (like +05:30). For real-world time zones that observe daylight saving time, Python 3.9+ ships a separate standard-library module, zoneinfo, which reads time zone rules from the IANA database (names like "America/New_York" or "Asia/Tokyo").

A crucial distinction runs through the whole module: naive vs aware objects. A naive datetime has no tzinfo attached — it is just a bag of numbers with no notion of which time zone (or even which planet) those numbers apply to. An aware datetime has a tzinfo object attached, which anchors it to a specific offset from UTC. Python will not let you compare or subtract a naive object and an aware object — it has no way to know what offset to assume for the naive one, so it raises a TypeError rather than guess.

Internally, every date and datetime object stores its fields (year, month, day, and for datetime also hour, minute, second, microsecond, and an optional tzinfo reference) in a compact C struct, and the objects are immutable — once created, none of those fields can be reassigned. Any “modification” you perform, such as adding a timedelta or calling .replace(), actually builds and returns a brand-new object. Dates are computed on the proleptic Gregorian calendar, meaning the modern Gregorian calendar rules are extended backward and forward indefinitely, even to dates before the calendar was historically adopted in 1582.

Syntax

The core constructors all take positional or keyword arguments in this order:

Constructor Form
date date(year, month, day)
time time(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
datetime datetime(year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
timedelta timedelta(days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0)

To convert between datetime objects and text, you use two mirror-image methods: strftime (“string format time”, object → string) and strptime (“string parse time”, string → object). Both rely on the same set of format codes:

Code Meaning Example
%Y 4-digit year 2026
%y 2-digit year 26
%m month, zero-padded 07
%d day of month, zero-padded 19
%H hour, 24-hour, zero-padded 14
%I hour, 12-hour, zero-padded 02
%M minute, zero-padded 30
%S second, zero-padded 00
%f microsecond, 6 digits 000000
%p AM/PM PM
%A / %a full / abbreviated weekday name Sunday / Sun
%B / %b full / abbreviated month name July / Jul
%z UTC offset +0000
%Z time zone name UTC
%j day of year, zero-padded 200

Watch that table closely: %m is month and %M is minute — mixing them up is one of the most common bugs in date-formatting code, covered below.

Examples

Example 1: Creating and inspecting a datetime

from datetime import datetime

meeting = datetime(2026, 7, 19, 14, 30, 0)

print(meeting)
print(meeting.year, meeting.month, meeting.day)
print(meeting.hour, meeting.minute)
print(meeting.weekday())      # Monday=0 ... Sunday=6
print(meeting.strftime("%A, %B %d, %Y"))

Output:

2026-07-19 14:30:00
2026 7 19
14 30
6
Sunday, July 19, 2026

Building a datetime directly from numbers gives you an object with all the fields you’d expect as read-only attributes. weekday() returns an integer where Monday is 0 and Sunday is 6 (use isoweekday() if you want Monday=1..Sunday=7 instead). strftime turns the object into a human-readable string using the format codes from the table above.

Example 2: Formatting and parsing

from datetime import datetime

order_placed = datetime(2026, 3, 5, 9, 8, 0)
formatted = order_placed.strftime("%Y-%m-%d %I:%M %p")
print(formatted)

log_line = "2026-03-05 09:08:00"
parsed = datetime.strptime(log_line, "%Y-%m-%d %H:%M:%S")
print(parsed)
print(parsed == order_placed)

iso = order_placed.isoformat()
print(iso)
print(datetime.fromisoformat(iso) == order_placed)

Output:

2026-03-05 09:08 AM
2026-03-05 09:08:00
True
2026-03-05T09:08:00
True

strftime produces a string using whatever layout you specify. strptime does the reverse: it takes a string and a matching format, and raises ValueError if the string doesn’t fit the pattern. isoformat() and fromisoformat() are shortcuts for the ISO 8601 standard (YYYY-MM-DDTHH:MM:SS) and are the preferred way to serialize dates for storage or APIs, since they need no custom format string at all.

Example 3: Date arithmetic with timedelta

from datetime import datetime, timedelta

project_start = datetime(2026, 1, 15)
deadline = project_start + timedelta(weeks=6, days=3)
print(deadline)

remaining = deadline - datetime(2026, 2, 1)
print(remaining)
print(remaining.days)

one_week_ago = datetime(2026, 7, 19, 10, 0) - timedelta(days=7)
print(one_week_ago)

Output:

2026-03-01 00:00:00
28 days, 0:00:00
28
2026-07-12 10:00:00

Adding a timedelta to a datetime produces a new, shifted datetime; subtracting two datetime objects produces a timedelta representing the gap between them. timedelta automatically normalizes units — weeks=6, days=3 becomes 45 days internally — and it correctly accounts for varying month lengths, which is exactly why you should never try to add “one month” by hand-adding 30 days.

Example 4: Time zone–aware datetimes

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

utc_now = datetime(2026, 7, 19, 18, 0, tzinfo=timezone.utc)
ny_time = utc_now.astimezone(ZoneInfo("America/New_York"))
tokyo_time = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))

print(utc_now)
print(ny_time)
print(tokyo_time)

Output:

2026-07-19 18:00:00+00:00
2026-07-19 14:00:00-04:00
2026-07-20 03:00:00+09:00

Attaching tzinfo=timezone.utc makes the object aware. astimezone() converts it to another time zone while keeping it pointing at the same instant — note how the Tokyo time rolls over to the next calendar day, since Tokyo is far ahead of UTC. New York shows a -04:00 offset rather than the usual -05:00 because July falls inside U.S. daylight saving time; zoneinfo handles that DST transition automatically, which a fixed timezone offset cannot do.

How It Works Step by Step / Under the Hood

A few mechanics are worth understanding so the module stops feeling mysterious:

  • Immutability and .replace(). Since fields can’t be reassigned, “changing” a date means creating a copy with one or more fields overridden: new_date = old_date.replace(year=2027) returns a fresh object; old_date is untouched.
  • Comparison. Two datetime objects compare by their fields in order (year, then month, then day, then hour, and so on), similar to comparing tuples. Aware datetimes are first converted to a common reference (UTC) before comparing, which is exactly why an aware and a naive datetime can’t be compared — there is no common reference for the naive one.
  • The epoch and timestamps. Under the hood, many systems (and Python’s own time module) represent an instant as a single float: seconds since the Unix epoch, midnight January 1, 1970, UTC. datetime.fromtimestamp(ts) builds a datetime from that float (in your local time zone unless you pass a tz argument), and some_datetime.timestamp() goes the other way.
  • Naive datetimes and “now”. datetime.now() returns the current naive local time; datetime.now(timezone.utc) returns the current aware UTC time. The older datetime.utcnow() looks like it returns UTC but actually returns a naive object with UTC values and no tzinfo attached — a frequent source of bugs, and it is deprecated as of Python 3.12 in favor of datetime.now(timezone.utc).

Common Mistakes

Mistake 1: Comparing naive and aware datetimes

from datetime import datetime, timezone

naive = datetime(2026, 7, 19, 12, 0)
aware = datetime(2026, 7, 19, 12, 0, tzinfo=timezone.utc)

try:
    print(naive < aware)
except TypeError as e:
    print(f"Error: {e}")

Output:

Error: can't compare offset-naive and offset-aware datetimes

This happens whenever data from two sources — one time-zone-aware, one not — get mixed together. The fix is consistency: either attach a tzinfo to every datetime in your program (recommended, usually UTC) or keep everything naive and document the implicit time zone. Mixing the two styles is the single biggest source of datetime bugs in real codebases.

Mistake 2: Confusing %M (minute) with %m (month)

from datetime import datetime

event = datetime(2026, 9, 5, 8, 45)

wrong = event.strftime("%Y-%m-%d %H:%m")   # %m used for minutes by mistake
correct = event.strftime("%Y-%m-%d %H:%M")

print(wrong)
print(correct)

Output:

2026-09-05 08:09
2026-09-05 08:45

The format codes are case-sensitive and easy to mistype: %m is the numeric month, %M is the minute. The buggy version silently prints the month number (09) where the minute (45) belongs — no exception is raised, so this kind of mistake can slip into production logs unnoticed. Always double-check format strings against a real value while developing.

Mistake 3: Assuming datetime objects can be mutated

from datetime import datetime

appointment = datetime(2026, 7, 19, 9, 0)

try:
    appointment.year = 2027
except AttributeError as e:
    print(f"Error: {e}")

updated = appointment.replace(year=2027)
print(appointment)
print(updated)

Output:

Error: attribute 'year' of 'datetime.datetime' objects is not writable
2026-07-19 09:00:00
2027-07-19 09:00:00

date, time, and datetime objects are immutable, just like strings and tuples. There is no in-place way to change a field; use .replace(field=new_value) to get a new object with only the specified fields changed, and remember to capture the return value — the original object is never touched.

Best Practices

  • Store and pass around aware datetimes, ideally normalized to UTC, and only convert to local/display time zones at the very edge of your program (the UI or the log formatter).
  • Use zoneinfo.ZoneInfo for real-world time zones that observe daylight saving time; reserve the fixed timezone class for genuinely fixed offsets like UTC.
  • Prefer datetime.now(timezone.utc) over the deprecated datetime.utcnow(), which silently returns a naive object.
  • Use isoformat() / datetime.fromisoformat() for serialization (files, databases, JSON, APIs) instead of hand-rolled format strings — it’s unambiguous and round-trips exactly.
  • Do date math with timedelta, never by adding/subtracting raw day or second counts by hand — it correctly handles month lengths, leap years, and normalization.
  • When doing arithmetic on aware datetimes across a DST boundary, convert to UTC, do the math, then convert back to local time; adding a timedelta directly to a local aware datetime can produce surprising results around DST transitions.
  • Validate user-supplied date strings with strptime inside a try/except ValueError block rather than assuming the input is well-formed.

Practice Exercises

  • Exercise 1: Write a function calculate_age(birthdate) that takes a date object and returns the person’s current age in complete years, accounting correctly for whether their birthday has already happened this year.
  • Exercise 2: Given the string "2026-11-03 22:15:47" in the format "%Y-%m-%d %H:%M:%S", parse it into a datetime and print the full name of the weekday it falls on.
  • Exercise 3: Write a function that takes two date objects (a start and an end) and returns the number of business days (Monday through Friday) between them, excluding weekends. Hint: loop day by day using timedelta(days=1) and check .weekday().

Summary

  • The module’s core types are date, time, datetime, and timedelta; all are immutable.
  • Naive datetimes carry no time zone; aware datetimes have a tzinfo and can’t be compared with naive ones.
  • strftime converts an object to a string; strptime parses a string into an object; both use the same format-code table (don’t confuse %m with %M).
  • timedelta represents a duration and is the correct tool for date/time arithmetic.
  • Use zoneinfo.ZoneInfo for real time zones with DST, and timezone.utc for fixed UTC offsets.
  • Prefer UTC for storage and internal logic; convert to local time only for display.
  • Use .replace() to produce a modified copy, and isoformat()/fromisoformat() for reliable serialization.