---
myst:
html_meta:
description: >-
Turning local fields into an instant in a named time zone: the
offset_mismatch policy for mismatched offsets, the disambiguation policy
for repeated and skipped times, the order in which they apply, and how
derived local times keep the offset they already had.
---
(resolving-local-times)=
# Resolving local times in a time zone
A named time zone contains rules that map exact instants to local clock
readings and UTC offsets. Going the other way—from local fields to an
instant—sometimes requires more information:
- A local time may be {ref}`repeated or skipped ` by a time zone
transition. The `disambiguation` policy resolves that ambiguity.
- Input may contain both a numeric offset and a named time zone. If they
conflict, the `offset_mismatch` policy decides which part is authoritative.
These rules apply to the {class}`~whenever.ZonedDateTime` constructor,
whether you pass it an ISO string or a standard library datetime, and to
{meth}`~whenever.ZonedDateTime.parse_iso`, patterned
{meth}`~whenever.ZonedDateTime.parse`, and
{meth}`~whenever.OffsetDateTime.assume_tz`. A local time derived from an
existing value follows a related rule, described in
{ref}`offset-preserving resolution `.
A standard library datetime with a local time in a gap already names one
instant under [PEP 495](https://peps.python.org/pep-0495/), which its `fold`
selects, so `disambiguation=` doesn't apply to it.
(offset-mismatch)=
## The complete resolution flow
```text
INPUT: local fields + named time zone
e.g. 2023-10-29 02:15 [Europe/Amsterdam]
│
├─ no offset, e.g. 02:15[Europe/Amsterdam] ───────────────┐
│ │
├─ Z, e.g. 02:15Z[Europe/Amsterdam] │
│ └─► use exact UTC instant; recalculate local ─► DONE │
│ │
└─ numeric offset, e.g. +02:00 │
│ │
├─ matches a possible time zone offset │
│ e.g. +02:00 during the repeated hour │
│ └─► offset identifies instant; │
│ ignore both policies ────────────────► DONE │
│ │
└─ mismatch, e.g. +03:00 │
└─ offset_mismatch │
├─ "raise" ──────────────────────────► ERROR │
├─ "keep_instant" │
│ └─► offset identifies instant; │
│ recalculate local fields ──────► DONE │
└─ "keep_local" │
└─► discard offset ───────────────────────────┤
│
▼
APPLY disambiguation= IN THE NAMED TIME ZONE
├─ local time is unique ──────────────► DONE
├─ repeated or skipped + explicit policy
│ └───────────► DONE or ERROR
└─ repeated or skipped + omitted policy
└─► warn, then use "compatible" ─► DONE
```
In other words, `offset_mismatch` determines which input is authoritative
*before* `disambiguation` can apply:
| Input situation | Result |
|---|---|
| No numeric offset | Resolve the written local fields in the time zone. Consult `disambiguation` only for a repeated or skipped local time. |
| `Z` suffix | Treat the input as an exact UTC instant and recalculate its local fields in the time zone. |
| Numeric offset matches | The offset identifies the instant, including which occurrence of a repeated time was written. Neither policy is consulted. |
| Mismatch + `"raise"` | Raise {exc}`~whenever.InvalidOffsetError`. |
| Mismatch + `"keep_instant"` | Treat the numeric offset as authoritative. Preserve the instant and recalculate local fields in the time zone. `disambiguation` is not consulted. |
| Mismatch + `"keep_local"` | Discard the numeric offset. Preserve the written local fields and consult `disambiguation` only if they are repeated or skipped. |
For example, both numeric offsets below are valid in Amsterdam's repeated
hour, so each identifies a different instant without requiring a policy:
```python
>>> ZonedDateTime("2023-10-29T02:15+02:00[Europe/Amsterdam]")
ZonedDateTime("2023-10-29 02:15:00+02:00[Europe/Amsterdam]")
>>> ZonedDateTime("2023-10-29T02:15+01:00[Europe/Amsterdam]")
ZonedDateTime("2023-10-29 02:15:00+01:00[Europe/Amsterdam]")
```
When the offset mismatches, the policy changes the meaning of the input:
```python
>>> s = "2023-05-01T12:00+03:00[Europe/Amsterdam]"
>>> ZonedDateTime(s, offset_mismatch="keep_instant")
ZonedDateTime("2023-05-01 11:00:00+02:00[Europe/Amsterdam]")
>>> ZonedDateTime(s, offset_mismatch="keep_local")
ZonedDateTime("2023-05-01 12:00:00+02:00[Europe/Amsterdam]")
```
{meth}`~whenever.OffsetDateTime.assume_tz` follows the same model: its local fields and
offset are the input, and the named time zone supplies the rules. A matching
offset preserves the represented instant. On a mismatch, `"keep_instant"`
preserves that instant while `"keep_local"` reinterprets the local fields.
### Offset precision
Hour-and-minute offsets in parsed text are compared with candidate historical
time zone offsets rounded to the nearest minute, half away from zero. An offset
that includes seconds must match exactly. {meth}`~whenever.OffsetDateTime.assume_tz` always
compares its exact offset.
Matching at the written precision is what lets a formatted value parse back.
Historical offsets often carry seconds—Dublin ran on `-00:25:21` until
1916—so an offset written to the minute would match nothing at all.
`Z` is not an offset in this sense. ISO 8601 and RFC 3339 both read `Z` as the
UTC designator: it says the time is written in UTC, not which offset the named
time zone was applying. `Z` therefore leaves the flow before either policy
applies, which is why `offset_mismatch="keep_local"` on a `Z` input behaves
like `"keep_instant"`.
(ambiguity)=
## Repeated and skipped local times
Local clocks sometimes move backward or forward because of daylight saving
or political changes:
- When the clock moves backward, a range of local times is repeated (a
*fold*, in the vocabulary of PEP 495). For example, 02:30 occurred twice in
Paris on 29 October 2023.
- When the clock moves forward, a range is skipped (a *gap*). For example,
02:30 did not occur in Paris on 26 March 2023.
`disambiguation` controls how a local time in one of these transitions is
resolved:
| `disambiguation` | Repeated time | Skipped time |
|---|---|---|
| `"raise"` | Raise {exc}`~whenever.RepeatedTime` | Raise {exc}`~whenever.SkippedTime` |
| `"earlier"` | Choose the earlier instant | Extrapolate backward across the gap |
| `"later"` | Choose the later instant | Extrapolate forward across the gap |
| `"compatible"` | Choose `"earlier"` | Choose `"later"` |
`"compatible"` follows the convention used by almost all other datetime
libraries and RFC 5545.
```python
>>> paris = "Europe/Paris"
>>> # Repeated: 02:30 occurs twice
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguation="earlier")
ZonedDateTime("2023-10-29 02:30:00+02:00[Europe/Paris]")
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguation="later")
ZonedDateTime("2023-10-29 02:30:00+01:00[Europe/Paris]")
>>> # Skipped: 02:30 does not exist
>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguation="earlier")
ZonedDateTime("2023-03-26 01:30:00+01:00[Europe/Paris]")
>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguation="later")
ZonedDateTime("2023-03-26 03:30:00+02:00[Europe/Paris]")
```
```{admonition} Why extrapolate a skipped time instead of snapping it to the transition?
:class: tip
A skipped local time cannot keep identical final local fields: those fields
do not exist in that time zone. Extrapolating by the size of the gap preserves
the intended minute and second, which fits the common case where a clock was
not adjusted—or was adjusted too early. It also matches almost all other
datetime libraries and the iCalendar standard (RFC 5545).
The diagram in [PEP 495](https://peps.python.org/pep-0495/#mind-the-gap)
illustrates why extrapolating across the gap is useful.
```
If `disambiguation` is omitted, Whenever uses `"compatible"` but emits
{class}`~whenever.ImplicitDisambiguationWarning` only when a repeated or
skipped local time is actually encountered. Passing
`disambiguation="compatible"` explicitly makes that choice without a warning.
An ordinary, unambiguous local time never emits this warning.
(offset-preserving)=
## Offset-preserving resolution
`disambiguation` answers a question about written input. An operation that
derives a new local time from an existing {class}`~whenever.ZonedDateTime`
already has half the answer: the offset the value carries.
{meth}`~whenever.ZonedDateTime.replace`,
{meth}`~whenever.ZonedDateTime.replace_date`,
{meth}`~whenever.ZonedDateTime.replace_time`, and calendar arithmetic
(`add()` and `subtract()` with days, weeks, months, or years) keep that offset
while it is still valid for the new local time.
A repeated local time is the case that needs the rule. The current offset is
one of the two the time zone offers, so the value stays on the side of the
transition it was already on, whatever `disambiguation` says. A written
offset in a parsed string wins over `disambiguation` in the same way.
```python
>>> d = ZonedDateTime(2023, 10, 29, 2, 30, tz="Europe/Paris", disambiguation="later")
>>> d
ZonedDateTime("2023-10-29 02:30:00+01:00[Europe/Paris]")
>>> d.replace(minute=45) # still the second occurrence
ZonedDateTime("2023-10-29 02:45:00+01:00[Europe/Paris]")
>>> d.replace(minute=45, disambiguation="earlier") # the offset still decides
ZonedDateTime("2023-10-29 02:45:00+01:00[Europe/Paris]")
```
`disambiguation` decides only where the offset identifies no occurrence. It
defaults to `"compatible"` and emits an
{class}`~whenever.ImplicitDisambiguationWarning` when omitted. A skipped local
time always falls to `disambiguation`, because no earlier offset is valid
inside a gap. A repeated local time falls to it when neither of its offsets is
the current one: after the time zone changed its standard offset, or when the
operation changes the time zone.
```python
>>> d.replace(month=3, day=26, hour=2, disambiguation="earlier") # a gap
ZonedDateTime("2023-03-26 01:30:00+01:00[Europe/Paris]")
```