Formatting Timestamps and Timezones in Python Logs

Python's default log timestamp is local time, with no timezone indicator, at a precision of milliseconds separated by a comma. Each of those properties causes a distinct problem once logs from more than one host are in the same store: events that cannot be merged across regions, an hour each year when timestamps are ambiguous, and parsers that reject the format outright. This page covers the fix — UTC, RFC 3339, enough precision to order events — and the pipeline setting that decides whether the application's timestamp is used at all. It is a task article under formatter configuration, part of the Python logging fundamentals and structured data section.

The same instant, two representations Two hosts, one configured for Central European time and one for US Eastern time, each log an event at the same instant as part of one request that crossed both. With Python's default local timestamps and no offset, the two records carry times six hours apart, and a log store merging them places the second service's record six hours before the first — the request appears to have been handled downstream before it arrived upstream. With UTC timestamps in RFC 3339 format, both records carry the same instant expressed identically, and they sort correctly next to each other. A third panel shows the daylight saving transition on a single host using local time: for one hour the same local times occur twice, so records from that hour cannot be ordered even on one machine. one request, two hosts, the same instant default local time, no offset 2026-09-18 16:02:11,408 api received order ord_7 2026-09-18 10:02:11,431 billing charged order ord_7 ← sorts six hours earlier UTC, RFC 3339 2026-09-18T14:02:11.408Z api received order ord_7 2026-09-18T14:02:11.431Z billing charged order ord_7 ← 23 ms later, correct and on one host, once a year, local time repeats an hour 02:30 happens twice at the autumn transition — records from that hour cannot be ordered at all
Local time without an offset is ambiguous across hosts all year and on a single host for one hour a year. UTC with an explicit marker is unambiguous everywhere.

Prerequisites

The standard library is sufficient; a JSON formatter makes the timestamp one field among others.

pip install "python-json-logger>=2.0.7,<4.0.0"

Implementation

Step 1 — Convert to UTC in the formatter. A formatter's converter attribute decides how record.created becomes a time tuple, and it defaults to local time. Setting it to time.gmtime makes every timestamp UTC regardless of the host's configuration, which matters because container images, developer machines and cloud hosts are configured inconsistently and nobody should have to know which one produced a record.

import logging
import time

formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
formatter.converter = time.gmtime               # 1. UTC, whatever the host says

Step 2 — Emit RFC 3339 with an explicit marker. The default format has no timezone indicator at all, and it separates milliseconds with a comma. RFC 3339 — the profile of ISO 8601 used by nearly every log store — puts a T between date and time, a dot before fractional seconds, and a Z or numeric offset at the end. Overriding formatTime produces exactly that.

from datetime import datetime, timezone

class Rfc3339Formatter(logging.Formatter):
    def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
        # 2. From the record's own creation time, to the microsecond, in UTC.
        dt = datetime.fromtimestamp(record.created, tz=timezone.utc)
        return dt.isoformat(timespec="microseconds").replace("+00:00", "Z")

Expected Output: an unambiguous, sortable, parseable timestamp.

2026-09-18T14:02:11.408213Z INFO orders order accepted

Step 3 — Keep enough precision to order events. At second precision, every record within the same second is tied, and the order an investigation needs — did the retry happen before or after the timeout — is lost. Millisecond precision is the minimum; microseconds cost nothing in a JSON field and resolve the cases where several events happen within one millisecond on a busy thread.

Step 4 — Take the time from the record. record.created is set when the log call is made. A formatter that calls datetime.now() instead stamps the record with the time it was formatted, which under a queue handler happens later, on the listener thread, possibly after a backlog. The difference is usually small and occasionally several seconds, and it silently reorders events that were logged close together on different threads.

# wrong under a QueueHandler: the time of formatting, not of the event
dt = datetime.now(timezone.utc)

# right: the time the log call was made
dt = datetime.fromtimestamp(record.created, tz=timezone.utc)

Step 5 — Put the timestamp in a named field in structured output. In JSON logs the timestamp should be a dedicated field with a conventional name, so the collector can be told which field is the event time. A timestamp embedded in a message string is invisible to the pipeline.

