Parsing dates gets all the attention, but I’ve spent just as much time on the reverse problem — taking a datetime object and turning it into exactly the string format a report, API, filename, or UI needs. Python’s strftime() (string format time) is the tool for that, and once I understood its full directive set and a few of the gotchas around locale and time zones, formatting dates stopped being something I had to look up every single time. This guide covers everything from the basics to the internal mechanics and real production patterns.
The Basics: datetime.strftime()
strftime() converts a datetime object into a string according to a format specification made of directives.
from datetime import datetime
now = datetime(2024, 3, 15, 14, 30, 45)
print(now.strftime("%Y-%m-%d")) # 2024-03-15
print(now.strftime("%Y-%m-%d %H:%M:%S")) # 2024-03-15 14:30:45
print(now.strftime("%m/%d/%Y")) # 03/15/2024
print(now.strftime("%B %d, %Y")) # March 15, 2024
The format string is the mirror image of what I’d use in strptime() — the same directives, but used to produce a string rather than consume one.
Common Format Directives
| Directive | Meaning | Example |
|---|---|---|
%Y | 4-digit year | 2024 |
%y | 2-digit year | 24 |
%m | Month, zero-padded | 03 |
%d | Day, zero-padded | 15 |
%H | Hour, 24-hour | 14 |
%I | Hour, 12-hour | 02 |
%M | Minute | 30 |
%S | Second | 45 |
%f | Microsecond | 000000 |
%p | AM/PM | PM |
%A | Full weekday name | Friday |
%a | Abbreviated weekday name | Fri |
%B | Full month name | March |
%b | Abbreviated month name | Mar |
%j | Day of year | 075 |
%W | Week number of year (Monday as first day) | 11 |
%Z | Time zone name | UTC |
%z | UTC offset | +0000 |
%% | Literal percent sign | % |
Practical Formatting Examples
from datetime import datetime
dt = datetime(2024, 3, 15, 14, 30, 45)
print(dt.strftime("%A, %B %d, %Y")) # Friday, March 15, 2024
print(dt.strftime("%d-%b-%Y")) # 15-Mar-2024
print(dt.strftime("%I:%M %p")) # 02:30 PM
print(dt.strftime("%Y%m%d_%H%M%S")) # 20240315_143045 -> useful for filenames
print(dt.strftime("Today is %A, the %d of %B")) # Today is Friday, the 15 of March
That %Y%m%d_%H%M%S pattern is one I use constantly for generating unique, sortable filenames in automation scripts:
filename = f"report_{dt.strftime('%Y%m%d_%H%M%S')}.csv"
print(filename) # report_20240315_143045.csv
Formatting to ISO 8601
For interoperability with APIs, logs, and databases, ISO 8601 is the format I default to unless told otherwise. Python provides a dedicated method, isoformat(), which is both simpler and more standards-compliant than manually building the string with strftime().
from datetime import datetime, timezone
dt = datetime(2024, 3, 15, 14, 30, 45)
print(dt.isoformat()) # 2024-03-15T14:30:45
dt_utc = datetime(2024, 3, 15, 14, 30, 45, tzinfo=timezone.utc)
print(dt_utc.isoformat()) # 2024-03-15T14:30:45+00:00
I could produce the same basic result with strftime("%Y-%m-%dT%H:%M:%S"), but isoformat() correctly handles microseconds and time zone offsets automatically without me having to manually assemble the format string.
Formatting with Time Zones
Formatting a time-zone-aware datetime correctly requires the object to actually carry timezone information — formatting alone doesn’t add a time zone that isn’t already there.
from datetime import datetime
from zoneinfo import ZoneInfo
dt = datetime(2024, 3, 15, 14, 30, tzinfo=ZoneInfo("America/New_York"))
print(dt.strftime("%Y-%m-%d %H:%M:%S %Z%z")) # 2024-03-15 14:30:00 EDT-0400
If the datetime is naive (no tzinfo), %Z and %z simply produce empty strings rather than raising an error:
naive_dt = datetime(2024, 3, 15, 14, 30)
print(naive_dt.strftime("%Y-%m-%d %H:%M:%S %Z%z")) # 2024-03-15 14:30:00 (blank tz fields)
I’ve been caught out by this — a silently empty time zone field is much harder to notice than an explicit error, so I make a habit of confirming dt.tzinfo is not None before formatting anything that needs to display a time zone.
Locale-Aware Formatting
%A, %B, and similar name-based directives respect the current locale setting, which matters for internationalized output:
import locale
from datetime import datetime
dt = datetime(2024, 3, 15)
locale.setlocale(locale.LC_TIME, "en_US.UTF-8")
print(dt.strftime("%A, %B %d, %Y")) # Friday, March 15, 2024
locale.setlocale(locale.LC_TIME, "fr_FR.UTF-8")
print(dt.strftime("%A, %d %B %Y")) # vendredi, 15 mars 2024
Locale availability depends on what’s installed on the host operating system, so I always wrap locale-switching code in error handling for environments (like some Docker containers) where a given locale might not be present.
try:
locale.setlocale(locale.LC_TIME, "fr_FR.UTF-8")
except locale.Error:
print("Locale not available on this system")
Formatting Dates for Display vs Storage
A pattern I follow consistently: store dates as UTC in ISO 8601 format, and only apply human-readable strftime() formatting at the point of display.
from datetime import datetime, timezone
# Storage: always UTC, ISO 8601
stored_value = datetime.now(timezone.utc).isoformat()
print(stored_value) # e.g. 2024-03-15T14:30:45.123456+00:00
# Display: convert and format for the user
from zoneinfo import ZoneInfo
dt = datetime.fromisoformat(stored_value)
local_dt = dt.astimezone(ZoneInfo("America/Los_Angeles"))
print(local_dt.strftime("%B %d, %Y at %I:%M %p")) # March 15, 2024 at 07:30 AM
Keeping storage format and display format strictly separate has prevented a lot of confusion in projects with users across multiple time zones.
How strftime() Works Internally
CPython’s strftime() implementation on datetime objects largely delegates to the underlying C library’s strftime() function via the time module for many directives, meaning behavior for some format codes (particularly locale-dependent ones and a few platform-specific directives like %s) can vary subtly between operating systems. This is different from strptime()‘s parsing side, which is implemented in pure Python (_strptime.py) using a compiled regex rather than calling into the C library.
Because of this reliance on the platform C library for some directives, I’ve occasionally seen minor formatting differences between, say, Linux and Windows for edge-case directives — something worth testing explicitly if a project’s output needs to be perfectly consistent across deployment environments.
import time
from datetime import datetime
dt = datetime(2024, 3, 15, 14, 30, 45)
# Benchmark repeated formatting
start = time.perf_counter()
for _ in range(100_000):
dt.strftime("%Y-%m-%d %H:%M:%S")
print("strftime loop:", time.perf_counter() - start)
# isoformat() is typically faster since it avoids the general-purpose format parser
start = time.perf_counter()
for _ in range(100_000):
dt.isoformat()
print("isoformat loop:", time.perf_counter() - start)
In my own benchmarking, isoformat() consistently outperforms an equivalent strftime() call, since it’s a more specialized, less generic code path — worth keeping in mind for high-throughput logging or serialization code.
Real-World Use Cases
Generating log timestamps:
def log(message):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {message}")
log("Service started") # [2024-03-15 14:30:45] Service started
Building sortable, unique filenames:
backup_name = f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
Formatting for user-facing reports:
report_date = datetime.now().strftime("%B %d, %Y")
print(f"Report generated on {report_date}")
Serializing to JSON-compatible strings:
import json
from datetime import datetime
def json_default(obj):
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError("Not serializable")
data = {"created_at": datetime.now()}
print(json.dumps(data, default=json_default))
Best Practices
- Use
isoformat()for machine-readable output (APIs, storage, logs) rather than manually reconstructing ISO 8601 withstrftime(). - Reserve
strftime()with custom formats for genuinely human-facing display text. - Always confirm whether a
datetimeis timezone-aware before formatting with%Z/%z, since naive datetimes silently produce blank fields instead of erroring. - Store timestamps in UTC and only format into local time zones at the display layer.
- Be cautious with locale-dependent formatting in server environments — test that the target locale is actually installed.
Common Mistakes
# Mistake: assuming %Z will show a timezone on a naive datetime
naive = datetime(2024, 3, 15, 14, 30)
print(naive.strftime("%Z")) # '' — empty, not an error, easy to miss
# Mistake: manually building ISO 8601 instead of using isoformat()
# dt.strftime("%Y-%m-%dT%H:%M:%S") loses microsecond and offset precision that isoformat() handles automatically
# Mistake: forgetting %% for a literal percent sign
# dt.strftime("100% done on %Y-%m-%d") -> ValueError, must escape as %%
print(dt.strftime("100%% done on %Y-%m-%d")) # 100% done on 2024-03-15
Debugging Tips
- Print
dt.tzinfobefore formatting to confirm whether the object is aware or naive. - Test format strings against a known fixed
datetimevalue in a REPL before using them in production code. - When locale-based names look wrong, print
locale.getlocale(locale.LC_TIME)to confirm the active locale.
FAQs
What’s the difference between strftime() and isoformat()? strftime() takes an arbitrary custom format string and works for any display style; isoformat() is specialized for producing standards-compliant ISO 8601 strings without needing a format string.
Why does %Z show nothing for my datetime? The datetime object is naive (no tzinfo attached) — %Z/%z only produce output for timezone-aware datetimes.
Can I format just the date or just the time? Yes — use dt.date().isoformat() or dt.strftime("%Y-%m-%d") for the date portion, and dt.time().isoformat() or dt.strftime("%H:%M:%S") for the time portion.
Is strftime() locale-sensitive? Yes, for name-based directives like %A and %B — the output changes based on the process’s current LC_TIME locale setting.
Summary
Formatting a datetime into a string is deceptively simple until real requirements show up — ISO 8601 for APIs, localized names for user-facing reports, timezone-aware output for global applications, and sortable formats for filenames. strftime() handles the fully custom cases, while isoformat() is my default for anything machine-readable, since it’s both faster and less error-prone than hand-assembling an ISO-compliant format string.