Date & Time #

Dates and times look easy until you start dealing with time zones, different formats, and complex duration calculations. Python’s stdlib datetime module covers all of these comprehensively. The most important thing to understand — and the most often implemented wrong — is the difference between naive datetimes (no timezone information) and aware datetimes (with a timezone). Using naive datetimes in an app serving users across many time zones is a recipe for bugs that are painful to debug. This article covers every aspect of time management in Python comprehensively.

Classes in the datetime Module #

The datetime module provides four main interrelated classes:

from datetime import date, time, datetime, timedelta, timezone

# date — date only (year, month, day)
# time — time only (hour, minute, second, microsecond)
# datetime — date + time combined
# timedelta — duration / time difference
# timezone — simple timezone representation (fixed offset)
Relationships between the classes:

date(2024, 3, 15)       → date only
time(14, 30, 0)         → time only
datetime(2024, 3, 15,
         14, 30, 0)     → date + time

datetime = date + time  (combined)
datetime - datetime     = timedelta
datetime + timedelta    = datetime

date — Date Only #

from datetime import date

# Creating date objects
today = date.today()
print(today)          # → 2024-03-15

specific_date = date(2024, 8, 17)   # (year, month, day)
print(specific_date)  # → 2024-08-17

# Component attributes
print(today.year)    # → 2024
print(today.month)   # → 3
print(today.day)     # → 15

# Day information
print(today.weekday())       # → 0–6, Monday=0, Sunday=6
print(today.isoweekday())    # → 1–7, Monday=1, Sunday=7
print(today.strftime("%A"))  # → weekday name in the system language

# Conversion
print(today.isoformat())     # → "2024-03-15"
print(today.timetuple())     # → time.struct_time(...)

# Create a date from an ISO string
from_iso = date.fromisoformat("2024-08-17")
print(from_iso)   # → 2024-08-17

# Create a date from an ordinal (days since January 1, 0001)
from_ordinal = date.fromordinal(738969)
print(from_ordinal)

datetime — Date and Time #

from datetime import datetime

# Current time (naive — no timezone)
now = datetime.now()
print(now)          # → 2024-03-15 14:30:45.123456

# Create a specific datetime
dt = datetime(2024, 8, 17, 9, 0, 0)   # (year, month, day, hour, minute, second)
print(dt)   # → 2024-08-17 09:00:00

# Component attributes
print(now.year)        # → 2024
print(now.month)       # → 3
print(now.day)         # → 15
print(now.hour)        # → 14
print(now.minute)      # → 30
print(now.second)      # → 45
print(now.microsecond) # → 123456
print(now.weekday())   # → 4 (Friday)

# Extract the date and time parts
print(now.date())   # → 2024-03-15  (a date object)
print(now.time())   # → 14:30:45.123456  (a time object)

# Replace specific components (creates a new object)
new_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
print(new_dt)   # → 2024-03-15 00:00:00

# Combine a date and a time
d = date(2024, 8, 17)
t = time(9, 30, 0)
combined = datetime.combine(d, t)
print(combined)   # → 2024-08-17 09:30:00

timedelta — Durations and Time Differences #

timedelta represents a duration — usable for arithmetic on date and datetime objects:

from datetime import datetime, timedelta

now = datetime.now()

# Creating timedeltas
one_day   = timedelta(days=1)
one_hour  = timedelta(hours=1)
one_week  = timedelta(weeks=1)
mixed     = timedelta(days=2, hours=3, minutes=30, seconds=15)

# Arithmetic
tomorrow  = now + timedelta(days=1)
yesterday = now - timedelta(days=1)
in_two_hours = now + timedelta(hours=2)

print(tomorrow.date())      # → 2024-03-16
print(yesterday.date())     # → 2024-03-14

# The difference between two datetimes produces a timedelta
start = datetime(2024, 1, 1)
end = datetime(2024, 3, 15, 14, 30)
duration = end - start

print(duration)               # → 74 days, 14:30:00
print(duration.days)          # → 74
print(duration.seconds)       # → 52200 (seconds in the LAST day, not the total!)
print(duration.total_seconds()) # → 6442200.0  ← use this for the total seconds

# Convert a duration to hours/minutes
total_seconds = int(duration.total_seconds())
hours   = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
print(f"{hours} hours {minutes} minutes")   # → 1789 hours 30 minutes
# ANTI-PATTERN: using .seconds for the total duration
delta = timedelta(days=2, hours=5)
print(delta.seconds)          # → 18000 (only the seconds in the LAST day!)
print(delta.total_seconds())  # → 190800.0  ← this is the correct total

