Python, de la zero Lecția 8 / 60

datetime + zoneinfo: partea pe care toata lumea o greseste

Naive vs aware datetimes, de ce fusurile orare se rup in productie si solutia din biblioteca standard care a sosit in sfarsit in 3.9.

La fel cum DATETIMEOFFSET și AT TIME ZONE din SQL Server există fiindcă fusurile orare sunt o belea, Python a petrecut ultimii cincisprezece ani luptându-se cu mai multe iterații ale aceleiași probleme. Starea curentă, Python 3.13, mai 2026, e în sfârșit bună. datetime pentru valoare, zoneinfo pentru fusul orar, ambele în biblioteca standard, ambele corecte.

Cârligul: limbajul îți permite în continuare să creezi datetimes fără niciun fus orar atașat. Acelea se numesc naive, arată ca datetimes reale, se afișează ca datetimes reale, iar dacă le amesteci cu cele aware primești răspunsuri greșite sau crash-uri. Cele mai multe bug-uri de date din producție pornesc de la un singur datetime naive care s-a strecurat prin cod.

Lecția asta e cum să nu se mai întâmple.

Naive vs aware: singura distinctie care conteaza

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

naive: datetime = datetime(2026, 5, 1, 14, 30)
# 2026-05-01 14:30:00 — but 14:30 *where*?

aware: datetime = datetime(2026, 5, 1, 14, 30, tzinfo=ZoneInfo("Europe/Rome"))
# 2026-05-01 14:30:00+02:00 — unambiguous

Un datetime naive nu are tzinfo. Sunt doar niște numere. Python nu știe dacă înseamnă UTC, ora Romei, ora Tokyo-ului sau ceva local. E o convenție Python fără semantică.

Un datetime aware are tzinfo. Se referă la un moment specific în timp, pe care orice alt fus orar îl poate converti spre sau dinspre el.

Poți verifica:

def is_aware(dt: datetime) -> bool:
    return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None

Iar limbajul nu te va lăsa să le amesteci. Asta ridică excepție:

naive - aware
# TypeError: can't subtract offset-naive and offset-aware datetimes

Care e rezultatul bun. Cel rău e când adaugi un timedelta la un datetime naive și folosești rezultatul undeva care presupunea UTC. Nicio eroare. Răspuns greșit. Dashboard-urile se duc la deriva. Cron job-urile se declanșează la ora greșită. Afli două luni mai târziu, când un client din Lisabona observă.

Regula de aur: stocheaza UTC, converteste la margini

Aceeași regulă din lecția SQL se aplică și în Python.

  • Toate datetimes din baza de date: UTC, aware.
  • Toate datetimes din logica aplicației: UTC, aware.
  • Toate datetimes care vin de la un user sau de la un API: convertește în UTC la graniță.
  • Toate datetimes afișate utilizatorului: convertește din UTC în fusul lui orar la graniță.

UTC nu are daylight saving. Aritmetica UTC e fără ambiguități. „O oră mai târziu în UTC” sunt mereu 60 de minute. În clipa în care lași ora locală să se strecoare în logica de business, ai adăugat un bug.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# Now in UTC, aware
utc_now: datetime = datetime.now(tz=timezone.utc)

# Convert to Rome for a display string
rome_now: datetime = utc_now.astimezone(ZoneInfo("Europe/Rome"))

# Convert back if you ever need to (you won't, you stored UTC)
back: datetime = rome_now.astimezone(timezone.utc)

datetime.now(tz=...) îți dă mereu un datetime aware. Folosește-l. datetime.now() simplu (fără tz) returnează un datetime naive în fusul orar al sistemului local, ceea ce e cea mai comună sursă de bug-uri de date din Python. Prefă-te că nu există.

zoneinfo: stdlib din 3.9

Vreme de un deceniu răspunsul a fost biblioteca terță pytz. Funcționa, dar avea un API ciudat: trebuia să apelezi pytz.timezone("Europe/Rome").localize(dt) în loc să pasezi fusul orar la constructor. Dacă încurcai treaba, primeai rezultate în liniște greșite.

