Quick Answer

date holds a calendar day, time holds a clock reading, and datetime holds both. strftime formats a datetime into a string and strptime parses a string into a datetime. A naive datetime carries no timezone, so comparing it with an aware one raises TypeError and storing it loses the information about which clock it came from. Store UTC-aware datetimes, convert to Asia/Kolkata only when displaying, and use timedelta for arithmetic.

date, time, datetime and timedelta

The datetime module has an awkward name because the module and one of its classes are both called datetime. Import the classes you need and the confusion mostly disappears.

from datetime import date, time, datetime, timedelta

d = date(2026, 8, 15)                 # a calendar day, no clock
t = time(9, 30)                       # a clock reading, no day
dt = datetime(2026, 8, 15, 9, 30)     # both

print(dt.date())      # 2026-08-15
print(dt.time())      # 09:30:00
print(datetime.combine(d, t))         # 2026-08-15 09:30:00

Use date when the clock genuinely does not matter, such as a date of birth or an exam date. Use datetime for anything that happened at a moment, such as a payment or a login. Bare time objects are rarer than beginners expect, because a clock reading with no day attached cannot be compared meaningfully across days or timezones.

The most important thing to understand early is that these objects are immutable. There is no method that changes a datetime in place. Everything returns a new object, which is why replace() exists:

dt = datetime(2026, 8, 15, 9, 30)
later = dt.replace(hour=18)     # new object
print(dt.hour, later.hour)      # 9 18

Subtracting two datetimes gives a timedelta, and adding a timedelta to a datetime gives another datetime. You cannot add two datetimes together, which makes sense once you notice that the sum of two moments in time is not a meaningful quantity.

datetime.today(), datetime.now() and date.today() all read the machine's clock. That machine is your laptop while you develop and a server somewhere else in production, and that difference is the source of the bug in the third section.

strftime formats, strptime parses

These two names are almost identical and do opposite things. The p in strptime stands for parse: string in, datetime out. strftime is the format direction: datetime in, string out.

from datetime import datetime

dt = datetime(2026, 8, 15, 9, 5)

print(dt.strftime("%d-%m-%Y"))          # 15-08-2026
print(dt.strftime("%d %b %Y, %I:%M %p"))# 15 Aug 2026, 09:05 AM

back = datetime.strptime("15-08-2026", "%d-%m-%Y")
print(back)                             # 2026-08-15 00:00:00

Note that strftime is a method on the object and strptime is a class method you call on datetime itself, because there is no object yet when you are parsing.

The format codes worth memorising are small in number: %Y four-digit year, %y two-digit year, %m month number, %b short month name, %B full month name, %d day, %H hour on a 24-hour clock, %I hour on a 12-hour clock, %M minute, %S second, %p AM or PM.

The failure mode is strict and unforgiving. If the string does not match the format exactly, including separators, you get ValueError: time data '15/08/2026' does not match format '%d-%m-%Y'. This matters constantly in India, where 15-08-2026 means the fifteenth of August but the American convention would read 08-15-2026 for the same day. Parsing a CSV of dates from a mixed source without pinning the format down is how a report ends up with transactions on nonexistent days.

One genuine platform difference: to drop a leading zero, Linux and macOS accept %-d and %-m, while Windows uses %#d and %#m. Neither is in the C standard, so code using either breaks when moved. If you need 5 Aug rather than 05 Aug, build it in Python with f"{dt.day} {dt:%b}" and stay portable.

Why naive datetimes cause production bugs

Every datetime object is either naive or aware. An aware datetime has a tzinfo attached and refers to an unambiguous moment. A naive one has tzinfo set to None and is just numbers on a face, with no record of which clock produced them.

datetime.now() with no argument returns a naive datetime holding the local time of whatever machine ran it. On your laptop in India that is IST. On a cloud server it is almost always UTC. Nothing in the object records the difference, so nothing complains when a timestamp written by the server displays as five and a half hours earlier than the user's own clock. Bookings appear to happen before they were made; a report labelled "today" starts at half past five in the morning.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

naive = datetime.now()                       # ambiguous, avoid
now_utc = datetime.now(timezone.utc)         # aware, correct
ist = now_utc.astimezone(ZoneInfo("Asia/Kolkata"))

print(now_utc.tzinfo, ist.tzinfo)
print(ist.utcoffset())                       # 5:30:00

Note that IST is offset by five hours and thirty minutes, not a whole number of hours. Code that stores an offset as an integer number of hours, or that tries to "fix" timezones by adding 5 to the hour field, is wrong for India specifically. India also has no daylight saving, which is convenient locally and misleading if you assume the rest of your users are in the same position.

Mixing the two kinds raises an error, which is the one piece of good news:

datetime.now() < datetime.now(timezone.utc)
# TypeError: can't compare offset-naive and offset-aware datetimes

Avoid datetime.utcnow(). It returns UTC values in a naive object, which is the worst of both worlds: it looks safe, carries no timezone, and gets misinterpreted as local time by the next piece of code that touches it. Use datetime.now(timezone.utc) instead.