# CORRECT: always use total_seconds() when you need the total duration in seconds

Parsing and Formatting #

strptime — Parse a String into a datetime #

from datetime import datetime

# strptime(string, format) — parse a string into a datetime
dt1 = datetime.strptime("2024-08-17", "%Y-%m-%d")
dt2 = datetime.strptime("17/08/2024 09:30", "%d/%m/%Y %H:%M")
dt3 = datetime.strptime("17 August 2024", "%d %B %Y")
dt4 = datetime.strptime("2024-08-17T09:30:00", "%Y-%m-%dT%H:%M:%S")

print(dt1)   # → 2024-08-17 00:00:00
print(dt2)   # → 2024-08-17 09:30:00

strftime — Format a datetime into a String #

dt = datetime(2024, 8, 17, 9, 30, 45)

print(dt.strftime("%Y-%m-%d"))             # → 2024-08-17
print(dt.strftime("%d/%m/%Y"))             # → 17/08/2024
print(dt.strftime("%d %B %Y"))             # → 17 August 2024
print(dt.strftime("%H:%M:%S"))             # → 09:30:45
print(dt.strftime("%Y-%m-%dT%H:%M:%S"))   # → 2024-08-17T09:30:45
print(dt.strftime("%A, %d %B %Y"))         # → Saturday, 17 August 2024
print(dt.strftime("%I:%M %p"))             # → 09:30 AM  (12-hour)

Important Format Codes Table #

CodeDescriptionExample
%Y4-digit year2024
%y2-digit year24
%mMonth (01–12)08
%BFull month nameAugust
%bAbbreviated month nameAug
%dDay (01–31)17
%AFull weekday nameSaturday
%aAbbreviated weekday nameSat
%H24-hour hour (00–23)09
%I12-hour hour (01–12)09
%MMinute (00–59)30
%SSecond (00–59)45
%fMicrosecond (000000)123456
%pAM/PMAM
%ZTimezone nameWIB
%zUTC offset (+HHMM)+0700
%jDay of the year230
%WWeek number (Mon=start)33

ISO 8601 — The International Standard Format #

from datetime import datetime, timezone

# isoformat() — produce an ISO 8601 string
dt = datetime(2024, 8, 17, 9, 30, 45)
print(dt.isoformat())              # → 2024-08-17T09:30:45
print(dt.isoformat(sep=" "))       # → 2024-08-17 09:30:45
print(dt.isoformat(timespec="minutes"))  # → 2024-08-17T09:30

# fromisoformat() — parse an ISO 8601 string (Python 3.7+)
dt = datetime.fromisoformat("2024-08-17T09:30:45")
print(dt)   # → 2024-08-17 09:30:45

# Python 3.11+ supports a fuller ISO format including Z
# dt = datetime.fromisoformat("2024-08-17T09:30:45Z")

Unix Timestamps #

A Unix timestamp is the number of seconds since January 1, 1970 00:00:00 UTC — a common format for storing times in databases and APIs:

from datetime import datetime, timezone
import time as time_module

# Get the current timestamp
current_ts = time_module.time()
print(current_ts)   # → 1710506445.123456

# Convert a datetime to a timestamp
dt = datetime(2024, 8, 17, 9, 0, 0, tzinfo=timezone.utc)
ts = dt.timestamp()
print(ts)   # → 1723885200.0

# Convert a timestamp to a datetime (local time)
dt_local = datetime.fromtimestamp(ts)
print(dt_local)

# Convert a timestamp to a UTC datetime
dt_utc = datetime.fromtimestamp(ts, tz=timezone.utc)
print(dt_utc)   # → 2024-08-17 09:00:00+00:00

Naive vs Aware Datetimes #

This is the most important — and most often misimplemented — concept when working with time. To make the difference between the two object types easier to grasp, along with how text conversion works using strptime and strftime, look at the visualization below:

