Every time I’ve had to ingest data from a CSV export, an API response, or a log file, dates show up as strings in some format I didn’t choose — and half the time it’s not even ISO 8601. Learning to parse these reliably with strptime() (and knowing when to reach for alternatives) has saved me from countless brittle string-slicing hacks. This guide covers everything I’ve learned about converting strings into datetime objects in Python, from the fundamentals to the edge cases that actually bite in production code.
The Basics: datetime.strptime()
Python’s datetime module provides strptime() (string parse time) as the core tool for converting a formatted string into a datetime object.
from datetime import datetime
date_string = "2024-03-15"
date_obj = datetime.strptime(date_string, "%Y-%m-%d")
print(date_obj) # 2024-03-15 00:00:00
print(type(date_obj)) # <class 'datetime.datetime'>
The second argument is a format string made of directives (like %Y, %m, %d) that tell Python exactly how to interpret each part of the input string.
Common Format Directives
These are the ones I use constantly:
| Directive | Meaning | Example |
|---|---|---|
%Y | 4-digit year | 2024 |
%y | 2-digit year | 24 |
%m | Month (01-12) | 03 |
%d | Day (01-31) | 15 |
%H | Hour, 24-hour (00-23) | 14 |
%I | Hour, 12-hour (01-12) | 02 |
%M | Minute (00-59) | 30 |
%S | Second (00-59) | 45 |
%p | AM/PM | PM |
%B | Full month name | March |
%b | Abbreviated month name | Mar |
%A | Full weekday name | Friday |
%a | Abbreviated weekday name | Fri |
%Z | Timezone name | UTC |
%z | UTC offset | +0000 |
%j | Day of year (001-366) | 075 |
Parsing Different Formats
from datetime import datetime
# Full timestamp
dt1 = datetime.strptime("2024-03-15 14:30:00", "%Y-%m-%d %H:%M:%S")
print(dt1) # 2024-03-15 14:30:00
# US-style date
dt2 = datetime.strptime("03/15/2024", "%m/%d/%Y")
print(dt2) # 2024-03-15 00:00:00
# Human-readable
dt3 = datetime.strptime("March 15, 2024", "%B %d, %Y")
print(dt3) # 2024-03-15 00:00:00
# 12-hour clock with AM/PM
dt4 = datetime.strptime("15/03/2024 02:30 PM", "%d/%m/%Y %I:%M %p")
print(dt4) # 2024-03-15 14:30:00
# Weekday included
dt5 = datetime.strptime("Friday, March 15, 2024", "%A, %B %d, %Y")
print(dt5) # 2024-03-15 00:00:00
The format string has to match the input exactly, including literal characters like slashes, commas, and spaces. If they don’t align, Python raises a ValueError.
try:
datetime.strptime("15-03-2024", "%m/%d/%Y")
except ValueError as e:
print("Parse error:", e)
Parsing ISO 8601 Strings
ISO 8601 is common enough in APIs that Python provides a dedicated, more forgiving method: datetime.fromisoformat().
from datetime import datetime
dt = datetime.fromisoformat("2024-03-15T14:30:00")
print(dt) # 2024-03-15 14:30:00
dt_with_tz = datetime.fromisoformat("2024-03-15T14:30:00+05:00")
print(dt_with_tz) # 2024-03-15 14:30:00+05:00
As of Python 3.11, fromisoformat() supports nearly the full ISO 8601 spec, including the Z suffix for UTC, which earlier versions didn’t handle directly.
dt_z = datetime.fromisoformat("2024-03-15T14:30:00Z")
print(dt_z) # 2024-03-15 14:30:00+00:00 (Python 3.11+)
For older Python versions, I handle the Z suffix manually:
raw = "2024-03-15T14:30:00Z"
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
print(dt)
Parsing Dates Without Knowing the Exact Format
When I don’t control the input format — say, scraping dates from varied web sources — strptime() becomes impractical since it requires an exact match. In those cases, I reach for the third-party dateutil library, which handles a huge range of formats automatically:
from dateutil import parser
print(parser.parse("March 15, 2024")) # 2024-03-15 00:00:00
print(parser.parse("15 Mar 2024 2:30pm")) # 2024-03-15 14:30:00
print(parser.parse("2024/03/15")) # 2024-03-15 00:00:00
dateutil isn’t part of the standard library, so it needs to be installed separately (pip install python-dateutil), but I consider it essential for any project ingesting real-world, inconsistently formatted date strings.
Handling Parsing Errors Gracefully
In real pipelines, malformed date strings are inevitable. I always wrap parsing in error handling rather than letting a single bad row crash a whole batch job:
from datetime import datetime
def safe_parse(date_str, fmt):
try:
return datetime.strptime(date_str, fmt)
except ValueError:
return None
results = [safe_parse(s, "%Y-%m-%d") for s in ["2024-03-15", "not-a-date", "2024-13-40"]]
print(results) # [datetime(...), None, None]
"2024-13-40" fails because month 13 and day 40 are invalid, even though the format matches structurally — strptime() validates actual calendar correctness, not just pattern shape.
Parsing Multiple Possible Formats
When input data comes from several inconsistent sources, I try a list of candidate formats in sequence:
from datetime import datetime
def parse_flexible(date_str):
formats = ["%Y-%m-%d", "%m/%d/%Y", "%d-%b-%Y", "%B %d, %Y"]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise ValueError(f"No matching format found for: {date_str}")
print(parse_flexible("15-Mar-2024")) # 2024-03-15 00:00:00
print(parse_flexible("March 15, 2024")) # 2024-03-15 00:00:00
How strptime() Works Internally
Under the hood, strptime() (in CPython’s pure-Python implementation, _strptime.py) compiles the format string into a regular expression, where each directive like %Y or %m is translated into a corresponding regex group with an appropriate pattern (\d{4} for a 4-digit year, a name-matching alternation for %B, and so on). The input string is then matched against this compiled regex, and the captured groups are converted into the individual date/time components used to construct the resulting datetime object.
This regex-compilation approach is also why repeatedly parsing many strings with the same format inside a tight loop has some overhead — though CPython caches the most recently used format’s compiled pattern (via an LRU-style cache in _strptime), so back-to-back calls with the same format string are notably faster than constantly switching formats.
import time
from datetime import datetime
dates = ["2024-03-15"] * 100_000
start = time.perf_counter()
for d in dates:
datetime.strptime(d, "%Y-%m-%d")
print("strptime loop:", time.perf_counter() - start)
For very large-scale parsing (millions of rows), I often reach for pandas.to_datetime() instead, which vectorizes parsing and is substantially faster than a per-row Python loop.
import pandas as pd
dates = pd.Series(["2024-03-15", "2024-03-16", "2024-03-17"])
parsed = pd.to_datetime(dates, format="%Y-%m-%d")
print(parsed)
Time Zones and Parsing
strptime() with %z parses UTC offsets but does not automatically know named time zones like "America/New_York" — for that, I combine it with the zoneinfo module (standard library since Python 3.9):
from datetime import datetime
from zoneinfo import ZoneInfo
naive = datetime.strptime("2024-03-15 14:30:00", "%Y-%m-%d %H:%M:%S")
aware = naive.replace(tzinfo=ZoneInfo("America/New_York"))
print(aware) # 2024-03-15 14:30:00-04:00
Parsing a string never automatically implies a time zone unless the string itself contains one and the format explicitly captures it with %z or %Z.
Real-World Use Cases
Log file analysis:
log_line = "[2024-03-15 14:30:00] ERROR: Connection failed"
timestamp_str = log_line.split("]")[0].strip("[")
timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
CSV/data pipeline ingestion:
import csv
from datetime import datetime
with open("orders.csv") as f:
reader = csv.DictReader(f)
for row in reader:
order_date = datetime.strptime(row["date"], "%m/%d/%Y")
API response normalization:
api_response = {"created_at": "2024-03-15T14:30:00Z"}
created = datetime.fromisoformat(api_response["created_at"].replace("Z", "+00:00"))
Best Practices
- Prefer
fromisoformat()for ISO 8601 strings — it’s faster and part of the standard library. - Use
strptime()when the format is known and fixed; usedateutil.parser.parse()when formats vary unpredictably. - Always wrap parsing in
try/except ValueErrorfor external or user-provided data. - Be explicit about time zones — a naive
datetime(notzinfo) can silently cause bugs when compared against timezone-aware objects. - For bulk parsing, use
pandas.to_datetime()over per-rowstrptime()loops.
Common Mistakes
# Mistake: format string mismatch
# datetime.strptime("2024/03/15", "%Y-%m-%d") # Raises ValueError
# Mistake: comparing naive and aware datetimes
from zoneinfo import ZoneInfo
naive = datetime(2024, 3, 15)
aware = datetime(2024, 3, 15, tzinfo=ZoneInfo("UTC"))
# naive < aware # Raises TypeError: can't compare offset-naive and offset-aware datetimes
# Mistake: assuming %m and %d order matches locale expectations
# "03/15/2024" with "%d/%m/%Y" silently misparses as day=3, month=15 -> ValueError, or worse, wrong date if both values are <=12
Debugging Tips
- Print the exact input string with
repr()to catch hidden whitespace or unexpected characters. - Test the format string against a single known-good example before applying it to a full dataset.
- Use
datetime.strptime(s, fmt)in a REPL first to confirm the format matches before embedding it in a script.
FAQs
What’s the difference between strptime() and fromisoformat()? strptime() requires an explicit format string and works for arbitrary custom formats; fromisoformat() is built specifically for ISO 8601 strings and requires no format string, but only understands that standard.
Why does strptime() raise an error even though my format looks correct? It performs real calendar validation — invalid combinations like day 32 or month 13 will fail even if the string structurally matches the format pattern.
How do I parse dates when I don’t know the format in advance? Use the third-party dateutil.parser.parse() function, which infers the format automatically for most common styles.
Does strptime() handle time zones? Only if the format string includes %z (UTC offset) or %Z (time zone name) and the input string actually contains that information; otherwise the resulting datetime is naive.
Summary
Parsing strings into datetime objects is one of those tasks that looks trivial until real-world data shows up in a dozen inconsistent formats. strptime() remains my go-to when the format is known, fromisoformat() is faster and simpler for ISO 8601 data, and dateutil.parser.parse() is my fallback for genuinely unpredictable input. Wrapping all of it in proper error handling has saved more than one production pipeline from crashing on a single malformed row.
