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 #
| Code | Description | Example |
|---|---|---|
%Y | 4-digit year | 2024 |
%y | 2-digit year | 24 |
%m | Month (01–12) | 08 |
%B | Full month name | August |
%b | Abbreviated month name | Aug |
%d | Day (01–31) | 17 |
%A | Full weekday name | Saturday |
%a | Abbreviated weekday name | Sat |
%H | 24-hour hour (00–23) | 09 |
%I | 12-hour hour (01–12) | 09 |
%M | Minute (00–59) | 30 |
%S | Second (00–59) | 45 |
%f | Microsecond (000000) | 123456 |
%p | AM/PM | AM |
%Z | Timezone name | WIB |
%z | UTC offset (+HHMM) | +0700 |
%j | Day of the year | 230 |
%W | Week 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
endBased 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. Usedatetime.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. Usedatetime.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. UseZoneInfo("Asia/Jakarta")for WIB.- If using
pytz, usetz.localize(naive_dt)notnaive_dt.replace(tzinfo=tz)— the result can be wrong for timezones with a DST history.timedelta.secondsonly returns the seconds in the last day, not the total. Usetimedelta.total_seconds()for the total duration in seconds.- ISO 8601 (
%Y-%m-%dT%H:%M:%S) is the most recommended data-exchange format — usedt.isoformat()anddatetime.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 thantime.time(), which is affected by system clock changes.