Date & Time

Python's datetime module models calendar dates, times of day, durations, and the naive-versus-timezone-aware distinction that trips up almost everyone at least once.

The standard library's datetime module is the core tool for representing points in time and durations in Python: date, time, datetime, and timedelta. This topic covers the four core types, the critical naive-vs-aware distinction, and how to format and parse dates and times correctly. Time zone mechanics specifically (zoneinfo, DST, IANA names) are covered in the separate Time Zones topic.

What Is / Why It Matters

Almost every real program eventually needs to represent "when" -- a timestamp on a record, a duration, a deadline, a scheduled event. Doing this with raw strings or Unix timestamps quickly gets error-prone: comparing dates, adding days, formatting for display, and handling time zones all need dedicated logic. The datetime module provides typed objects for exactly this, with correct comparison, arithmetic, and formatting built in.

The date Type

datetime.date represents a calendar date -- year, month, day -- with no time-of-day component. It's the right type for birthdays, deadlines expressed as a day, or anything that doesn't care about a specific moment within the day.

from datetime import date

today = date.today()
specific = date(2024, 3, 15)

print(today)                 # e.g. 2026-09-08
print(specific)              # 2024-03-15
print(specific.year, specific.month, specific.day)
print(specific.weekday())    # 0=Monday .. 6=Sunday
print(specific.isoweekday()) # 1=Monday .. 7=Sunday

The time Type

datetime.time represents a time of day -- hour, minute, second, microsecond -- with no associated calendar date. It's less commonly used alone than date or datetime, but useful for things like "store opens at 09:00" independent of any particular day.

A bare time object, like a bare date, has no time zone unless one is explicitly attached via its tzinfo argument -- this is the same naive/aware distinction discussed below for datetime.

from datetime import time

opening = time(9, 0)
closing = time(17, 30, 0)

print(opening)             # 09:00:00
print(opening < closing)   # True -- time objects are directly comparable

The datetime Type

datetime.datetime combines a date and a time into a single point in time -- year through microsecond. It's the type you reach for most often: timestamps, log entries, event times.

from datetime import datetime

now = datetime.now()
event = datetime(2024, 3, 15, 14, 30, 0)

print(now)
print(event)
print(event.date())   # date(2024, 3, 15)
print(event.time())   # time(14, 30, 0)

# Comparisons and equality work as expected for two naive datetimes
print(event < now)

timedelta: Representing Durations

timedelta represents a duration -- a difference between two points in time, or an amount of time to add/subtract. Subtracting two date/datetime objects produces a timedelta; adding or subtracting a timedelta from a date/datetime shifts it.

timedelta normalizes internally to days, seconds, and microseconds -- timedelta(hours=25) and timedelta(days=1, hours=1) are equal and print the same way.

from datetime import datetime, timedelta

start = datetime(2024, 3, 15, 9, 0)
end = datetime(2024, 3, 15, 17, 30)

duration = end - start
print(duration)                 # 8:30:00
print(duration.total_seconds()) # 30600.0

one_week = timedelta(weeks=1)
print(start + one_week)         # 2024-03-22 09:00:00

deadline = datetime.now() + timedelta(days=3, hours=12)
print(deadline)

Naive Versus Aware datetimes

This is the single most important distinction in the module. A *naive* datetime (or time) has no attached time zone information -- it's just a set of numbers with no context for what time zone, if any, they're relative to. An *aware* datetime has a tzinfo attached, unambiguously anchoring it to a specific moment in real time (e.g. UTC, or a specific IANA zone).

Naive and aware datetimes cannot be compared or subtracted from each other -- Python raises TypeError rather than guessing which time zone the naive value was supposed to mean. datetime.now() returns a naive local-time value by default; datetime.now(timezone.utc) returns an aware UTC value.

Best practice: store and pass around aware datetimes (ideally in UTC) everywhere except at the presentation edge, where you convert to a user's local zone only for display. Mixing naive and aware values in the same codebase is a frequent source of subtle bugs.

from datetime import datetime, timezone

naive = datetime(2024, 3, 15, 12, 0)
print(naive.tzinfo)          # None -- naive

aware = datetime(2024, 3, 15, 12, 0, tzinfo=timezone.utc)
print(aware.tzinfo)          # UTC -- aware

print(naive.utcoffset())     # None
print(aware.utcoffset())     # timedelta(0)
from datetime import datetime, timezone

naive = datetime(2024, 3, 15, 12, 0)
aware = datetime(2024, 3, 15, 12, 0, tzinfo=timezone.utc)

