TIMESTAMPTZ vs TIMESTAMP: cruscotti e API in PostgreSQL
TIMESTAMPTZ vs TIMESTAMP in PostgreSQL: come il tipo scelto influisce su cruscotti, risposte API, conversioni di fuso orario e bug dell'ora legale.

Il problema reale: un evento, molte interpretazioni
Un evento avviene una sola volta, ma viene riportato in dozzine di modi diversi. Il database salva un valore, un'API lo serializza, un cruscotto lo raggruppa e ogni persona lo vede nel suo fuso orario. Se uno strato fa un'ipotesi diversa, la stessa riga può sembrare due momenti differenti.
Per questo TIMESTAMPTZ vs TIMESTAMP non è solo una preferenza di tipo dati. Decide se un valore memorizzato rappresenta un istante preciso nel tempo, o un orario di muro che ha senso solo in un luogo particolare.
Questo è ciò che di solito si rompe per primo: un cruscotto vendite mostra totali giornalieri diversi a New York e a Berlino. Un grafico orario ha un'ora mancante o duplicata durante i cambi dell'ora legale (DST). Un registro di audit sembra fuori ordine perché due sistemi “sono d'accordo” sulla data ma non sull'istante reale.
Un modello semplice ti tiene lontano dai guai:
- Storage: cosa salvi in PostgreSQL e cosa rappresenta.
- Display: come lo formatti in una UI, in un'esportazione o in un report.
- User locale: il fuso orario e le regole del calendario del visualizzatore, inclusa l'ora legale.
Se li mescoli, ottieni bug di reporting silenziosi. Un team di supporto esporta “ticket creati ieri” da un cruscotto e poi lo confronta con un report API. Entrambi sembrano ragionevoli, ma uno ha usato il confine di mezzanotte locale del visualizzatore mentre l'altro ha usato UTC.
L'obiettivo è semplice: per ogni valore temporale prendi due decisioni chiare. Decidi cosa memorizzi e cosa mostri. La stessa chiarezza deve attraversare il modello dati, le risposte API e i cruscotti così che tutti vedano la stessa linea temporale.
Cosa significano davvero TIMESTAMP e TIMESTAMPTZ
In PostgreSQL i nomi sono fuorvianti. Sembrano descrivere ciò che viene memorizzato, ma per lo più descrivono come PostgreSQL interpreta l'input e formatta l'output.
TIMESTAMP (alias timestamp without time zone) è solo una data del calendario e un orario di muro, come 2026-01-29 09:00:00. Nessun fuso è allegato. PostgreSQL non lo converte per te. Due persone in fusi orari diversi possono leggere lo stesso TIMESTAMP e assumere momenti reali diversi.
TIMESTAMPTZ (alias timestamp with time zone) rappresenta un punto reale nel tempo. Pensalo come un istante. PostgreSQL lo normalizza internamente (effettivamente in UTC), poi lo mostra nel fuso orario che la sessione sta usando.
Il comportamento dietro la maggior parte delle sorprese è:
- In input: PostgreSQL converte i valori
TIMESTAMPTZin un unico istante confrontabile. - In output: PostgreSQL formatta quell'istante usando il fuso orario della sessione corrente.
- Per
TIMESTAMP: non avviene alcuna conversione automatica in input o output.
Un piccolo esempio mostra la differenza. Supponiamo che la tua app riceva 2026-03-08 02:30 da un utente. Se la inserisci in una colonna TIMESTAMP, PostgreSQL memorizza esattamente quel valore di orologio di muro. Se quell'orario locale non esiste a causa di un salto DST, potresti non accorgertene fino a quando i report non si rompono.
Se lo inserisci in TIMESTAMPTZ, PostgreSQL ha bisogno di un fuso per interpretare il valore. Se fornisci 2026-03-08 02:30 America/New_York, PostgreSQL lo converte in un istante (o lancia un errore a seconda delle regole e del valore esatto). Più tardi, un cruscotto a Londra mostrerà un orario locale differente, ma è lo stesso istante.
Un malinteso comune: la gente vede “with time zone” e si aspetta che PostgreSQL memorizzi l'etichetta del fuso originale. Non lo fa. PostgreSQL memorizza il momento, non l'etichetta. Se hai bisogno del fuso originale dell'utente per la visualizzazione (per esempio, “mostra nell'ora locale del cliente”), memorizza la zona separatamente come campo testo.
Fuso orario della sessione: l'impostazione nascosta dietro molte sorprese
PostgreSQL ha un'impostazione che cambia silenziosamente ciò che vedi: il fuso orario della sessione. Due persone possono eseguire la stessa query sugli stessi dati e ottenere orari diversi perché le loro sessioni usano fusi diversi.
Questo colpisce per lo più TIMESTAMPTZ. PostgreSQL memorizza un momento assoluto, poi lo mostra nel fuso orario della sessione. Con TIMESTAMP (senza fuso), PostgreSQL tratta il valore come orario di muro. Non lo sposta per la visualizzazione, ma il fuso della sessione può comunque creare problemi quando lo converti in TIMESTAMPTZ o lo confronti con valori con fuso.
I fusi di sessione vengono spesso impostati senza che tu te ne accorga: configurazioni all'avvio dell'app, parametri del driver, pool di connessioni che riusano sessioni vecchie, strumenti BI con default propri, job ETL che ereditano le impostazioni locali del server o console SQL manuali che usano le preferenze del tuo portatile.
Ecco come i team finiscono a litigare. Supponiamo che un evento sia memorizzato come 2026-03-08 01:30:00+00 in una colonna TIMESTAMPTZ. Una sessione di cruscotto impostata su America/Los_Angeles lo mostrerà come l'orario locale della sera precedente, mentre una sessione API in UTC mostrerà un orario diverso. Se un grafico raggruppa per giorno usando il giorno locale della sessione, ottieni totali giornalieri diversi.
-- Make your output consistent for a reporting job
SET TIME ZONE 'UTC';
SELECT created_at, date_trunc('day', created_at) AS day_bucket
FROM events;
Per qualsiasi cosa produca report o risposte API, rendi esplicito il fuso orario. Impostalo alla connessione (o esegui SET TIME ZONE prima), scegli uno standard per output macchina (spesso UTC) e per i report in “tempo business locale” imposta la zona aziendale dentro il job, non sul portatile di qualcuno. Se usi connessioni in pool, resetta le impostazioni di sessione quando una connessione viene estratta.
Come si rompono i cruscotti: raggruppamenti, bucket e buchi dovuti al DST
I cruscotti sembrano semplici: conta ordini al giorno, mostra iscrizioni all'ora, confronta settimana su settimana. I problemi iniziano quando il database memorizza un “istante” ma il grafico lo trasforma in molti diversi “giorni”, a seconda di chi guarda.
Se raggruppi per giorno usando il fuso orario locale di un utente, due persone possono vedere date diverse per lo stesso evento. Un ordine effettuato alle 23:30 a Los Angeles è già “domani” a Berlino. E se la tua SQL raggruppa con DATE(created_at) su un TIMESTAMP semplice, non stai raggruppando per un istante reale. Stai raggruppando per una lettura di orologio di muro senza fuso.
I grafici orari diventano più complessi attorno al DST. in primavera, un'ora locale non succede mai, quindi i grafici possono mostrare un gap. In autunno, un'ora locale succede due volte, quindi puoi avere un picco o bucket duplicati se la query e il cruscotto non concordano su quale 01:30 intendi.
Una domanda pratica aiuta: stai tracciando momenti reali (sicuri da convertire), oppure orari programmati locali (non devono essere convertiti)? I cruscotti quasi sempre vogliono momenti reali.
Quando raggruppare in UTC vs in un fuso aziendale
Scegli una regola di raggruppamento e applicala ovunque (SQL, API, strumento BI), altrimenti i totali derivano.
Raggruppa in UTC quando vuoi una serie globale e coerente (salute del sistema, traffico API, iscrizioni globali). Raggruppa in un fuso aziendale quando “il giorno” ha un significato legale o operativo (giorno di negozio, SLA di supporto, chiusura contabile). Raggruppa per il fuso del visualizzatore solo quando la personalizzazione è più importante della comparabilità (feed attività personali).
Ecco il pattern per un raggruppamento coerente per “giorno aziendale”:
SELECT date_trunc('day', created_at AT TIME ZONE 'America/New_York') AS business_day,
count(*)
FROM orders
GROUP BY 1
ORDER BY 1;
Etichette che evitano sfiducia
Le persone smettono di fidarsi dei grafici quando i numeri saltano e nessuno sa spiegare perché. Etichetta la regola direttamente nell'interfaccia: “Ordini giornalieri (America/New_York)” o “Eventi orari (UTC)”. Usa la stessa regola in esportazioni e API.
Una semplice regola per reporting e API
Decidi se stai memorizzando un istante oppure una lettura di orologio locale. Mescolare i due è dove cruscotti e API iniziano a non essere d'accordo.
Un insieme di regole che mantiene il reporting prevedibile:
- Memorizza gli eventi del mondo reale come istanti usando
TIMESTAMPTZ, e tratta UTC come fonte di verità. - Memorizza concetti di business come “giorno di fatturazione” separatamente come
DATE(o un campo di ora locale se hai davvero bisogno dell'orario di muro). - Nelle API, restituisci timestamp in ISO 8601 e sii coerente: includi sempre un offset (come
+02:00) o usa sempreZper UTC. - Converti ai bordi (UI e layer di reporting). Evita conversioni avanti e indietro nella logica del database e nei job in background.
Perché questo regge: i cruscotti bucketizzano e confrontano intervalli. Se salvi istanti (TIMESTAMPTZ), PostgreSQL può ordinare e filtrare eventi in modo affidabile anche quando il DST cambia. Poi scegli come mostrarli o raggrupparli. Se salvi un orario di muro (TIMESTAMP) senza fuso, PostgreSQL non può sapere cosa significa, perciò il raggruppamento può cambiare quando il fuso della sessione cambia.
Tieni separate le “date business locali” perché non sono istanti. “Consegnare il 2026-03-08” è una decisione di data, non un istante. Se lo forzi in un timestamp, i giorni con DST possono creare ore locali mancanti o duplicate, che poi appaiono come gap o picchi.
Passo dopo passo: scegliere il tipo giusto per ogni valore temporale
Scegliere tra TIMESTAMPTZ e TIMESTAMP inizia con una domanda: questo valore descrive un momento reale che è accaduto, o un orario locale che vuoi mantenere esattamente come scritto?
1) Separa eventi reali da orari locali programmati
Fai un inventario rapido delle tue colonne.
Eventi reali (click, pagamenti, login, spedizioni, letture di sensori, messaggi di supporto) dovrebbero di norma essere memorizzati come TIMESTAMPTZ. Vuoi un istante univoco, anche se le persone lo vedono da fusi diversi.
Gli orari locali programmati sono diversi: “Il negozio apre alle 09:00”, “Finestra di pickup 16:00–18:00”, “La fatturazione parte il 1° alle 10:00 ora locale”. Questi sono spesso migliori come TIMESTAMP più un campo separato per il fuso orario, perché l'intento è legato all'orologio di un luogo.
2) Scegli uno standard e mettilo per iscritto
Per la maggior parte dei prodotti, un buon default è: memorizza i tempi degli eventi in UTC, presentali nel fuso orario dell'utente. Documentalo in luoghi che le persone leggono davvero: note dello schema, documentazione API e descrizioni dei cruscotti. Definisci anche cosa significa “giorno aziendale” (giorno UTC, giorno zona aziendale, o giorno locale del visualizzatore), perché quella scelta guida il reporting giornaliero.
Una breve checklist pratica:
- Contrassegna ogni colonna temporale come “istante evento” o “programma locale”.
- Default per istanti evento:
TIMESTAMPTZmemorizzato in UTC. - Quando cambi schemi, backfilla con cura e valida righe di esempio a mano.
- Standardizza i formati API (includi sempre
Zo un offset per gli istanti). - Imposta esplicitamente il fuso della sessione in job ETL, connettori BI e worker in background.
Fai attenzione al lavoro di “converti e backfilla”. Cambiare il tipo di colonna può cambiare silenziosamente il significato se i vecchi valori sono stati interpretati sotto un fuso di sessione diverso.
Errori comuni che causano bug di un giorno e problemi con il DST
La maggior parte dei bug temporali non è “PostgreSQL che fa cose strane”. Deriva dal memorizzare un valore che sembra corretto con il significato sbagliato, poi lasciare che gli strati superiori indovinino il contesto mancante.
Errore 1: salvare un orario di muro come se fosse assoluto
Un tranello comune è memorizzare orari locali (come “2026-03-29 09:00” a Berlino) in un TIMESTAMPTZ. PostgreSQL lo tratta come un istante e lo converte in base al fuso della sessione corrente. Se l'intento era “sempre le 9:00 ora locale”, l'hai perso. Visualizzando la stessa riga sotto un fuso diverso la ora mostrata si sposta.
Per appuntamenti, memorizza l'orario locale come TIMESTAMP più un campo separato per il fuso (o la località). Per eventi che sono accaduti in un preciso momento (pagamenti, login), memorizza l'istante come TIMESTAMPTZ.
Errore 2: ambienti diversi, assunzioni diverse
Il tuo portatile, lo staging e la produzione potrebbero non condividere lo stesso fuso. Un ambiente gira in UTC, un altro in ora locale, e i report “group by day” iniziano a non coincidere. I dati non sono cambiati, è cambiata l'impostazione di sessione.
Errore 3: usare funzioni temporali senza sapere cosa promettono
now() e current_timestamp sono stabili all'interno di una transazione. clock_timestamp() cambia ad ogni chiamata. Se generi timestamp in più punti di una transazione e mescoli queste funzioni, ordinamento e durate possono sembrare strani.
Errore 4: convertire due volte (o zero volte)
Un errore API frequente: l'app converte un orario locale in UTC, lo invia come stringa naive, poi il database lo converte di nuovo perché assume che l'input fosse locale. L'opposto succede anche: l'app invia un orario locale ma lo etichetta con Z (UTC), spostandolo quando viene renderizzato.
Errore 5: raggruppare per data senza dichiarare il fuso inteso
“Totali giornalieri” dipende dal confine di giorno che intendi. Se raggruppi con date(created_at) su un TIMESTAMPTZ, il risultato segue il fuso della sessione. Gli eventi di tarda sera possono spostarsi al giorno precedente o successivo.
Prima di rilasciare un cruscotto o un'API, verifica le basi: scegli un fuso di reporting per ogni grafico e applicalo coerentemente, includi offset (o Z) nelle API, allinea staging e produzione sulla policy dei fusi e dichiara esplicitamente quale fuso intendi quando raggruppi.
Controlli rapidi prima di lanciare un cruscotto o un'API
I bug temporali raramente nascono da una sola query sbagliata. Avvengono perché storage, reporting e API fanno ciascuno una stima leggermente diversa.
Usa una breve checklist pre-lancio:
- Per eventi reali (iscrizioni, pagamenti, ping di sensori) memorizza l'istante come
TIMESTAMPTZ. - Per concetti locali aziendali (giorno di fatturazione, data di report) memorizza un
DATEoTIME, non un timestamp che prevedi di “convertire dopo”. - Nei job schedulati e nei runner di report, imposta il fuso della sessione intenzionalmente.
- Nelle risposte API includi un offset o
Z, e conferma che il client lo interpreta come valore con fuso. - Testa la settimana della transizione DST per almeno un fuso target.
Una validazione end-to-end rapida: scegli un evento noto come edge-case (per esempio 2026-03-08 01:30 in una zona che osserva DST) e seguilo attraverso memorizzazione, output query, JSON API e etichetta finale del grafico. Se il grafico mostra il giorno giusto ma il tooltip l'ora sbagliata (o viceversa), hai un mismatch di conversione.
Esempio: perché due team non sono d'accordo sugli stessi numeri giornalieri
Un team di supporto a New York e un team finance a Berlino guardano lo stesso cruscotto. Il server DB gira in UTC. Tutti insistono che i loro numeri siano corretti, ma “ieri” è diverso a seconda di chi chiedi.
Ecco l'evento: un ticket cliente viene creato alle 23:30 a New York il 10 marzo. È 04:30 UTC l'11 marzo e 05:30 a Berlino. Un istante reale, tre date di calendario diverse.
Se il tempo di creazione è memorizzato come TIMESTAMP (senza fuso) e la tua app assume che sia “locale”, puoi riscrivere la storia silenziosamente. New York potrebbe trattare 2026-03-10 23:30 come tempo New York, mentre Berlino interpreta lo stesso valore memorizzato come tempo di Berlino. La stessa riga finisce in giorni diversi per visualizzatori diversi.
Se è memorizzato come TIMESTAMPTZ, PostgreSQL conserva l'istante coerente e lo converte solo quando qualcuno lo visualizza o lo formatta. Ecco perché TIMESTAMPTZ vs TIMESTAMP cambia cosa significa “un giorno” nei report.
La soluzione è separare due idee: l'istante in cui l'evento è successo e la data di reporting che vuoi usare.
Un pattern pratico:
- Memorizza l'ora dell'evento come
TIMESTAMPTZ. - Decidi la regola di reporting: locale del visualizzatore (cruscotti personali) o un fuso aziendale (finance aziendale).
- Calcola la data di reporting a runtime seguendo quella regola: converti l'istante nella zona scelta, poi prendi la
date.
Passi successivi: standardizza la gestione del tempo nello stack
Se la gestione del tempo non è scritta, ogni nuovo report diventa un gioco d'azzardo. Mira a un comportamento temporale noioso e prevedibile attraverso database, API e cruscotti.
Scrivi un breve “contratto sul tempo” che risponda a tre domande:
- Standard tempo evento: memorizza gli istanti evento come
TIMESTAMPTZ(tipicamente in UTC) a meno che tu non abbia una forte ragione per non farlo. - Fuso aziendale: scegli una zona per il reporting e usala coerentemente quando definisci “giorno”, “settimana” e “mese”.
- Formato API: invia sempre timestamp con un offset (ISO 8601 con
Zo+/-HH:MM) e documenta se i campi significano “istante” o “orario locale di muro”.
Aggiungi piccoli test intorno all'inizio e alla fine dell'ora legale. Catturano bug costosi in anticipo. Per esempio, valida che una query di “totale giornaliero” sia stabile per una zona aziendale fissa attraverso un cambiamento DST, e che input API come 2026-11-01T01:30:00-04:00 e 2026-11-01T01:30:00-05:00 siano trattati come due istanti distinti.
Pianifica le migrazioni con cura. Cambiare tipi e assunzioni sul posto può riscrivere silenziosamente la storia nei grafici. Un approccio più sicuro è aggiungere una colonna nuova (per esempio created_at_utc TIMESTAMPTZ), backfillarla con una conversione verificata, aggiornare le letture per usare la colonna nuova e poi aggiornare le scritture. Mantieni vecchi e nuovi report affiancati brevemente così gli spostamenti nei numeri giornalieri sono evidenti.
Se vuoi un posto unico per applicare questo “contratto sul tempo” tra modelli dati, API e schermate, una configurazione unificata di build aiuta. AppMaster (appmaster.io) genera backend, web app e API da un singolo progetto, il che rende più semplice mantenere coerenti le regole di memorizzazione e visualizzazione dei timestamp mentre la tua app cresce.
FAQ
Usa TIMESTAMPTZ per qualsiasi evento che sia avvenuto in un preciso istante (iscrizioni, pagamenti, accessi, messaggi, rilevazioni di sensori). Memorizza un istante univoco che può essere ordinato, filtrato e confrontato tra sistemi. Usa TIMESTAMP solo quando il valore deve rimanere esattamente come orario di muro e di norma va accompagnato da un campo separato per il fuso orario o la località.
TIMESTAMPTZ rappresenta un istante reale nel tempo; PostgreSQL lo normalizza internamente e poi lo mostra nel fuso orario della sessione. TIMESTAMP è solo una data e un orario senza fuso, quindi PostgreSQL non lo sposta automaticamente. La differenza chiave è il significato: istante vs orario di muro locale.
Perché la visualizzazione di TIMESTAMPTZ dipende dal fuso orario della sessione che formatta il valore e da come certi input vengono interpretati. Due strumenti possono interrogare la stessa riga e mostrare orari diversi se una sessione è impostata su UTC e un'altra su America/Los_Angeles. Per report e API imposta esplicitamente il fuso orario della sessione così i risultati non dipendono da default nascosti.
Perché “un giorno” dipende dal confine di fuso orario. Se un cruscotto raggruppa per il fuso orario del visualizzatore mentre un altro usa UTC (o un fuso aziendale), gli eventi a tarda sera possono cadere su giorni diversi e modificare i totali giornalieri. Risolvi scegliendo una regola di raggruppamento per ogni grafico (UTC o un fuso aziendale) e usandola coerentemente in SQL, BI ed esportazioni.
L'ora legale crea ore locali mancanti o duplicate, che provocano gap o bucket doppi se raggruppi per tempo locale. Se i tuoi dati rappresentano momenti reali, salvali come TIMESTAMPTZ e scegli un fuso chiaro per i bucket nei grafici. Inoltre testa la settimana della transizione dell'ora legale per i fusi target per catturare sorprese in anticipo.
No, PostgreSQL non conserva l'etichetta originale del fuso orario con TIMESTAMPTZ; memorizza l'istante. Quando lo interroghi, PostgreSQL lo mostra nel fuso orario della sessione, che può essere diverso da quello originale dell'utente. Se devi “mostrarlo nel fuso orario del cliente”, memorizza quel fuso separatamente in un'altra colonna.
Restituisci timestamp ISO 8601 che includano un offset e sii coerente. Un default semplice è restituire sempre UTC con Z per gli istanti, poi lascia che i client convertano per la visualizzazione. Evita di inviare stringhe “naive” come 2026-03-10 23:30:00 perché i client potrebbero indovinare il fuso in modo diverso.
Converti ai bordi: memorizza gli istanti evento come TIMESTAMPTZ, poi converti al fuso desiderato quando mostri o raggruppi per reporting. Evita conversioni ripetute dentro trigger, job in background ed ETL a meno che non ci sia un contratto chiaro. La maggior parte dei problemi di reporting arriva da doppie conversioni o dalla miscelazione di valori naive e con fuso.
Usa DATE per concetti aziendali che sono davvero date, come “giorno di fatturazione”, “data di report” o “data di consegna”. Usa TIME (o TIMESTAMP insieme a un fuso orario separato) per orari programmati come “apre alle 09:00 ora locale”. Non forzare questi concetti in TIMESTAMPTZ a meno che tu non intenda un singolo istante, perché l'ora legale e i cambi di fuso possono spostare il significato previsto.
Prima decidi se il valore è un istante (TIMESTAMPTZ) o un orario locale (TIMESTAMP più fuso), poi aggiungi una nuova colonna invece di riscrivere in place. Backfilla con conversioni verificate sotto un fuso orario di sessione noto e valida righe di esempio intorno a mezzanotte e ai confini dell'ora legale. Esegui vecchi e nuovi report in parallelo per rilevare spostamenti nei totali prima di rimuovere la colonna vecchia.