from pythonjsonlogger import jsonlogger

class UtcJsonFormatter(jsonlogger.JsonFormatter):
    def add_fields(self, target, record, message_dict):
        super().add_fields(target, record, message_dict)
        target["@timestamp"] = datetime.fromtimestamp(
            record.created, tz=timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
        target.pop("asctime", None)

Step 6 — Tell the pipeline to use it. A log shipper that does not know which field is the event time uses the time it read the record, which is later — sometimes much later under backpressure. Configuring the shipper to parse the application's field as the event time makes the store's ordering reflect when things happened rather than when they were collected. The shipper side is covered in shipping Python logs with Fluent Bit.

Which clock the store believes Three records are written by an application at one-second intervals during a period when the log collector is backlogged. Each carries the application's event timestamp. The collector reads them late and out of the order they were written relative to records from another service, because it drains different files at different rates. If the store uses the collector's read time as the event time, the three records appear forty seconds late and interleaved incorrectly with the other service's records, so a sequence that went api then billing then api appears as billing then api then api. If the store uses the application's timestamp field, the records sit at the moments they were written and the sequence is correct regardless of when they were collected. The note records that the application's field is only usable if it is in UTC with an explicit marker. three records written during a collector backlog event time (application) api billing api collection time billing api api 40 s late, and billing now appears to come first the store should believe the application's clock which is only possible if that clock is written in UTC with an explicit marker
A collector reads when it can, not when things happened. Using its clock as the event time reorders exactly the records an investigation needs in order.

Clocks, and what a timestamp can tell you

Correct formatting makes timestamps unambiguous. It does not make them accurate, and it is worth knowing how far they can be trusted.

Clocks drift between hosts. Each host's clock is kept in sync by a time protocol, typically to within a few milliseconds and occasionally much worse after a host's clock has been stepped. Two records from different hosts with timestamps a millisecond apart may in reality have happened in either order. For ordering events across services, a trace's parent-child relationships are authoritative where timestamps are merely suggestive — which is one of the strongest reasons to add trace identifiers to log records.

Wall clocks can go backwards. A clock correction can step the system time backwards, producing records whose timestamps are earlier than records logged before them on the same host. record.created comes from the wall clock, so this affects logs directly. Durations should never be computed by subtracting two log timestamps; they belong in a field computed with a monotonic clock at the point of measurement.

Precision is not accuracy. Microsecond precision records the value the clock returned to the microsecond. The clock itself may be several milliseconds off true time, and the formatting says nothing about that. Precision is valuable for ordering records from one process, where a single clock is shared; across processes its extra digits are mostly noise.

The practical position is: format timestamps in UTC with high precision so that nothing is lost at the formatting stage, use them for ordering within a process and approximate ordering across processes, and rely on traces for causality and monotonic measurements for durations.

Timestamps inside the record, not only on it

The record's own timestamp says when the log call was made. Many records also carry times inside their fields — when an order was placed, when a token expires, when a retry is scheduled — and those deserve the same treatment for the same reasons.

A field such as expires_at serialised from a naive datetime becomes a string with no timezone, and any consumer comparing it with the record's UTC timestamp is comparing two different clocks without knowing it. The common failure is a formatter that handles record.created correctly while json.dumps(..., default=str) turns every other datetime into local, offset-less text.

The fix is a single rule applied in the formatter: any datetime value in any field is converted to UTC and rendered in the same RFC 3339 form as the record's timestamp. Naive datetimes — those without timezone information — are the dangerous ones, because there is no way to know what they meant; treating them as an error during testing, and fixing them at the source to be timezone-aware, is better than guessing in the formatter.

Durations deserve their own field type as well. A duration logged as a timedelta string — "0:00:00.041200" — is human-readable and hard to query. Logging it as a number of milliseconds in a field named with its unit, such as duration_ms, makes it filterable, aggregatable and unambiguous, and it avoids the temptation to compute durations from two log timestamps, which the clocks discussion above explains is unreliable.

Timestamp formats and what goes wrong with each A table of five timestamp formats seen in Python logs with a verdict on each. The default asctime, such as 2026-09-18 14:03:22,418, has no timezone and a comma before the milliseconds, so parsers guess both. Local time with an offset, such as 2026-09-18T16:03:22.418+02:00, is unambiguous but makes sorting across regions awkward. UTC with a Z suffix and milliseconds, 2026-09-18T14:03:22.418Z, is unambiguous, sorts correctly and is the recommended form. UTC with microseconds adds precision useful for ordering records within a millisecond. Epoch seconds as a float is unambiguous for machines but unreadable to people. The note says UTC ISO 8601 with a Z is the one to standardise on. format verdict 2026-09-18 14:03:22,418 no zone, comma — parsers guess 2026-09-18T16:03:22.418+02:00 unambiguous, awkward to sort 2026-09-18T14:03:22.418Z unambiguous, sorts — recommended 2026-09-18T14:03:22.418123Z adds ordering within a millisecond 1789740202.418 fine for machines, unreadable for people standardise on UTC ISO 8601 with a Z
The default asctime omits the zone and uses a comma. UTC with a Z suffix is unambiguous and sorts correctly everywhere.

Configuration options

Setting Value Why
formatter.converter time.gmtime UTC regardless of host configuration
Format RFC 3339 with Z unambiguous and universally parseable
Precision microseconds orders events within a millisecond
Source record.created the time of the call, not of formatting
Field name @timestamp or time the collector can be told to use it
Pipeline event time the application field ordering survives collector backlog
Durations a separate monotonic field wall clocks can step

Verification

Confirm the timestamp parses as UTC and orders correctly relative to a known sequence.

import logging, json, io
from datetime import datetime

stream = io.StringIO()
h = logging.StreamHandler(stream); h.setFormatter(UtcJsonFormatter("%(message)s"))
log = logging.getLogger("ts"); log.handlers = [h]; log.propagate = False
for i in range(3):
    log.warning("event %d", i)

stamps = [json.loads(l)["@timestamp"] for l in stream.getvalue().splitlines()]
parsed = [datetime.fromisoformat(s.replace("Z", "+00:00")) for s in stamps]
assert all(p.utcoffset().total_seconds() == 0 for p in parsed)
assert parsed == sorted(parsed)
print(stamps[0])

Expected Output:

2026-09-18T14:02:11.408213Z

Common mistakes

The default local-time format. Error signature: records from different regions sorting hours apart. Root cause: local time with no offset. Remediation: UTC via the formatter's converter.

A comma before the milliseconds. Error signature: a shipper failing to parse the timestamp and falling back to arrival time. Root cause: the default format's separator. Remediation: RFC 3339 with a dot.

Stamping at format time. Error signature: records from a busy period slightly reordered under a queue handler. Root cause: now() called in the formatter. Remediation: use record.created.

Second precision. Error signature: ties that hide the order of a retry and a timeout. Root cause: a format without fractional seconds. Remediation: milliseconds at least.

The store using arrival time. Error signature: events appearing late and out of order after collector backlogs. Root cause: the shipper not configured with the event time field. Remediation: parse the application's timestamp as the event time.

Frequently Asked Questions

Why is Python's default log timestamp a problem?

The default asctime format uses local time with no timezone indicator and a comma before the milliseconds. Logs from hosts in different timezones — or the same host across a daylight saving change — cannot be merged reliably, and many parsers do not accept the comma.

Should logs use UTC or local time?

UTC, always, in the stored record. Local time is a display concern that a log viewer can apply for the reader. A stored timestamp in local time without an offset is ambiguous for one hour every year and unmergeable across regions all year.

How much precision is needed?

Milliseconds at minimum. A busy service logs many events per second, and at second precision the order within each second is lost — which is often exactly the order an investigation needs. Microseconds cost nothing extra in JSON.

Which timestamp should the log store use?

The application's. The collector's arrival time reflects when the record was read, which under backpressure can be seconds or minutes later than when it was written. Using it as the event time silently reorders and delays events.