try:
    naive - aware
except TypeError as exc:
    print("error:", exc)

ISO 8601 Formatting

ISO 8601 is the standard, unambiguous text format for dates and times (YYYY-MM-DD, HH:MM:SS, and the combined YYYY-MM-DDTHH:MM:SS, optionally with a UTC offset like +00:00 or Z). .isoformat() produces it; date.fromisoformat() / time.fromisoformat() / datetime.fromisoformat() parse it back.

Since Python 3.11, datetime.fromisoformat() accepts a much wider range of valid ISO 8601 variants, including a trailing 'Z' for UTC -- on 3.10 and earlier, 'Z' must be replaced with '+00:00' first.

from datetime import datetime, timezone

dt = datetime(2024, 3, 15, 14, 30, tzinfo=timezone.utc)
text = dt.isoformat()
print(text)                              # 2024-03-15T14:30:00+00:00

parsed = datetime.fromisoformat(text)
print(parsed == dt)                      # True
print(parsed.tzinfo)                     # UTC -- offset was preserved

Parsing and Formatting with strptime/strftime

For non-ISO formats, datetime.strptime(text, format) parses a string according to a format string, and .strftime(format) formats a datetime into a string, using the same %-directive mini-language (%Y four-digit year, %m month, %d day, %H/%M/%S time, %A weekday name, etc.).

strptime/strftime produce and consume naive datetimes by default (no offset directive used) -- prefer ISO 8601 with fromisoformat/isoformat when you control the format, and reserve strptime/strftime for interoperating with external formats you don't control.

from datetime import datetime

parsed = datetime.strptime("15/03/2024 14:30", "%d/%m/%Y %H:%M")
print(parsed)                                    # 2024-03-15 14:30:00

formatted = parsed.strftime("%A, %B %d, %Y")
print(formatted)                                  # Friday, March 15, 2024

Comparing, Sorting, and Practical Usage

date, time, and datetime objects all support the usual comparison operators (<, <=, ==, >=, >) directly, and sort naturally with sorted() -- no custom key function needed as long as all values are consistently naive or consistently aware.

from datetime import datetime

events = [
    datetime(2024, 3, 15, 9, 0),
    datetime(2024, 1, 1, 0, 0),
    datetime(2024, 3, 15, 17, 30),
]
print(sorted(events))
print(min(events), max(events))

Edge Cases

Comparing or subtracting a naive and an aware datetime raises TypeError -- this is intentional, not a bug to work around by stripping tzinfo; convert one side to match the other's awareness deliberately instead.

datetime.min/datetime.max mark the smallest/largest representable datetime and are naive by default -- useful as sentinel bounds but easy to accidentally mix with aware values.

Leap years affect date arithmetic (date(2024, 2, 29) is valid; date(2023, 2, 29) raises ValueError) -- always construct dates from validated components rather than assuming every year has the same days-in-February.

timedelta has no month or year unit, because months/years vary in length -- adding "one month" is not well-defined as a fixed duration; for calendar-aware month/year arithmetic use the third-party dateutil.relativedelta (not standard library) or compute it manually.

Complete Example

The function explicitly requires aware datetimes, raising rather than silently guessing a time zone -- exactly the discipline the naive/aware distinction calls for.

from datetime import datetime, timedelta, timezone

def format_time_until(target: datetime, now: datetime | None = None) -> str:
    """Given an aware target datetime, describe the time remaining."""
    now = now or datetime.now(timezone.utc)
    if target.tzinfo is None or now.tzinfo is None:
        raise ValueError("both datetimes must be timezone-aware")

    remaining = target - now
    if remaining.total_seconds() <= 0:
        return "already passed"

    days, seconds = remaining.days, remaining.seconds
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    return f"{days}d {hours}h {minutes}m remaining"

deadline = datetime(2024, 3, 20, 12, 0, tzinfo=timezone.utc)
now = datetime(2024, 3, 15, 9, 0, tzinfo=timezone.utc)
print(format_time_until(deadline, now))   # 5d 3h 0m remaining

Quick Reference

date(y, m, d)                calendar date, no time
time(h, m, s, us)             time of day, no date
datetime(y, m, d, h, m, s)    combined date + time
timedelta(days=, hours=, ...) a duration
datetime.now()                naive, local time
datetime.now(timezone.utc)    aware, UTC
obj.isoformat()                -> "2024-03-15T14:30:00+00:00"
Class.fromisoformat(text)     parse ISO 8601
datetime.strptime(text, fmt)  parse a custom format
obj.strftime(fmt)             format with a custom format
naive - aware  -> TypeError   never mix awareness without converting

