Allo stesso modo in cui DATETIMEOFFSET e AT TIME ZONE di SQL Server esistono perché i timezone sono difficili, Python ha passato gli ultimi quindici anni a combattere attraverso diverse iterazioni dello stesso problema. Lo stato attuale, a maggio 2026 con Python 3.13, è finalmente buono. datetime per il valore, zoneinfo per il timezone, entrambi nella standard library, entrambi corretti.
Il rovescio della medaglia: il linguaggio ti permette ancora di creare datetime senza timezone collegato. Quelli si chiamano naive, sembrano datetime veri, vengono stampati come datetime veri, e se li mescoli con quelli aware ottieni risposte sbagliate o crash. La maggior parte dei bug di date in produzione si riconduce a un datetime naive che si è infilato di nascosto.
Questa lezione è come evitare che succeda.
Naive vs aware: l’unica distinzione che conta
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
naive: datetime = datetime(2026, 5, 1, 14, 30)
# 2026-05-01 14:30:00 -- ma 14:30 *dove*?
aware: datetime = datetime(2026, 5, 1, 14, 30, tzinfo=ZoneInfo("Europe/Rome"))
# 2026-05-01 14:30:00+02:00 -- non ambiguo
Un datetime naive non ha tzinfo. Sono solo numeri. Python non sa se intende UTC, ora di Roma, ora di Tokyo, o qualche locale. È una convenzione Python senza semantica.
Un datetime aware ha un tzinfo. Si riferisce a un momento specifico nel tempo che qualunque altro timezone può convertire da o verso.
Puoi controllare:
def is_aware(dt: datetime) -> bool:
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
E il linguaggio non ti aiuta a mescolarli. Questo solleva un’eccezione:
naive - aware
# TypeError: can't subtract offset-naive and offset-aware datetimes
Che è l’esito buono. L’esito brutto è quando aggiungi un timedelta a un datetime naive e usi il risultato da qualche parte che presupponeva UTC. Nessun errore. Risposta sbagliata. I dashboard derivano. I cron job partono all’ora sbagliata. Lo scopri due mesi dopo quando un cliente di Lisbona se ne accorge.
La regola d’oro: salva in UTC, converti ai bordi
La stessa regola della lezione SQL si applica in Python.
- Tutti i datetime nel tuo database: UTC, aware.
- Tutti i datetime dentro la logica della tua applicazione: UTC, aware.
- Tutti i datetime in arrivo da un utente o un’API: convertili in UTC al confine.
- Tutti i datetime mostrati a un utente: convertili da UTC al loro timezone locale al confine.
UTC non ha ora legale. L’aritmetica UTC è non ambigua. “1 ora dopo in UTC” è sempre 60 minuti. Nel momento in cui lasci che l’ora locale si infili nella tua business logic, hai aggiunto un bug.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# Adesso in UTC, aware
utc_now: datetime = datetime.now(tz=timezone.utc)
# Converti in Roma per una stringa di display
rome_now: datetime = utc_now.astimezone(ZoneInfo("Europe/Rome"))
# Riconverti se mai ne avessi bisogno (non l'avrai, hai salvato UTC)
back: datetime = rome_now.astimezone(timezone.utc)
datetime.now(tz=...) ti dà sempre un datetime aware. Usalo. Il semplice datetime.now() (senza tz) restituisce un datetime naive nel timezone locale del sistema, che è la singola fonte più comune di bug di date in Python. Fai finta che non esista.
zoneinfo: stdlib dalla 3.9
Per un decennio la risposta era la libreria di terze parti pytz. Funzionava ma aveva un’API strana: dovevi chiamare pytz.timezone("Europe/Rome").localize(dt) invece di passare il timezone al costruttore. Sbagliarsi dava risultati silenziosamente sbagliati.
zoneinfo, aggiunto in Python 3.9 (PEP 615), usa il database IANA dei timezone del tuo sistema operativo: lo stesso pacchetto tzdata che tutto il resto del sistema usa. L’API è quella ovvia:
from zoneinfo import ZoneInfo
rome: ZoneInfo = ZoneInfo("Europe/Rome")
ny: ZoneInfo = ZoneInfo("America/New_York")
tokyo: ZoneInfo = ZoneInfo("Asia/Tokyo")
Su Windows, dove non c’è un tzdata di sistema, devi installare il pacchetto tzdata: pip install tzdata. Su Linux e macOS è già lì. In un container, installa il pacchetto tzdata di sistema o includi la wheel Python di tzdata.
Usa nomi IANA: Europe/Rome, America/New_York, Asia/Tokyo. Non usare offset come UTC+2 come timezone: non sanno nulla dell’ora legale. Il senso di un timezone con nome è che l’offset varia durante l’anno.
Se vedi ancora pytz in un codebase, pianifica una migrazione. Non è ancora deprecato, ma zoneinfo è il futuro. La conversione è meccanica: pytz.timezone("Europe/Rome") diventa ZoneInfo("Europe/Rome"), e puoi togliere le chiamate a .localize().
Parsing e formattazione
Leggere una stringa ISO 8601 in 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)))
In Python 3.11 fromisoformat è stato aggiornato per gestire l’intera spec ISO 8601: secondi frazionari, suffisso Z per UTC, formati base ed estesi. Prima della 3.11 era un sottoinsieme più ristretto. Sulla 3.13 usalo e basta; copre tutto quello che è ragionevole.
dt = datetime.fromisoformat("2026-05-01T14:30:00Z") # UTC
dt = datetime.fromisoformat("2026-05-01T14:30:00.123456+00:00") # microsecondi
Riscrivere come ISO:
s: str = dt.isoformat() # '2026-05-01T14:30:00+02:00'
Per formati umani, 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'
I codici di formato seguono lo strftime del C: %Y anno a quattro cifre, %m mese con zero davanti, %d giorno con zero davanti, %H orologio a 24 ore, %M minuti, %S secondi. Cerca la tabella una volta.
Per lo scambio tra macchine — API, JSON, database — usa sempre ISO 8601. dt.isoformat() e datetime.fromisoformat(). Niente formati locali da nessuna parte vicino a un protocollo di rete.
timedelta: aritmetica sul tempo
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)
# La differenza tra due datetime è un timedelta
elapsed: timedelta = now - one_hour_ago
elapsed.total_seconds() # 3600.0
timedelta è l’unico oggetto che dovresti usare per “spostati di una certa quantità di tempo”. È semplice e ben definito.
Cosa timedelta non ha: mesi e anni. Perché i mesi non hanno una lunghezza fissa. “1 mese dopo il 31 gennaio” è a volte il 28 febbraio, a volte il 29 febbraio, a seconda dell’anno. Non c’è un timedelta(months=1).
Per l’aritmetica su mesi e anni, usa dateutil.relativedelta (l’unica cosa nel pacchetto dateutil che resta essenziale):
from dateutil.relativedelta import relativedelta
dt: datetime = datetime(2026, 1, 31, tzinfo=timezone.utc)
dt + relativedelta(months=1) # 2026-02-28 (clampato)
dt + relativedelta(years=1) # 2027-01-31
dt + relativedelta(months=1, days=-1) # ultimo giorno di febbraio meno logica extra
È di terze parti (pip install python-dateutil), ma è una dipendenza stabile in giro da Python 2.5. Per qualunque calcolo “mese prossimo” o “1 anno fa”, ricorri a essa.
La trappola DST
Il motivo per cui i datetime naive sono pericolosi, in un esempio:
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
rome: ZoneInfo = ZoneInfo("Europe/Rome")
# Sabato prima dell'inizio del DST nel 2026 (DST inizia domenica 29 marzo a Roma)
sat: datetime = datetime(2026, 3, 28, 12, 0, tzinfo=rome)
# Aggiunta naive: "24 ore dopo"
sun: datetime = sat + timedelta(hours=24)
print(sun) # 2026-03-29 13:00:00+02:00 -- nota: 13:00, non 12:00
24 ore dopo mezzogiorno di sabato sono le 13:00 di domenica, non mezzogiorno di domenica, perché gli orologi sono saltati avanti alle 02:00. Se volevi “stessa ora del giorno, il giorno dopo”, timedelta(days=1) è lo strumento sbagliato. Vuoi relativedelta(days=1), che mantiene l’ora del wall-clock:
sun = sat + relativedelta(days=1)
print(sun) # 2026-03-29 12:00:00+02:00 -- stessa ora del wall-clock, momento UTC diverso
I due sono momenti diversi nel tempo reale. Scegli quello che la tua business logic vuole davvero. “Manda un promemoria 24 ore dopo” probabilmente vuole timedelta. “Avvia la riunione alla stessa ora locale domani” vuole relativedelta.
Questo è il tipo di bug che si nasconde per metà dell’anno. Testa il tuo codice di scheduling contro una data subito dopo una transizione DST.
Esempio reale: una riunione tra timezone
from datetime import datetime
from zoneinfo import ZoneInfo
# Una riunione fissata in ora locale di Roma
meeting_rome: datetime = datetime(2026, 6, 15, 10, 0, tzinfo=ZoneInfo("Europe/Rome"))
# Converti per i partecipanti a NYC e 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')}")
Output:
Marco (Rome): 2026-06-15 10:00 CEST
Alex (NYC): 2026-06-15 04:00 EDT
Yuki (Tokyo): 2026-06-15 17:00 JST
Tre orari locali corretti, nessun bug di un’ora di troppo, nessuna aritmetica manuale di offset, nessun ripensamento sull’ora legale di oggi. Definisci il momento una volta, in un timezone, e lascia che astimezone produca il resto.
Il confine col database
Se stai usando SQLAlchemy o Django, configura le tue colonne TIMESTAMPTZ e gli ORM ti restituiranno datetime aware. Con 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) mappa su TIMESTAMPTZ di PostgreSQL e restituisce datetime aware. Il callable di default usa UTC. Combinati, ogni riga ha un timestamp non ambiguo dalla creazione alla visualizzazione.
Confrontare e ordinare
I datetime aware si confrontano correttamente attraverso i timezone, perché sotto il cofano sono punti nel tempo assoluto:
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 -- mezzogiorno NY è 6 ore dopo mezzogiorno Roma
Funziona perché confrontare datetime aware converte entrambi i lati in UTC. Naive vs aware nello stesso confronto solleva TypeError. Naive vs naive si confronta come numeri grezzi, senza coscienza del timezone, che è occasionalmente quello che vuoi e di solito un bug.
Ordinare una lista di datetime aware da timezone diversi funziona come ti aspetti: l’ordine è per momento assoluto nel tempo, non per ora del wall-clock.
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")))
Qualche altro dettaglio che vale la pena sapere
Il tipo date senza un’ora. from datetime import date. Utile per compleanni, scadenze, qualunque cosa in cui l’ora del giorno non ha senso. date è sempre naive: non c’è nozione di timezone per una data calendariale presa da sola. date.today() restituisce la data locale, che ha lo stesso caveat di datetime.now(): dipende dal timezone di sistema.
datetime.utcnow() è deprecato. Rimosso in Python 3.12. Restituiva un datetime naive in UTC, che è il peggio dei due mondi: sembra naive, segretamente UTC, facile da confondere con l’ora locale naive. Sostituto: datetime.now(tz=timezone.utc).
time.time() per intervalli monotonici, a volte. Quando vuoi tempo trascorso per un benchmark o un timeout, time.monotonic() è la chiamata giusta. È un float di secondi, non torna indietro quando l’orologio di sistema viene aggiustato, e non è influenzato dal DST. datetime è per il tempo calendariale; time.monotonic() è per misurare durate.
Timestamp Unix. dt.timestamp() ti dà secondi dall’epoca come float. datetime.fromtimestamp(ts, tz=timezone.utc) ne fa il parsing all’inverso. Passa sempre tz: la forma senza argomenti restituisce ora locale naive.
Cosa tenere in testa
- Ogni datetime nel tuo codice è aware.
datetime.now(tz=timezone.utc)per “adesso”. Maidatetime.now().ZoneInfo("Europe/Rome")per i timezone. Solo nomi IANA. Nientepytz.dt.astimezone(tz)per convertire.datetime.fromisoformat()edt.isoformat()per la serializzazione.timedeltaper ore/giorni.relativedeltaper mesi/anni e “stessa ora del wall-clock domani”.- Qualunque datetime naive nella tua app è un bug o un confine che richiede un esplicito
replace(tzinfo=...).
Prossima lezione: le piccole strutture dati che rendono Python di tutti i giorni piacevole: Counter, defaultdict, deque, e il decoratore @dataclass che sostituisce la maggior parte del resto.
Riferimenti: datetime — Basic date and time types, zoneinfo — IANA time zone support, PEP 615 — Support for the IANA Time Zone Database in the Standard Library. Recuperato il 2026-05-01.