zoneinfo, adăugat în Python 3.9 (PEP 615), folosește baza de date IANA de fusuri orare a sistemului tău de operare, același pachet tzdata pe care îl folosește orice altceva pe sistem. API-ul e cel evident:

from zoneinfo import ZoneInfo

rome: ZoneInfo = ZoneInfo("Europe/Rome")
ny: ZoneInfo = ZoneInfo("America/New_York")
tokyo: ZoneInfo = ZoneInfo("Asia/Tokyo")

Pe Windows, unde nu există tzdata în sistem, trebuie să instalezi pachetul tzdata: pip install tzdata. Pe Linux și macOS e deja acolo. Într-un container, instalează pachetul de sistem tzdata sau include wheel-ul Python tzdata.

Folosește nume IANA: Europe/Rome, America/New_York, Asia/Tokyo. Nu folosi offset-uri ca UTC+2 ca fus orar, nu știu de daylight saving. Tot rostul unui fus orar cu nume e că offset-ul variază de-a lungul anului.

Dacă mai vezi pytz într-un codebase, planifică o migrare. Nu e încă deprecated, dar zoneinfo e viitorul. Conversia e mecanică: pytz.timezone("Europe/Rome") devine ZoneInfo("Europe/Rome") și poți renunța la apelurile .localize().

Parsare si formatare

Citirea unui string ISO 8601 într-un datetime:

from datetime import datetime

dt: datetime = datetime.fromisoformat("2026-05-01T14:30:00+02:00")
# datetime(2026, 5, 1, 14, 30, tzinfo=timezone(timedelta(hours=2)))

În Python 3.11, fromisoformat a fost îmbunătățit ca să trateze toată specificația ISO 8601: secunde fracționare, sufixul Z pentru UTC, formate de bază și extinse. Înainte de 3.11 era un subset mai restrâns. Pe 3.13, folosește-l fără frică; acoperă tot ce e rezonabil.

dt = datetime.fromisoformat("2026-05-01T14:30:00Z")          # UTC
dt = datetime.fromisoformat("2026-05-01T14:30:00.123456+00:00")  # microseconds

Scrierea înapoi ca ISO:

s: str = dt.isoformat()  # '2026-05-01T14:30:00+02:00'

Pentru formate prietenoase la oameni, strftime:

dt.strftime("%Y-%m-%d")          # '2026-05-01'
dt.strftime("%d/%m/%Y %H:%M")    # '01/05/2026 14:30'
dt.strftime("%A, %d %B %Y")      # 'Friday, 01 May 2026'

Codurile de format urmează strftime din C: %Y an cu patru cifre, %m lună umplută cu zero, %d zi umplută cu zero, %H ceas pe 24 de ore, %M minute, %S secunde. Caută tabelul o singură dată.

Pentru schimb între mașini (API-uri, JSON, baze de date), folosește mereu ISO 8601. dt.isoformat() și datetime.fromisoformat(). Niciun format dependent de locale aproape de un protocol de wire.

timedelta: aritmetica pe timp

from datetime import datetime, timedelta, timezone

now: datetime = datetime.now(tz=timezone.utc)

one_hour_ago: datetime = now - timedelta(hours=1)
one_week_later: datetime = now + timedelta(days=7)
ninety_seconds: timedelta = timedelta(minutes=1, seconds=30)

# Difference between two datetimes is a timedelta
elapsed: timedelta = now - one_hour_ago
elapsed.total_seconds()  # 3600.0

timedelta e singurul obiect pe care ar trebui să-l folosești pentru „mută cu o anumită cantitate de timp”. E direct și bine definit.

Ce nu are timedelta: luni și ani. Fiindcă lunile n-au o lungime fixă. „1 lună după 31 ianuarie” e uneori 28 februarie, alteori 29 februarie, în funcție de an. Nu există timedelta(months=1).