Key Takeaways

date, time, and datetime model calendar dates, times of day, and combined moments respectively; timedelta models a duration between them.

A datetime is naive (no time zone context) unless it has a tzinfo attached, in which case it's aware and unambiguously anchored to real time.

Naive and aware values cannot be compared or subtracted -- Python raises TypeError rather than guessing, which is a safety feature, not a bug.

Prefer isoformat()/fromisoformat() for ISO 8601 text; use strftime/strptime only when interoperating with a non-ISO external format.

timedelta has no month/year unit because those vary in length; use the third-party dateutil for calendar-aware month/year arithmetic if you need it.

Store and pass aware datetimes (ideally UTC) internally; convert to local time only at the display edge.

now = datetime.now() utc_now = datetime.now(timezone.utc) elapsed = utc_now - now # TypeError utc_now = datetime.now(timezone.utc) local_as_utc = now.astimezone(timezone.utc) # explicit conversion first elapsed = utc_now - local_as_utc

Subtracting a naive and an aware datetime raises TypeError; convert explicitly to a shared awareness before comparing or subtracting.

deadline = event + timedelta(months=1) # timedelta has no 'months' timedelta only understands fixed units (weeks/days/hours/...); months and years have variable length and aren't representable as a fixed timedelta.

text = str(some_datetime) # locale/format not guaranteed stable text = some_datetime.isoformat() isoformat() produces the unambiguous, parseable ISO 8601 representation; relying on str()'s default formatting for interchange is fragile.

What is the difference between date, time, and datetime?

date represents a calendar date with no time component; time represents a time of day with no date; datetime combines both into a single point in time.

What does it mean for a datetime to be 'naive' versus 'aware'?

A naive datetime has no tzinfo attached and carries no time zone context; an aware datetime has a tzinfo attached, unambiguously anchoring it to a specific moment in real time (e.g. relative to UTC).

What happens if you try to subtract a naive datetime from an aware one, and why does Python behave this way?

Python raises TypeError. It refuses to guess what time zone the naive value implicitly means, since assuming one could silently produce a wrong result; the caller must explicitly resolve the ambiguity.

Why does timedelta not support a 'months' or 'years' argument?

Because months and years have variable length (28-31 days, 365/366 days), so 'one month later' isn't a fixed duration -- it depends on which month. timedelta only models fixed-length units (weeks, days, hours, minutes, seconds, microseconds).

What is the practical difference between datetime.now() and datetime.now(timezone.utc)?

datetime.now() returns a naive datetime representing local time with no attached time zone; datetime.now(timezone.utc) returns an aware datetime representing the current moment in UTC.

When should you prefer isoformat()/fromisoformat() over strftime()/strptime()?

Whenever you control the text format and just need reliable round-tripping -- ISO 8601 is unambiguous and the standard interchange format. Reserve strftime/strptime for parsing or producing a specific external format you don't control.

Why does date(2023, 2, 29) raise ValueError while date(2024, 2, 29) succeeds?

2024 is a leap year (divisible by 4, and by the full leap-year rule) so February has 29 days; 2023 is not a leap year, so February 29 is not a valid date in that year -- date validates calendar correctness at construction time.

Write a function days_until(target_date) that returns the number of whole days from today until target_date (a date object), using date arithmetic.

from datetime import date def days_until(target_date: date) -> int: return (target_date - date.today()).days print(days_until(date(2030, 1, 1)))

A positive integer if target_date is in the future, negative if in the past, 0 if it's today -- subtracting two date objects yields a timedelta, and .days gives the whole-day count.

If timedelta_placeholder is timedelta(days=1), what does this print, and what is the resulting month?

2024-02-01 10:00:00 -- datetime addition correctly rolls over into the next month (and would roll into the next year at a December 31 boundary), since timedelta arithmetic operates on the underlying calendar-aware date, not just the day number.

Write a function parse_us_date(text) that parses a date string in 'MM/DD/YYYY' format (e.g. '03/15/2024') into a date object.

from datetime import datetime def parse_us_date(text: str) -> "date": return datetime.strptime(text, "%m/%d/%Y").date() print(parse_us_date("03/15/2024")) date(2024, 3, 15) -- strptime parses according to the %m/%d/%Y format string, and.date() extracts just the date portion, discarding the (zeroed) time.

from datetime import datetime
d1 = datetime(2024, 1, 31, 10, 0)
d2 = d1 + timedelta_placeholder
print(d2)