One packaging detail: zoneinfo reads the operating system's timezone database. Linux and macOS ship one. Windows does not, so ZoneInfo("Asia/Kolkata") can raise ZoneInfoNotFoundError until you install the tzdata package from PyPI.

ISO 8601 and how to store times

ISO 8601 is the format that sorts correctly as plain text, is unambiguous about day and month order, and is understood by every language and database. Python produces and consumes it directly.

from datetime import datetime, timezone

dt = datetime(2026, 8, 15, 9, 30, tzinfo=timezone.utc)
s = dt.isoformat()
print(s)                              # 2026-08-15T09:30:00+00:00

back = datetime.fromisoformat(s)
print(back == dt)                     # True

Because the year comes first and every field is zero padded, sorting ISO strings alphabetically gives the same order as sorting the underlying moments. That single property is why logs, filenames and database columns should use it. 2026-08-15_report.csv lists correctly in any file browser; 15-08-2026_report.csv does not.

APIs commonly send a trailing Z to mean UTC, as in 2026-08-15T09:30:00Z. Older Python 3 releases reject that in fromisoformat, which is a frequent surprise when code that works locally fails on an older runtime. A safe pattern that works everywhere:

raw = "2026-08-15T09:30:00Z"
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))

The storage rule that prevents most timezone bugs is short. Store aware datetimes in UTC. Convert to the user's zone only at the moment of display. Never store a local time without also storing which zone it belongs to, because you cannot recover that information later.

There is one honest exception. For future local events, a class starting at 10:00 next March, storing the local wall time plus the zone name is more correct than storing a UTC instant, because if a government changes the offset the class still starts at ten in the morning. India has not changed its offset in a long time, but the principle matters for anything scheduled across borders.

timedelta arithmetic and its sharp edges

timedelta represents a duration, and it stores only three numbers internally: days, seconds and microseconds. Everything you pass in is normalised into those.

from datetime import datetime, timedelta

d = timedelta(days=1, hours=6)
print(d.days)               # 1     <- hours are NOT included here
print(d.seconds)            # 21600 <- the leftover 6 hours
print(d.total_seconds())    # 108000.0

deadline = datetime(2026, 8, 15) + timedelta(days=45)
print(deadline.date())      # 2026-09-29

The .days attribute catches people out because it is the whole-day part, not the total. A duration of 30 hours reports days as 1, and seconds as 21600. If you want the full length as a single number, total_seconds() is the only correct answer; divide it yourself for hours or minutes.

Negative durations normalise in a way that looks wrong at first. timedelta(hours=-1).days is -1 and .seconds is 82800, because the representation keeps seconds non-negative. Again, use total_seconds(), which correctly returns -3600.0.

There is deliberately no months or years argument, because neither has a fixed length. Adding one month to the 31st of January has no obvious answer. If you need calendar arithmetic, either move to the first of the month and work from there, or use dateutil.relativedelta from the third-party python-dateutil package, which defines sensible rules for these cases.

from datetime import date

# days between two dates, no timezone worry
gap = date(2026, 12, 25) - date(2026, 8, 15)
print(gap.days)             # 132

Finally, do not measure elapsed time with datetime.now(). The wall clock can jump backwards when the machine syncs with a time server, which makes a measured duration negative. For timing code use time.perf_counter(), and for timeouts that must survive a clock change use time.monotonic(). Wall clock time answers "when did this happen"; monotonic time answers "how long did it take", and they are not interchangeable.

Frequently Asked Questions

What is the difference between a naive and an aware datetime? An aware datetime has a tzinfo attached, so it identifies a specific moment anywhere in the world. A naive datetime has tzinfo set to None and is just a set of numbers with no record of which clock produced them. Python refuses to compare or subtract one from the other and raises TypeError. Storing naive values is the usual cause of timestamps that shift when code moves from a laptop to a server.
Should I store times in UTC or IST? Store UTC and convert to Asia/Kolkata when displaying. UTC never changes offset, sorts correctly, and stays comparable across servers and users in different zones. Converting at display time is one line with astimezone. The one exception is a future local appointment, where storing the local wall time plus the zone name is more faithful, because the intended local hour should survive any change to the offset.
Why does strptime raise ValueError on my date string? The format string must match the input exactly, including separators and padding. A slash in the data with a hyphen in the format fails, and so does a two-digit year parsed with %Y. Indian data typically uses day-month-year, so the format is usually %d-%m-%Y rather than the American %m-%d-%Y. Print the offending string before parsing, since stray whitespace or a trailing timezone suffix is a common culprit.
Why is timedelta.days different from the total number of days? timedelta stores days, seconds and microseconds separately, and .days returns only the whole-day component. A duration of 30 hours has .days equal to 1 and .seconds equal to 21600. Negative durations are stranger still, because seconds are kept non-negative. Use total_seconds() whenever you want the entire duration as a single number and divide it yourself for hours, minutes or fractional days.
Do I need pytz, or is zoneinfo enough? zoneinfo is in the standard library from Python 3.9 onward and is the recommended choice for new code, since it uses the operating system's timezone database and avoids the old localize() pattern pytz required. On Windows there is often no system timezone database, so you additionally install the tzdata package from PyPI. pytz is still fine in older codebases but there is no reason to reach for it in a new project.