Skip to content

How to localize timestamps across daylight-saving transitions in Pandas (with Code)

The Short Answer (TL;DR)

Use tz_localize with the known source timezone and explicit policies for repeated or nonexistent local clock times. Resolve repeated times from source evidence, retain a flag for unresolved times, and use tz_convert("UTC") after localization when you need elapsed-time comparisons.

import pandas as pd
naive = pd.to_datetime(["2026-10-25 02:30", "2026-10-25 02:30"], format="%Y-%m-%d %H:%M")
print(naive.tz_localize("Europe/Prague", ambiguous=[True, False], nonexistent="raise").tz_convert("UTC"))

Detailed Implementation

This example uses synthetic data and does not need a network connection. Install the recorded package versions in a separate Python environment before running it.

python -m pip install numpy==2.5.4 pandas==3.0.6 tzdata==2026.5
from pathlib import Path
import pandas as pd

zone = "Europe/Prague"
# Synthetic source evidence: the first 02:30 is in DST; the second is standard time.
autumn_naive = pd.to_datetime(["2026-10-25 01:30", "2026-10-25 02:30",
                               "2026-10-25 02:30", "2026-10-25 03:30"],
                              format="%Y-%m-%d %H:%M")
dst_flags = [False, True, False, False]  # Ignored for unambiguous clock labels.
autumn = autumn_naive.tz_localize(zone, ambiguous=dst_flags, nonexistent="raise")
autumn_utc = autumn.tz_convert("UTC")
autumn_audit = pd.DataFrame({"source_local": autumn_naive,
                             "localized": autumn, "utc": autumn_utc})

# The spring source contains a clock label that never occurred in this timezone.
spring_naive = pd.to_datetime(["2026-03-29 01:30", "2026-03-29 02:30",
                               "2026-03-29 03:00", "2026-03-29 03:30"],
                              format="%Y-%m-%d %H:%M")
spring = spring_naive.tz_localize(zone, ambiguous="raise", nonexistent="NaT")
spring_audit = pd.DataFrame({"source_local": spring_naive, "localized": spring,
    "utc": spring.tz_convert("UTC"), "source_missing": spring_naive.isna(),
    "unresolved_nonmissing": spring_naive.notna() & spring.isna()})

# Compare a different policy explicitly: shifting changes a source clock label.
shifted = spring_naive.tz_localize(zone, ambiguous="raise", nonexistent="shift_forward")
shift_audit = pd.DataFrame({"source_local": spring_naive, "shifted": shifted,
    "clock_label_changed": shifted.tz_localize(None) != spring_naive,
    "duplicated_instant": shifted.duplicated(keep=False)})
report = ("AUTUMN: evidence-resolved occurrences\n"+autumn_audit.to_string(index=False)+
          "\n\nSPRING: unresolved clock label retained and flagged\n"+
          spring_audit.to_string(index=False)+
          "\n\nALTERNATIVE: shift_forward changes one label and creates a duplicate instant\n"+
          shift_audit.to_string(index=False))
Path("dst-localization-audit.txt").write_text(report+"\n",encoding="utf-8")
print(report)

The autumn UTC instants are 24 October 23:30, then 25 October 00:30, 01:30 and 02:30, one hour apart. The spring audit retains four rows with one unresolved label. The alternative shift_forward policy changes that label to 03:00 +0200 and creates two records at the same instant.

Establish which timezone the source clock uses

The source in this example records Europe/Prague local clock labels. Localization attaches that interpretation; it does not discover where an event happened. An already aware timestamp represents an instant and should be converted with tz_convert. Treating a naive local label as UTC first would assign a different instant. Keep the source timezone in the import contract and preserve the original clock text or parsed naive labels for audit.

Resolve the two autumn occurrences from evidence

On 25 October 2026, the two synthetic 02:30 records correspond to 00:30 UTC and 01:30 UTC. The example supplies known occurrence information: the first is daylight-saving time, and the second is standard time. In Pandas ambiguous flags, True selects DST and False selects non-DST for an ambiguous label. These flags are not Python fold numbers. Python ZoneInfo uses fold=0 before the backward transition and fold=1 after it; validation compares both interpretations in UTC.

Retain an unresolved spring label instead of inventing an instant

The synthetic 29 March 2026 source includes 02:30, which never occurred in Europe/Prague. The main spring policy returns NaT for that label and retains its original value with an unresolved_nonmissing flag. An original missing timestamp must have a separate flag. The example has one unresolved nonmissing label among four source rows. Marking it unresolved records an import problem; it does not identify the event time that the source intended.

Inspect the consequences of a shifting policy

The alternative shift_forward policy moves the spring 02:30 record to the first valid local time, 03:00. The source already contains a genuine 03:00 record, so two rows now represent the same instant. The audit reports one changed clock label and two records in the duplicate pair. Keeping four rows does not mean four distinct instants remain. Any later aggregation or duplicate-removal rule must account for that collision explicitly.

Keep occurrence evidence attached when ordering changes

The full example supplies occurrence flags for every row and shows that reordering rows together with those flags preserves the same resolved instants. Assigning DST simply to the first repeated label is valid only if the source evidence actually establishes that occurrence. Ambiguous="infer" relies on sequence structure and is unsuitable when ordering is unreliable or observations are missing. If the occurrence is unknown, use a documented rejection or unresolved-value policy rather than fabricating an offset.

Compare instants before calculating elapsed durations

The four resolved autumn records are one elapsed hour apart, even though the local 02:30 label repeats. Their UTC offsets are +02, +02, +01 and +01 hours. Dropping the timezone with tz_localize(None) preserves the local labels and loses that distinction. Converting with tz_convert(None) produces naive UTC labels. Those operations therefore have different meanings. The audit tests both behaviors and the round trip back from UTC.

Record versions and the limits of the example

Validation on November 4, 2026 read current Pandas 3.1.0 and Python 3.15.2 documentation, then executed the preserved Python 3.12.14, Pandas 3.0.6 and installed tzdata 2026.5 environment. Timezone rules are data that can change, so record the environment for reproducibility. These are synthetic Prague examples with declared policies, not a universal rule for every country, historical offset transition or source system. Localization alone does not reconstruct missing event times or correct a misconfigured device clock.

Mathematical Context

Write a local clock label as L and its applicable UTC offset as o, measured in the same time units. Its UTC representation is U=L-o. For two records, elapsed time is the difference between their UTC representations, so changing offsets must be included. In the repeated autumn hour, equal 02:30 clock labels with offsets +02 and +01 correspond to instants one hour apart. A nonexistent local label has no applicable instant under the timezone rules; shifting it is an explicit data transformation. The offset must come from the timezone and resolved occurrence, not from a single fixed correction applied to an entire year.

\[ U=L-o,\qquad \Delta U=(L_2-o_2)-(L_1-o_1) \]

Reproducibility

Executed with Python 3.12.14; numpy 2.5.4, pandas 3.0.6, tzdata 2026.5. Automated checks confirm the stated output properties.

Primary documentation

Related guides