▦ Dati e database

Date e fusi orari nei database: come non sbagliare gli orari

UTC, ora legale, timestamp con e senza fuso: le regole pratiche per salvare date e orari in un database e mostrarli giusti agli utenti di ogni paese.

Le date sembrano il tipo di dato più semplice, finché un'applicazione non inizia a funzionare in più paesi, o finché arriva il cambio dell'ora. Ordini che risultano registrati il giorno sbagliato, promemoria inviati un'ora prima, report giornalieri che non tornano: quasi sempre la causa è un orario salvato senza sapere a quale fuso si riferisse. Poche regole, applicate fin dall'inizio, evitano la maggior parte di questi problemi.

Due tipi di informazione diversi

Prima di scegliere il tipo di colonna conviene distinguere che cosa si sta registrando.

  • Un istante preciso nel tempo. Il momento in cui un ordine è stato pagato, un utente ha effettuato l'accesso, un sensore ha misurato una temperatura. È lo stesso istante in tutto il mondo, anche se a Roma si legge 15:00 e a New York 9:00.
  • Una data o un orario «civile». Un compleanno, la data di scadenza di un contratto, l'orario di apertura di un negozio alle 9. Il compleanno è il 12 maggio ovunque si trovi la persona; il negozio apre alle 9 locali sia d'inverno sia d'estate.

Confondere i due casi è l'origine della maggior parte degli errori.

Regola 1: gli istanti si salvano in UTC

UTC, il tempo coordinato universale, è il riferimento che non ha ora legale e non cambia mai. Salvare gli istanti in UTC rende confrontabili tutti i valori, indipendentemente da dove sono stati generati, e risolve il problema del cambio d'ora.

In PostgreSQL il tipo timestamp with time zone (abbreviato timestamptz) fa esattamente questo: riceve un orario con il suo fuso, lo converte in UTC per la memorizzazione e lo riconverte nel fuso della sessione quando lo si legge. Nonostante il nome, non conserva il fuso originale.

CREATE TABLE accessi (
  id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  utente_id BIGINT NOT NULL,
  avvenuto  TIMESTAMPTZ NOT NULL DEFAULT now()
);

In MySQL il tipo TIMESTAMP converte in UTC in base al fuso della connessione, ma ha un intervallo limitato che termina nel 2038; DATETIME invece salva il valore così com'è, senza alcuna conversione. Con DATETIME la convenzione di scrivere sempre in UTC va rispettata dall'applicazione.

Regola 2: le date civili restano date civili

Un compleanno va salvato come DATE, senza orario e senza fuso. Salvarlo come mezzanotte di un istante in UTC porta all'errore classico: un utente in un fuso a ovest di Greenwich vede il compleanno spostato al giorno prima.

Lo stesso vale per gli orari ricorrenti, come «apertura alle 9:00»: si salva un orario locale più il fuso di riferimento, non un istante.

Regola 3: il fuso si indica per nome, non per scostamento

+01:00 non è un fuso orario: è uno scostamento valido in un certo momento. L'Italia è a +01:00 in inverno e a +02:00 con l'ora legale. Il fuso vero è identificato da un nome del database IANA, come Europe/Rome, che contiene tutte le regole storiche e attuali sui cambi d'ora.

Quando serve sapere in quale fuso si trovava un utente o un evento, si salva il nome in una colonna separata:

ALTER TABLE utenti ADD COLUMN fuso TEXT NOT NULL DEFAULT 'Europe/Rome';

E si converte in lettura:

SELECT avvenuto AT TIME ZONE u.fuso AS ora_locale
FROM accessi a JOIN utenti u ON u.id = a.utente_id;

Le trappole dell'ora legale

Nei paesi dell'Unione Europea l'ora legale inizia l'ultima domenica di marzo e termina l'ultima domenica di ottobre. Questo crea due situazioni particolari nell'ora locale:

  • l'ora che non esiste: in primavera gli orologi passano dalle 2:00 alle 3:00, quindi un orario locale come 2:30 di quel giorno non esiste;
  • l'ora che si ripete: in autunno si torna dalle 3:00 alle 2:00, quindi le 2:30 avvengono due volte.

Se si salvano orari locali senza fuso, nella notte autunnale due eventi a un'ora di distanza possono risultare simultanei, e l'ordine degli eventi diventa ambiguo. Con gli istanti in UTC il problema non esiste.

Un'altra conseguenza riguarda i calcoli: una giornata locale non dura sempre 24 ore. Il giorno del cambio d'ora ne dura 23 o 25. Aggiungere «un giorno» a un istante e aggiungere «24 ore» non dà sempre lo stesso risultato.

Report giornalieri: quale giorno?

«Le vendite di ieri» dipendono dal fuso. Per un'azienda italiana, ieri va da mezzanotte a mezzanotte ora di Roma, non UTC. La query corretta converte i limiti del giorno locale in istanti:

SELECT SUM(totale)
FROM ordini
WHERE pagato_il >= TIMESTAMP '2025-03-10 00:00' AT TIME ZONE 'Europe/Rome'
  AND pagato_il <  TIMESTAMP '2025-03-11 00:00' AT TIME ZONE 'Europe/Rome';

L'intervallo semiaperto, con >= all'inizio e < alla fine, evita di contare due volte gli eventi esattamente a mezzanotte e funziona anche nei giorni di 23 o 25 ore.

Una lista di controllo

  1. Per ogni colonna temporale decidere se è un istante o una data civile.
  2. Istanti in un tipo che normalizza a UTC, o in UTC per convenzione documentata.
  3. Date senza orario nel tipo DATE.
  4. Fusi salvati per nome IANA quando serve.
  5. Conversione nell'ora locale solo al momento della visualizzazione.
  6. Server, database e applicazione configurati in modo esplicito, senza dipendere dall'impostazione predefinita del sistema.

In sintesi

  • Istanti e date civili sono informazioni diverse e richiedono tipi diversi.
  • Gli istanti si salvano in UTC; la conversione all'ora locale avviene solo in visualizzazione.
  • Il fuso si identifica con un nome come Europe/Rome, non con uno scostamento fisso.
  • L'ora legale crea orari locali inesistenti o ripetuti e giorni di 23 o 25 ore.
  • I report giornalieri devono usare i limiti del giorno nel fuso dell'attività, con intervalli semiaperti.
MM
Marco Marzaduri

Marco Marzaduri si occupa di cybersecurity, informatica forense e sviluppo web. Laureato in informatica e in psicologia, unisce l'analisi tecnica alla comprensione del fattore umano.

Leggi anche