Pentru aritmetică pe luni și ani, folosește dateutil.relativedelta (singurul lucru din pachetul dateutil care încă e esențial):

from dateutil.relativedelta import relativedelta

dt: datetime = datetime(2026, 1, 31, tzinfo=timezone.utc)
dt + relativedelta(months=1)  # 2026-02-28 (clamped)
dt + relativedelta(years=1)   # 2027-01-31
dt + relativedelta(months=1, days=-1)  # last day of Feb minus extra logic

E o dependență terță (pip install python-dateutil), dar e una stabilă, prezentă încă din Python 2.5. Pentru orice calcul de „luna viitoare” sau „acum un an”, întinde mâna după ea.

Capcana DST

Motivul pentru care datetimes naive sunt periculoase, într-un singur exemplu:

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

rome: ZoneInfo = ZoneInfo("Europe/Rome")

# Saturday before DST starts in 2026 (DST begins Sunday March 29 in Rome)
sat: datetime = datetime(2026, 3, 28, 12, 0, tzinfo=rome)

# Naive add: "24 hours later"
sun: datetime = sat + timedelta(hours=24)
print(sun)  # 2026-03-29 13:00:00+02:00 — note: 13:00, not 12:00

24 de ore după sâmbătă la prânz este duminică la 13:00, nu duminică la prânz, fiindcă ceasurile au sărit în față la 02:00. Dacă voiai „aceeași oră a zilei, ziua următoare”, timedelta(days=1) e unealta greșită. Vrei relativedelta(days=1), care păstrează ora de pe perete:

sun = sat + relativedelta(days=1)
print(sun)  # 2026-03-29 12:00:00+02:00 — same wall time, different UTC moment

Cele două sunt momente diferite în timp real. Alege ce înseamnă efectiv treaba ta. „Trimite-i o reamintire 24 de ore mai târziu” probabil vrea timedelta. „Ține ședința la aceeași oră locală mâine” vrea relativedelta.

E genul de bug care se ascunde jumătate de an. Testează-ți codul de programare la o dată imediat după o tranziție DST.

Exemplu real: o sedinta peste fusuri orare

from datetime import datetime
from zoneinfo import ZoneInfo

# A meeting set in Rome local time
meeting_rome: datetime = datetime(2026, 6, 15, 10, 0, tzinfo=ZoneInfo("Europe/Rome"))

# Convert for participants in NYC and Tokyo
attendees: dict[str, ZoneInfo] = {
    "Marco (Rome)":  ZoneInfo("Europe/Rome"),
    "Alex (NYC)":    ZoneInfo("America/New_York"),
    "Yuki (Tokyo)":  ZoneInfo("Asia/Tokyo"),
}

for name, tz in attendees.items():
    local: datetime = meeting_rome.astimezone(tz)
    print(f"{name}: {local.strftime('%Y-%m-%d %H:%M %Z')}")

Rezultat:

Marco (Rome): 2026-06-15 10:00 CEST
Alex (NYC):   2026-06-15 04:00 EDT
Yuki (Tokyo): 2026-06-15 17:00 JST

Trei ore locale corecte, niciun bug de o oră, nicio aritmetică manuală de offset, nicio îndoială dacă daylight saving e azi în vigoare. Definești momentul o singură dată, într-un fus orar, și lași astimezone să producă restul.

Granita cu baza de date

Dacă folosești SQLAlchemy sau Django, configurează-ți coloanele TIMESTAMPTZ și ORM-urile îți vor returna datetimes aware. Cu SQLAlchemy:

from sqlalchemy import Column, DateTime
from sqlalchemy.orm import declarative_base
from datetime import datetime, timezone

Base = declarative_base()

class Order(Base):
    __tablename__ = "orders"
    id: int = Column(Integer, primary_key=True)
    created_at: datetime = Column(DateTime(timezone=True),
                                  default=lambda: datetime.now(tz=timezone.utc))

