Quick summary
Summarize this blog with AI
A timestamp needs both a clock reading and a clear interpretation. Parsing turns text into datetime values. Localization assigns a timezone to a naive clock reading. Conversion expresses an already aware instant in another zone. Mixing these operations can silently shift events by hours.
Tested with Python 3.12.14 and pandas 2.2.3 with Copy-on-Write enabled and pandas 3.0.6. Run the blocks in order. Dates are explicit, so the machine's local timezone is not used.
Parse local clock text without inventing a zone
import pandas as pd
clock_text = pd.Series(['2026-01-15 09:00', '2026-07-15 09:00'])
naive = pd.to_datetime(clock_text, format='%Y-%m-%d %H:%M', errors='raise')
print(naive.dt.tz)
local = naive.dt.tz_localize('America/Los_Angeles')
utc = local.dt.tz_convert('UTC')
print(utc.dt.strftime('%Y-%m-%d %H:%M %z').tolist())
Expected output:
None
['2026-01-15 17:00 +0000', '2026-07-15 16:00 +0000']
Both source values say 09:00 in Los Angeles. January uses a different UTC offset from July, so they become 17:00 and 16:00 UTC. Passing utc=True while parsing the original naive text would instead interpret both as 09:00 UTC. It cannot discover the source timezone for you.
Parse explicit offsets directly to UTC
with_offsets = pd.Series([
'2026-11-01T01:30:00-07:00',
'2026-11-01T01:30:00-08:00',
])
instants = pd.to_datetime(with_offsets, utc=True, errors='raise')
print(instants.dt.strftime('%Y-%m-%d %H:%M %z').tolist())
print((instants.iloc[1] - instants.iloc[0]).total_seconds())
Expected output:
['2026-11-01 08:30 +0000', '2026-11-01 09:30 +0000']
3600.0
The offset-bearing strings identify two different instants, one hour apart. Parsing to UTC handles their mixed offsets consistently. An offset alone is not a regional timezone rule for future dates; retain a region identifier separately when the application needs local scheduling.
Resolve repeated and nonexistent local times deliberately
During the fall transition, 01:30 occurs twice in Los Angeles. A naive value cannot tell you which occurrence was intended. The boolean ambiguous policy below explicitly selects daylight time first and standard time second; use it only when the source supplies that distinction.
repeated = pd.DatetimeIndex(['2026-11-01 01:30', '2026-11-01 01:30'])
resolved = repeated.tz_localize('America/Los_Angeles', ambiguous=[True, False])
print(resolved.tz_convert('UTC').strftime('%H:%M %z').tolist())
unresolved = repeated.tz_localize('America/Los_Angeles', ambiguous='NaT')
print(unresolved.isna().tolist())
Expected output:
['08:30 +0000', '09:30 +0000']
[True, True]
During the spring transition, 02:30 does not exist. Reject it, mark it missing for review, or apply an explicitly approved shift policy. Shifting forward moves this value to the transition boundary, 03:00; it does not preserve the original 30-minute component.
gap = pd.DatetimeIndex(['2026-03-08 02:30'])
missing = gap.tz_localize('America/Los_Angeles', nonexistent='NaT')
shifted = gap.tz_localize('America/Los_Angeles', nonexistent='shift_forward')
print(missing.isna().tolist())
print(shifted.strftime('%Y-%m-%d %H:%M %z').tolist())
print(shifted.tz_convert('UTC').strftime('%H:%M %z').tolist())
Expected output:
[True]
['2026-03-08 03:00 -0700']
['10:00 +0000']
The default ambiguous and nonexistent policies raise instead of silently choosing. Automatic inference can require a particular observation sequence; do not apply it to an isolated repeated clock value and assume it has recovered missing source information.
Compare instants with compatible timezone awareness
cutoff = pd.Timestamp('2026-01-15 17:00', tz='UTC')
print((utc >= cutoff).tolist())
try:
naive >= cutoff
except TypeError:
print('Cannot order naive times against an aware cutoff')
print(local.dt.tz_localize(None).dt.strftime('%H:%M').tolist())
print(local.dt.tz_convert(None).dt.strftime('%H:%M').tolist())
Expected output:
[True, True]
Cannot order naive times against an aware cutoff
['09:00', '09:00']
['17:00', '16:00']
Removing a zone with tz_localize(None) keeps the local wall clock. tz_convert(None) converts to UTC and removes the timezone label. Neither is a neutral repair for an incompatible merge. Prefer a common aware UTC key for event joins, and keep local display values separate.
Make invalid timestamps visible
raw = pd.Series(['2026-01-15 09:00', 'not-a-date'])
parsed = pd.to_datetime(raw, format='%Y-%m-%d %H:%M', errors='coerce')
print(parsed.isna().tolist())
if parsed.isna().any():
print('Review rejected rows before time-based analysis')
Expected output:
[False, True]
Review rejected rows before time-based analysis
Coercion is useful only when the resulting missing values are counted and reviewed. Define the source zone, format, invalid-row policy, and daylight-saving policy before comparing or resampling. A local calendar day around a timezone transition can contain 23 or 25 hours, so a daily label and a fixed 24-hour duration are different contracts.
Continue with pandas time and row windows. Primary reference: the pandas timezone guide.