You stored a user’s signup time as plain text, the app shipped, and now a customer in Pune swears they signed up “yesterday” while your logs say “today”. Welcome to dates and times. The Python datetime module is how you stop guessing: it gives you real date objects, clean formatting, safe parsing, time math, and timezones that actually behave.
“Storing a time without its timezone is like writing down a phone number without the country code. It looks fine until someone far away tries to use it.”
Every backend developer, eventually
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 18 minutes
Dates look simple until you actually work with them. Timezones, daylight saving, leap years, the fact that strftime and strptime sound almost identical but do opposite jobs, the gap between a “naive” datetime and an “aware” one. Any of these can quietly break a feature in production.
Good news: the whole module comes built into Python, so there is nothing to install for the basics. By the end of this post you will create dates, format and parse them, do time math with timedelta, convert between timezones with ZoneInfo, and sidestep the traps that cause bugs on dates like February 29th.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
Think of this as a subway map for dates. Each box is a type you can be at, and each arrow is the method that takes you from one to another. Want a formatted string out of a datetime? Ride strftime(). Have a string and need a datetime back? Take strptime() the other way. You will come back to this map every time you parse a date from an Application Programming Interface (API), print one for a user, or work out the gap between two timestamps. Keep it handy.
Table of Contents
Install and Verify
Here is the part most date libraries get wrong and python datetime gets right: there is nothing to install. datetime ships with Python itself, so you just import it. Open a terminal and check that you are on a recent version.
📄 Terminal: confirm your Python and that datetime imports
$ python --version Python 3.14.6 $ python -c "from datetime import datetime; print(datetime.now())" 2026-06-21 15:42:00.932438
If you see a datetime printed back, you are ready. One catch lies in wait for Windows users, and it only bites later when you reach timezones. The ZoneInfo class reads its timezone names (“Asia/Kolkata”, “America/Los_Angeles”) from a database that Linux and macOS already ship with. Windows does not include that database, so the first time you ask for a zone you get a ZoneInfoNotFoundError. The fix is one small package.
📄 Terminal: Windows users install the timezone database
$ pip install tzdata Successfully installed tzdata-2026.2
What happened here: tzdata is the IANA timezone database packaged for pip. On Linux and macOS you almost never need it because the operating system already has the zone files. On Windows there is no system copy, so pip install tzdata hands Python its own copy. Install it once per virtual environment and the timezone examples later in this post will run without complaint. If you skip it on Windows, every ZoneInfo("...") call will fail, which trips up a lot of first-timers.
The Quick Win
Before the full tour, here is the single most common thing you will ever do with python datetime: grab the current moment and print it the way a human wants to read it. Three lines.
📄 quick_win.py: today, your way
from datetime import datetime
now = datetime.now()
print(now.strftime("%A, %B %d, %Y at %I:%M %p"))
▶ Output (yours shows the moment you run it)
Sunday, June 21, 2026 at 03:42 PM
What happened here: datetime.now() reads your computer’s clock and hands back an object holding the year, month, day, hour, minute, second, and microsecond. That object is not a string, so printing it raw looks robotic. strftime is the translator: you hand it a pattern of % codes and it returns a clean, human sentence. Your output will show whatever moment you ran it, not mine, because the clock keeps moving. That is the whole module in miniature, an object that knows the time, plus a way to dress it up for people.
The Core Types
The python datetime module gives you four building blocks. The easiest way to keep them straight: a date is a calendar page, a time is a clock face, a datetime is both stapled together, and a timedelta is the gap between two of them (think “3 days and 4 hours”, not an actual moment). You will spend most of your life in datetime, but the other three show up constantly.
📄 core_types.py: four types, each with a clear job
from datetime import date, time, datetime, timedelta
# date: just year, month, day (a calendar page)
today = date.today()
print(f"Today: {today}") # whatever day you run it
print(f"Year: {today.year}, Month: {today.month}, Day: {today.day}")
# time: just hours, minutes, seconds, microseconds (a clock face)
meeting_time = time(14, 30, 0)
print(f"\nMeeting: {meeting_time}") # 14:30:00
# datetime: date + time combined (the one you'll use most)
now = datetime.now()
print(f"\nNow: {now}") # date and clock together
print(f"Just date: {now.date()}")
print(f"Just time: {now.time()}")
# timedelta: a duration, not a moment (the gap between two points)
duration = timedelta(days=7, hours=3, minutes=30)
print(f"\nDuration: {duration}") # 7 days, 3:30:00
print(f"Total seconds: {duration.total_seconds()}")
▶ Output (your Today and Now lines will show your own clock)
Today: 2026-06-21 Year: 2026, Month: 6, Day: 21 Meeting: 14:30:00 Now: 2026-06-21 15:42:00.932438 Just date: 2026-06-21 Just time: 15:42:00.932438 Duration: 7 days, 3:30:00 Total seconds: 617400.0
What happened here: Each type carries only the pieces it needs. A date has no clock, so it cannot tell you the hour. A time has no calendar, so it does not know what day it is. datetime joins them, which is why it is the workhorse. The odd one out is timedelta: it is not a point in time at all, it is a length of time, like a stopwatch reading. Notice total_seconds() returned 617400.0, which is 7 days (604800) plus 3 hours and 30 minutes (12600). It hands back a float because durations can carry fractions of a second.
Formatting and Parsing
This is the pair everyone mixes up, so here is a memory hook. strftime is “string FROM time”: you start with a datetime and get a string out. strptime is “string PARSE time”: you start with a string and get a datetime back. Think of them like the two windows at a currency exchange counter: one converts your money out, the other converts it back, and both use the same rate card.
Here the rate card is the set of % format codes, like %Y for the year and %d for the day, which you saw in the diagram’s Format Codes section. The example below ends with a practical use of parsing: say a user named Rahul typed his birthday into a signup form as plain text, and we want to compute his age from it.
📄 format_parse.py: strftime (datetime to string) and strptime (string to datetime)
from datetime import datetime
now = datetime(2026, 3, 27, 14, 30, 0)
# strftime: format datetime as string
print(now.strftime("%Y-%m-%d")) # 2026-03-27
print(now.strftime("%d/%m/%Y")) # 27/03/2026
print(now.strftime("%B %d, %Y")) # March 27, 2026
print(now.strftime("%I:%M %p")) # 02:30 PM
print(now.strftime("%A, %d %b %Y %H:%M")) # Friday, 27 Mar 2026 14:30
# strptime: parse string into datetime
date_str = "27-03-2026 14:30"
parsed = datetime.strptime(date_str, "%d-%m-%Y %H:%M")
print(f"\nParsed: {parsed}")
print(f"Type: {type(parsed)}")
# Rahul's birthday calculation
rahul_birthday = datetime.strptime("1997-08-15", "%Y-%m-%d")
age_days = (now - rahul_birthday).days
print(f"\nRahul is {age_days} days old ({age_days // 365} years)")
▶ Output
2026-03-27 27/03/2026 March 27, 2026 02:30 PM Friday, 27 Mar 2026 14:30 Parsed: 2026-03-27 14:30:00 Type: <class 'datetime.datetime'> Rahul is 10451 days old (28 years)
What happened here: The big lesson is in the parsing pattern. strptime only works if your format string matches the input exactly, punctuation and all. The text was "27-03-2026 14:30", so the pattern had to be "%d-%m-%Y %H:%M" with those same dashes and spaces. Get one separator wrong and Python raises a ValueError. Once parsed, the result is a real datetime object (see the type line), which means you can do math on it. That is exactly what the last block does: subtracting Rahul’s birthday from now gives a timedelta, and .days pulls out the day count.
Dividing by 365 is a rough age, close enough for a greeting, not for legal paperwork, since it ignores leap years.
Date Math with timedelta
Here is where dates stop being labels and start being numbers you can add and subtract. Add a timedelta to a datetime and you move forward in time. Subtract one and you go back. Subtract two datetimes from each other and you get the gap between them. It is like a number line, except the units are days, hours, and seconds.
📄 arithmetic.py: add, subtract, and compare dates
from datetime import datetime, timedelta
now = datetime(2026, 3, 27, 14, 0)
# Add/subtract time
tomorrow = now + timedelta(days=1)
last_week = now - timedelta(weeks=1)
in_2_hours = now + timedelta(hours=2, minutes=30)
print(f"Tomorrow: {tomorrow.strftime('%A, %B %d')}")
print(f"Last week: {last_week.strftime('%A, %B %d')}")
print(f"In 2.5 hrs: {in_2_hours.strftime('%H:%M')}")
# Difference between dates
project_start = datetime(2026, 1, 15)
project_end = datetime(2026, 6, 30)
duration = project_end - project_start
print(f"\nProject duration: {duration.days} days ({duration.days // 7} weeks)")
# Deadline check for a teammate named Niranjan
deadline = datetime(2026, 3, 31, 17, 0)
remaining = deadline - now
print(f"Niranjan's deadline: {remaining.days} days, {remaining.seconds // 3600} hours left")
# Compare dates
print(f"\nDeadline passed? {now > deadline}")
print(f"Same day? {now.date() == deadline.date()}")
▶ Output
Tomorrow: Saturday, March 28 Last week: Friday, March 20 In 2.5 hrs: 16:30 Project duration: 166 days (23 weeks) Niranjan's deadline: 4 days, 3 hours left Deadline passed? False Same day? False
What happened here: Two things worth slowing down for. First, timedelta(weeks=1) is friendlier than writing days=7, and the same goes for mixing units like hours=2, minutes=30. Python sorts out the carry-over for you, so 14:00 plus two and a half hours correctly lands on 16:30. Second, look at how the deadline read out. remaining.days gave 4, but remaining.seconds is the leftover seconds inside that final partial day, not the grand total. That is why we divide remaining.seconds by 3600 to get the 3 trailing hours. A timedelta only ever stores days, seconds, and microseconds, so anything bigger (weeks, months) you compute yourself. There is deliberately no months=1, because a month has no fixed length.
Timezones with ZoneInfo
Here is the trap that catches everyone at least once. A datetime with no timezone is called naive, and it is genuinely ambiguous: “14:30” could be Pune, London, or New York. A datetime that carries its zone is aware, and it points to one exact moment on Earth. Think of a naive datetime as a meeting time texted with no location (“see you at 2”), and an aware datetime as “2 PM IST” (Indian Standard Time). One of them gets people into the wrong room. The rule in production is simple: always be aware. In the example below, a developer named Viraj in Pune needs to figure out when a global team call starts for him.
Windows users: if the ZoneInfo lines below raise ZoneInfoNotFoundError, you skipped pip install tzdata from the setup section. Install it, then re-run.
📄 timezones.py: always use aware datetimes in production
from datetime import datetime, timezone
from zoneinfo import ZoneInfo # stdlib since Python 3.9, no pip install on Linux/macOS
# Naive datetime: no timezone info (DANGEROUS in production)
naive = datetime(2026, 3, 27, 14, 30)
print(f"Naive: {naive} (no timezone!)")
# Aware datetime: has timezone info (ALWAYS use this)
utc_now = datetime.now(timezone.utc)
print(f"UTC: {utc_now}")
# Convert between timezones
ist = ZoneInfo("Asia/Kolkata")
pst = ZoneInfo("America/Los_Angeles")
meeting_utc = datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc)
meeting_ist = meeting_utc.astimezone(ist)
meeting_pst = meeting_utc.astimezone(pst)
print(f"\nMeeting time:")
print(f" UTC: {meeting_utc.strftime('%H:%M %Z')}")
print(f" IST: {meeting_ist.strftime('%H:%M %Z')}")
print(f" PST: {meeting_pst.strftime('%H:%M %Z')}")
# Viraj in Pune checks when to join the team call
print(f"\nViraj joins at {meeting_ist.strftime('%I:%M %p')} IST")
▶ Output
Naive: 2026-03-27 14:30:00 (no timezone!) UTC: 2026-06-21 07:42:21.145453+00:00 Meeting time: UTC: 10:00 UTC IST: 15:30 IST PST: 03:00 PDT Viraj joins at 03:30 PM IST
What happened here: The naive datetime prints with no offset on the end, a quiet warning that Python has no idea which part of the world it belongs to. The aware one ends in +00:00, meaning UTC, short for Coordinated Universal Time. (The UTC: line shows the live clock, so yours will read a different moment than mine.) The real trick is astimezone(): it does not change the actual instant, it just re-expresses it in another zone.
The same meeting reads 10:00 in UTC, 15:30 in Pune, and 03:00 the same morning in Los Angeles. Notice the LA label says PDT (Pacific Daylight Time), not PST (Pacific Standard Time), because late March is daylight saving time over there. ZoneInfo knows the daylight saving rules for every zone, which is the whole reason you reach for it instead of hard-coding offsets. Viraj in Pune reads off 03:30 PM IST and joins on time.
Unix Timestamps and ISO Format
Two formats rule the wider world of computers. A Unix timestamp is just a single number: how many seconds have ticked by since midnight UTC on January 1, 1970. Think of it like the odometer in a car, one ever-growing number, useless to say out loud but perfect for math and comparisons, which is why databases and APIs love it. ISO 8601 (that 2026-03-27T14:30:00+00:00 shape) is more like a full postal address: longer, but any human or system anywhere can read it, so two systems never argue about whether 03/04 means March or April.
📄 timestamps.py: converting between number, object, and ISO string
from datetime import datetime, timezone
# datetime to timestamp (seconds since 1970-01-01 UTC)
dt = datetime(2026, 3, 27, 14, 30, 0, tzinfo=timezone.utc)
ts = dt.timestamp()
print(f"Timestamp: {ts}") # 1774621800.0
# Timestamp back to datetime
restored = datetime.fromtimestamp(ts, tz=timezone.utc)
print(f"Restored: {restored}")
# ISO 8601 format (the universal standard)
print(f"ISO: {dt.isoformat()}") # 2026-03-27T14:30:00+00:00
# Parse ISO format (since 3.11 fromisoformat accepts most ISO 8601 strings, including Z)
parsed = datetime.fromisoformat("2026-03-27T14:30:00+05:30")
print(f"Parsed ISO: {parsed}")
print(f"As UTC: {parsed.astimezone(timezone.utc)}")
▶ Output
Timestamp: 1774621800.0 Restored: 2026-03-27 14:30:00+00:00 ISO: 2026-03-27T14:30:00+00:00 Parsed ISO: 2026-03-27 14:30:00+05:30 As UTC: 2026-03-27 09:00:00+00:00
What happened here: A round trip with no data lost. We turned the datetime into the number 1774621800.0 with .timestamp(), then rebuilt the exact same moment with fromtimestamp(). One detail to spot: isoformat() joins the date and time with a literal T (that is the ISO standard), but when you just print() a datetime, Python uses a space instead, which is why the Parsed ISO line shows a space rather than a T.
The last two lines are the everyday win: we read a Pune time (+05:30) straight from a string, then shifted it to UTC for storage. 14:30 in Pune is 09:00 UTC, exactly five and a half hours earlier. Store UTC, display local. That habit alone prevents a surprising number of bugs.
Common Mistakes
Mistake 1: Mixing naive and aware datetimes
The most common datetime error in production. You try to subtract a datetime that has a timezone from one that does not, and Python refuses, because it genuinely cannot know how to line them up.
❌ Wrong: subtract a naive datetime from an aware one
from datetime import datetime, timezone naive = datetime(2026, 3, 27, 14, 0) # no timezone aware = datetime(2026, 3, 27, 14, 0, tzinfo=timezone.utc) # has timezone diff = aware - naive # boom
▶ Output
Traceback (most recent call last):
File "mistake.py", line 6, in <module>
diff = aware - naive # boom
~~~~~~^~~~~~~
TypeError: can't subtract offset-naive and offset-aware datetimes
✅ Correct: make both aware first
from datetime import datetime, timezone naive = datetime(2026, 3, 27, 14, 0) aware = datetime(2026, 3, 27, 14, 0, tzinfo=timezone.utc) # Attach a timezone to the naive one, then the math works naive_fixed = naive.replace(tzinfo=timezone.utc) diff = aware - naive_fixed print(diff) # 0:00:00
Why: The error message says it plainly: can't subtract offset-naive and offset-aware datetimes. One value knows its place on the globe and the other does not, so Python will not guess. The clean fix is to make everything aware as early as possible, ideally the moment data enters your program. replace(tzinfo=timezone.utc) only works here because we already knew the naive value was meant to be UTC. If you are unsure what zone a naive value belongs to, that uncertainty is the actual bug, not the subtraction.
Mistake 2: Reaching for pytz instead of ZoneInfo
Older tutorials tell you to pip install pytz. Since Python 3.9 you do not need it. ZoneInfo is built in and, importantly, it plays nicely with the normal tzinfo= argument. pytz does not: it makes you call .localize() in a way that quietly produces wrong offsets if you forget.
✅ Correct: ZoneInfo works with the standard tzinfo argument
from zoneinfo import ZoneInfo
from datetime import datetime
# Clean and correct, no special localize() dance needed
dt = datetime(2026, 3, 27, 14, 0, tzinfo=ZoneInfo("Asia/Kolkata"))
print(dt) # 2026-03-27 14:00:00+05:30
Why: With ZoneInfo you pass the zone straight into tzinfo= and you are done. The only reason to still touch pytz today is supporting Python 3.8 or older, which reached end of life, so for any new code ZoneInfo is the answer. Remember the Windows note from setup: ZoneInfo needs pip install tzdata there.
Wrapping Up
You now have the full python datetime toolkit: the four core types (date, time, datetime, timedelta), strftime and strptime for moving between objects and strings, timedelta math for gaps and deadlines, and ZoneInfo for timezones that respect daylight saving. If you remember just two habits from this post, make them these: always work with aware datetimes, and store UTC while displaying local. Those two rules kill most date bugs before they exist.
Next up is logging, where python datetime quietly powers every timestamp you will ever read in a log file. And if you want to jump around, browse every post in the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is the difference between strftime and strptime in Python?
In the Python datetime module, strftime formats a datetime object into a string (f for format), and strptime parses a string into a datetime object (p for parse). Both use the same format codes such as %Y, %m, and %d. A quick memory hook: strFtime goes From a datetime, strPtime Parses into one.
What is a naive vs aware datetime in Python?
A naive datetime has no timezone info (tzinfo=None), so a value like 14:00 could belong to any timezone. An aware datetime carries its timezone and points to one exact instant. Always use aware datetimes in production, especially when storing in databases or comparing times across regions.
Should I use pytz or ZoneInfo in Python?
Use zoneinfo.ZoneInfo, which has been in the standard library since Python 3.9. It works correctly with the normal tzinfo= argument. pytz has a non-standard API that needs .localize() and is easy to get wrong. On Windows, run pip install tzdata so ZoneInfo can find its timezone names.
How do I get the current time in UTC in Python?
Use datetime.now(timezone.utc). Do not use datetime.utcnow(), because it returns a naive datetime (no timezone attached) even though the value is UTC, which causes bugs when you compare it with aware datetimes. utcnow() has been deprecated since Python 3.12.
How do I calculate the difference between two dates in Python?
Subtract them: delta = date2 - date1 returns a timedelta. Read delta.days for the whole-day count and delta.total_seconds() for the full duration in seconds. A timedelta stores only days, seconds, and microseconds, so there is no months or years field, since a month has no fixed length.
Try It Yourself
Build a meeting_scheduler(utc_time_str, attendees) function. It takes a UTC time as an ISO string and a dict of {name: timezone_str}, then prints each person’s local time. Test it with a three-person team spread across the globe: Anvi in Pune, Prathamesh in New York, and Vinay in London. Everything you need is in the python datetime sections above: fromisoformat, ZoneInfo, and astimezone.
📄 your_turn.py: starter shape
from datetime import datetime
from zoneinfo import ZoneInfo
def meeting_scheduler(utc_time_str, attendees):
# 1. Parse utc_time_str into an aware datetime (hint: fromisoformat)
# 2. For each name and zone, convert with .astimezone(ZoneInfo(zone))
# 3. Print name and their local time nicely with strftime
...
team = {"Anvi": "Asia/Kolkata", "Prathamesh": "America/New_York", "Vinay": "Europe/London"}
meeting_scheduler("2026-03-27T10:00:00+00:00", team)
One thing to watch: make sure the datetime you parse is aware (it carries that +00:00), otherwise astimezone() will assume your local machine’s zone and quietly give wrong answers. On Windows, remember pip install tzdata first.
Interview Questions on Python Datetime
If you can walk through these without peeking, you are ready for this topic in an interview.
Q: Your app stores signup times with datetime.now() and saves them to the database. After you move the server from Mumbai to a data center in Frankfurt, all “time since signup” values shift by several hours. What went wrong and how do you fix it?
datetime.now() with no argument returns a naive datetime in the server’s local time, so the stored values silently changed meaning when the server’s timezone changed from IST to CET. The fix is to store aware UTC everywhere: use datetime.now(timezone.utc) at write time and convert to the user’s zone with astimezone(ZoneInfo(...)) only at display time. Existing rows need a one-time migration where you attach the timezone they were actually recorded in.
Q: A daily report job is scheduled for 2:30 AM local time in a region with daylight saving. Twice a year the report either never runs or runs twice. Why, and what is the standard fix?
On the spring-forward night, 2:30 AM does not exist because clocks jump from 2:00 straight to 3:00, so the job is skipped. On the fall-back night, 2:30 AM happens twice, so the job can fire twice. The standard fix is to schedule in UTC, which has no daylight saving, and convert for display only. If the schedule genuinely must be local wall-clock time, pick an hour that never falls in the transition window.
Q: Why does timedelta have no months or years argument, and how would you compute “same day next month” in practice?
A timedelta is a fixed physical duration stored as days, seconds, and microseconds, and a month has no fixed length (28 to 31 days), so months=1 would be ambiguous. For calendar math you either compute the new month and year yourself and use .replace(), handling overflow cases like January 31 to February, or use dateutil.relativedelta from the third-party python-dateutil package, which encodes those calendar rules for you.
Q: Can you compare or subtract two aware datetimes that are in different timezones, say one in IST and one in US Eastern, without converting them first?
Yes. Comparison and subtraction on aware datetimes work on the absolute instant, not the wall-clock digits, because Python normalizes both sides through their UTC offsets. So an event at 19:30 IST equals one at 10:00 US Eastern daylight time even though the printed numbers differ. Only mixing naive with aware raises a TypeError.
Q: How do you send a datetime through a JSON API, given that json.dumps() raises TypeError on datetime objects?
Serialize it yourself as an ISO 8601 string: call dt.isoformat() on an aware UTC datetime before dumping, or pass default=str (or a custom encoder) to json.dumps(). On the receiving side, datetime.fromisoformat() rebuilds the object, and since Python 3.11 it also accepts the trailing Z that many APIs send. Always include the offset in what you send, otherwise the receiver gets a naive, ambiguous value.
Q: What does the fold attribute on a datetime do?
During a daylight saving fall-back, one local hour repeats, so a wall-clock time like 1:30 AM maps to two different real instants. fold=0 (the default) means the first occurrence and fold=1 means the second, letting ZoneInfo pick the correct UTC offset for each. You rarely set it by hand, but it is why modern Python can disambiguate repeated times where older approaches guessed.
Reference: the complete, always-current details live in Python datetime documentation.
Related Posts
Previous: Python: Collections Module (Counter, defaultdict, deque, namedtuple)
Next: Python: Logging Levels, Handlers, Formatters
Series Home: Python + AI/ML Tutorial Series

No comment