Outputting Datetime Object to String in Python: Complete strftime() and Date Formatting Implementation Guide

Outputting datetime object to string in python

Outputting datetime object to string in python

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

DirectiveMeaningExample
%Y4-digit year2024
%y2-digit year24
%mMonth, zero-padded03
%dDay, zero-padded15
%HHour, 24-hour14
%IHour, 12-hour02
%MMinute30
%SSecond45
%fMicrosecond000000
%pAM/PMPM
%AFull weekday nameFriday
%aAbbreviated weekday nameFri
%BFull month nameMarch
%bAbbreviated month nameMar
%jDay of year075
%WWeek number of year (Monday as first day)11
%ZTime zone nameUTC
%zUTC 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

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

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.

References

Exit mobile version