flowchart TD
    subgraph Konsep ["Datetime Object Types"]
        Naive["Naive Datetime<br/>(Local time value only)<br/>Example: 2026-06-21 10:30<br/>tzinfo = None"]
        
        Aware["Aware Datetime<br/>(Local time + zone info)<br/>Example: 2026-06-21 10:30+07:00<br/>tzinfo = ZoneInfo(...)"]
    end

    subgraph Konversi ["Parsing & Formatting Flow"]
        Str["String / Text Data<br/>Example: '2026-06-21 10:30'"]
        Obj["Datetime Object"]
        
        Str -->|"strptime / parsing"| Obj
        Obj -->|"strftime / formatting"| Str
    end

Based on the diagram above:

  • Naive Datetimes have no geographic context, so they’re prone to ambiguity when read by systems in different time zones.
  • Aware Datetimes bind timezone information explicitly, making their value absolute no matter where the system is.
  • strptime (string parse time) reads text into an object, while strftime (string format time) prints a time object as text.
Naive datetime  → doesn't know the timezone → dangerous in multi-timezone apps
Aware datetime  → knows its timezone → always use this for storing times
from datetime import datetime, timezone

# Naive — no timezone info
naive = datetime.now()
print(naive.tzinfo)   # → None

# Aware — has timezone info
aware_utc = datetime.now(tz=timezone.utc)
print(aware_utc.tzinfo)   # → UTC

# ANTI-PATTERN: storing time as naive and hoping everything is UTC
created_at = datetime.now()            # naive — which server timezone?
updated_at = datetime.utcnow()        # still naive! even though the value is UTC

# CORRECT: always use aware datetimes with UTC for storage
created_at = datetime.now(tz=timezone.utc)       # aware UTC
updated_at = datetime.now(tz=timezone.utc)       # aware UTC
datetime.utcnow() returns a naive datetime whose value happens to be UTC — but Python doesn’t know it’s UTC. This is a classic timezone bug source. Use datetime.now(tz=timezone.utc) which returns an aware datetime and is unambiguous. utcnow() has been deprecated since Python 3.12.

Time Zones with zoneinfo (Python 3.9+) #

zoneinfo is the new stdlib module that replaces pytz for most cases:

from datetime import datetime
from zoneinfo import ZoneInfo

# Create aware datetimes with a specific timezone
wib = ZoneInfo("Asia/Jakarta")       # UTC+7
wita = ZoneInfo("Asia/Makassar")     # UTC+8
wit = ZoneInfo("Asia/Jayapura")      # UTC+9
utc = ZoneInfo("UTC")

# Current time in a specific zone
now_wib = datetime.now(tz=wib)
print(now_wib)   # → 2024-03-15 14:30:45.123456+07:00

# Convert between time zones
now_utc = datetime.now(tz=utc)
now_wib  = now_utc.astimezone(wib)
now_wita = now_utc.astimezone(wita)
now_wit  = now_utc.astimezone(wit)

print(now_utc.strftime("%H:%M %Z"))    # → 07:30 UTC
print(now_wib.strftime("%H:%M %Z"))    # → 14:30 WIB
print(now_wita.strftime("%H:%M %Z"))   # → 15:30 WITA
print(now_wit.strftime("%H:%M %Z"))    # → 16:30 WIT
# List all available time zones
import zoneinfo
asia_zones = sorted(z for z in zoneinfo.available_timezones() if z.startswith("Asia/"))
print(asia_zones[:5])   # → ['Asia/Aden', 'Asia/Almaty', 'Asia/Amman', ...]

pytz — For Python < 3.9 Compatibility #

# If you need to support Python < 3.9, use pytz
# pip install pytz

import pytz
from datetime import datetime

wib = pytz.timezone("Asia/Jakarta")

# ANTI-PATTERN with pytz: don't replace tzinfo directly
naive = datetime(2024, 8, 17, 9, 0, 0)
# wrong = naive.replace(tzinfo=wib)  # ← WRONG for timezones with historical DST

# CORRECT with pytz: always use localize() for naive → aware
aware = wib.localize(naive)
print(aware)   # → 2024-08-17 09:00:00+07:00

# Zone conversion
utc = pytz.utc
aware_utc = aware.astimezone(utc)
print(aware_utc)   # → 2024-08-17 02:00:00+00:00

Comparing and Sorting Datetimes #

from datetime import datetime, timezone

dt1 = datetime(2024, 1, 1)
dt2 = datetime(2024, 6, 15)
dt3 = datetime(2024, 1, 1)

# Comparison
print(dt1 < dt2)    # → True
print(dt1 == dt3)   # → True
print(dt2 > dt1)    # → True