DateTime(timezone=True) se mapează pe TIMESTAMPTZ din PostgreSQL și returnează datetimes aware. Callable-ul implicit folosește UTC. Combinate, fiecare rând are un timestamp neambiguu de la creare până la afișare.

Comparare si sortare

Datetimes aware se compară corect între fusuri orare, fiindcă pe dedesubt sunt puncte în timpul absolut:

from datetime import datetime
from zoneinfo import ZoneInfo

rome_noon: datetime = datetime(2026, 5, 1, 12, 0, tzinfo=ZoneInfo("Europe/Rome"))
ny_noon:   datetime = datetime(2026, 5, 1, 12, 0, tzinfo=ZoneInfo("America/New_York"))

rome_noon < ny_noon   # True — NY noon is 6 hours after Rome noon

Asta funcționează fiindcă la compararea de datetimes aware, ambele părți se convertesc în UTC. Naive vs aware în aceeași comparație ridică TypeError. Naive vs naive se compară ca numere brute, fără conștiență de fus orar, ceea ce e ocazional ce vrei și de obicei un bug.

Sortarea unei liste de datetimes aware din fusuri orare diferite funcționează cum te-ai aștepta: ordinea e după momentul absolut, nu după ceasul de pe perete.

events: list[datetime] = [
    datetime(2026, 5, 1, 14, 0, tzinfo=ZoneInfo("Europe/Rome")),
    datetime(2026, 5, 1, 9, 0, tzinfo=ZoneInfo("America/New_York")),
    datetime(2026, 5, 1, 22, 0, tzinfo=ZoneInfo("Asia/Tokyo")),
]
for e in sorted(events):
    print(e.astimezone(ZoneInfo("UTC")))

Cateva detalii in plus care merita stiute

Tipul date fără ora. from datetime import date. Util pentru zile de naștere, deadline-uri, orice unde ora din zi e fără sens. date e mereu naive, nu există noțiunea de fus orar pentru o dată calendaristică în sine. date.today() returnează data locală, ceea ce are aceeași clauză ca datetime.now(): depinde de fusul orar al sistemului.

datetime.utcnow() e deprecated. Eliminat în Python 3.12. Returna un datetime naive în UTC, ceea ce e ce e mai rău din ambele lumi: arată naive, secret UTC, ușor de confundat cu timp local naive. Înlocuitor: datetime.now(tz=timezone.utc).

time.time() pentru intervale monotone, uneori. Când vrei timp scurs pentru un benchmark sau un timeout, time.monotonic() e apelul corect. E un float de secunde, nu se mișcă înapoi când se ajustează ceasul sistemului și nu e afectat de DST. datetime e pentru timp calendaristic; time.monotonic() e pentru măsurat durate.

Timestamp-uri Unix. dt.timestamp() îți dă secunde de la epoch ca float. datetime.fromtimestamp(ts, tz=timezone.utc) parsează unul înapoi. Pasează mereu tz, forma fără argument returnează timp local naive.

Ce sa tii in cap

  • Fiecare datetime din codul tău e aware.
  • datetime.now(tz=timezone.utc) pentru „acum”. Niciodată datetime.now().
  • ZoneInfo("Europe/Rome") pentru fusuri orare. Doar nume IANA. Fără pytz.
  • dt.astimezone(tz) ca să convertești.
  • datetime.fromisoformat() și dt.isoformat() pentru serializare.
  • timedelta pentru ore/zile. relativedelta pentru luni/ani și „aceeași oră de perete mâine”.
  • Orice datetime naive din aplicația ta e un bug sau o graniță care are nevoie de un replace(tzinfo=...) explicit.

Lecția următoare: structurile mici de date care fac Python-ul de zi cu zi plăcut: Counter, defaultdict, deque și decoratorul @dataclass care înlocuiește mare parte din rest.


Referințe: datetime — Basic date and time types, zoneinfo — IANA time zone support, PEP 615 — Support for the IANA Time Zone Database in the Standard Library. Consultat 2026-05-01.

Caută