# Find the minimum and maximum
date_list = [datetime(2024, 3, 15), datetime(2024, 1, 1), datetime(2024, 12, 25)]
print(min(date_list))   # → 2024-01-01 00:00:00
print(max(date_list))   # → 2024-12-25 00:00:00

# Sort a list of datetimes
sorted_list = sorted(date_list)
print(sorted_list)

# DON'T compare naive and aware — you'll get a TypeError
naive = datetime(2024, 1, 1)
aware = datetime(2024, 1, 1, tzinfo=timezone.utc)
# naive < aware  # → TypeError: can't compare offset-naive and offset-aware datetimes

Practical Calculations #

from datetime import datetime, timedelta, date
from zoneinfo import ZoneInfo

wib = ZoneInfo("Asia/Jakarta")

# Start and end of day
def start_of_day(dt: datetime) -> datetime:
    return dt.replace(hour=0, minute=0, second=0, microsecond=0)

def end_of_day(dt: datetime) -> datetime:
    return dt.replace(hour=23, minute=59, second=59, microsecond=999999)

now = datetime.now(tz=wib)
print(start_of_day(now))   # → 2024-03-15 00:00:00+07:00
print(end_of_day(now))     # → 2024-03-15 23:59:59.999999+07:00

# Start and end of month
def start_of_month(dt: datetime) -> datetime:
    return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)

def end_of_month(dt: datetime) -> datetime:
    import calendar
    last_day = calendar.monthrange(dt.year, dt.month)[1]
    return dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=999999)

# Calculate age
def compute_age(birth_date: date) -> int:
    today = date.today()
    age = today.year - birth_date.year
    # Subtract 1 if the birthday hasn't passed this year yet
    if (today.month, today.day) < (birth_date.month, birth_date.day):
        age -= 1
    return age

birth = date(1995, 8, 17)
print(f"Age: {compute_age(birth)} years")

# Count weekdays between two dates (no extra library)
def count_weekdays(start: date, end: date) -> int:
    """Count the number of weekdays (Monday–Friday) between two dates."""
    total = 0
    current = start
    while current <= end:
        if current.weekday() < 5:  # 0=Monday, 4=Friday
            total += 1
        current += timedelta(days=1)
    return total

start = date(2024, 3, 1)
end = date(2024, 3, 31)
print(f"Weekdays in March 2024: {count_weekdays(start, end)}")

Measuring Execution Time with time #

The time module (distinct from datetime.time) is useful for measuring code performance:

import time

# time.time() — seconds since the epoch (float)
start = time.time()
# ... the operation being measured ...
end = time.time()
print(f"Duration: {end - start:.4f} seconds")

# time.perf_counter() — high precision for benchmarking (recommended)
start = time.perf_counter()
sum(i**2 for i in range(1_000_000))
end = time.perf_counter()
print(f"Duration: {end - start:.6f} seconds")

# time.sleep() — pause execution
print("Starting...")
time.sleep(2)   # pause for 2 seconds
print("Done after 2 seconds")

# timeit — measure the average of many executions
import timeit
duration = timeit.timeit(
    stmt='"-".join(str(n) for n in range(100))',
    number=10_000
)
print(f"Average: {duration/10_000*1_000:.4f} ms")

Summary #

  • Always use aware datetimes for storing and comparing times in real applications. Naive datetimes without a timezone are a source of hard-to-debug bugs.
  • Avoid datetime.utcnow() — deprecated in Python 3.12. Use datetime.now(tz=timezone.utc) which returns an aware datetime.
  • zoneinfo (Python 3.9+) is the modern way to handle timezones — built-in, no pytz install needed. Use ZoneInfo("Asia/Jakarta") for WIB.
  • If using pytz, use tz.localize(naive_dt) not naive_dt.replace(tzinfo=tz) — the result can be wrong for timezones with a DST history.
  • timedelta.seconds only returns the seconds in the last day, not the total. Use timedelta.total_seconds() for the total duration in seconds.
  • ISO 8601 (%Y-%m-%dT%H:%M:%S) is the most recommended data-exchange format — use dt.isoformat() and datetime.fromisoformat().
  • Don’t compare naive and aware datetimes — Python raises a TypeError. Make sure both are in the same state before comparing.
  • time.perf_counter() for code benchmarking — more precise than time.time(), which is affected by system clock changes.

← Previous: Dictionaries   Next: Regex →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact