Datums- und Zeit-Typen sind in jeder Datenbank eine Quelle von Bugs — Postgres ist da keine Ausnahme. Die wichtigste Empfehlung gleich vorab: für jede Spalte, die einen Zeitpunkt speichern soll (created_at, paid_at, …), nimm timestamptz (= timestamp with time zone), nicht timestamp. Hier zeigen wir warum, dazu die anderen Zeit-Typen und ihre Eigenheiten.
Die fünf Zeit-Typen
| Typ | Was speichert er | Beispiel | Größe |
|---|---|---|---|
date | Nur Datum | '2026-05-07' | 4 Bytes |
time | Nur Uhrzeit | '14:30:00' | 8 Bytes |
timestamp | Datum + Uhrzeit, ohne Zeitzone | '2026-05-07 14:30:00' | 8 Bytes |
timestamptz | Datum + Uhrzeit, mit Zeitzonen-Bezug | '2026-05-07 14:30:00+00' | 8 Bytes |
interval | Zeitspanne | '2 hours 30 minutes', '7 days' | 16 Bytes |
timestamptz ist die Abkürzung für timestamp with time zone. Der Name ist irreführend — er suggeriert, dass die Zeitzone gespeichert wird. Tatsächlich speichert Postgres beide Typen (timestamp und timestamptz) identisch als Zeitpunkt in UTC. Der Unterschied liegt nur darin, wie Eingaben interpretiert und Ausgaben formatiert werden.
timestamp vs. timestamptz — der entscheidende Unterschied
Stell dir zwei Tabellen vor:
CREATE TABLE events_a (occurred_at timestamp); -- ohne TZ
CREATE TABLE events_b (occurred_at timestamptz); -- mit TZBeide Spalten brauchen 8 Bytes. Was passiert, wenn wir denselben Wert einfügen?
myapp=> SET TIME ZONE 'Europe/Berlin';
SET
myapp=> INSERT INTO events_a VALUES ('2026-05-07 14:30:00');
myapp=> INSERT INTO events_b VALUES ('2026-05-07 14:30:00');
myapp=> SELECT * FROM events_a;
occurred_at
---------------------
2026-05-07 14:30:00 -- exakt wie eingegeben
myapp=> SELECT * FROM events_b;
occurred_at
------------------------
2026-05-07 14:30:00+02 -- als Berlin-Zeit interpretiertevents_a (timestamp) speichert die Zahlen blind — „14:30 Uhr”, was auch immer das heißen mag. events_b (timestamptz) interpretiert die Eingabe als „Berlin-Zeit”, weil das die Session-Zeitzone ist, und speichert intern den UTC-Zeitpunkt (12:30 UTC). Bei der Ausgabe wird wieder in die Session-Zeitzone konvertiert.
Wenn jetzt jemand mit Tokio-Zeitzone (Asia/Tokyo) abfragt:
myapp=> SET TIME ZONE 'Asia/Tokyo';
myapp=> SELECT * FROM events_a;
occurred_at
---------------------
2026-05-07 14:30:00 -- immer noch '14:30:00', egal wo!
myapp=> SELECT * FROM events_b;
occurred_at
------------------------
2026-05-07 21:30:00+09 -- Tokio-Zeit zum gleichen Momentevents_a zeigt immer 14:30 — was ein anderer Zeitpunkt sein könnte als gemeint! events_b zeigt denselben Zeitpunkt in Tokio-Lokalzeit (21:30, weil Japan 9 Stunden vor UTC ist).
Faustregel: Wenn die Spalte einen Zeitpunkt im Lebensweg deiner App speichert (Login, Bestellung, Kommentar), nimm immer timestamptz. Wenn die Spalte einen wandlungsfähigen Wandkalender-Eintrag speichert (z. B. „Geschäftszeiten 09:00-17:00 in Lokalzeit”), nimm timestamp oder time.
In >95 % der Web-App-Spalten ist timestamptz richtig.
Aktuelle Zeit holen
myapp=> SELECT now(), current_timestamp, current_date, current_time;
now | current_timestamp | current_date | current_time
------------------------------+--------------------------------+--------------+--------------
2026-05-07 14:30:00.123+02 | 2026-05-07 14:30:00.123+02 | 2026-05-07 | 14:30:00.123+02| Funktion | Liefert | Wann nutzen? |
|---|---|---|
now() | timestamptz mit Mikrosekunden | Standard-Wahl für Default-Spalten |
current_timestamp | wie now() | SQL-Standard-Variante |
clock_timestamp() | timestamptz zum Zeitpunkt des Aufrufs (nicht Transaktionsstart) | Für Performance-Messung innerhalb einer Transaktion |
current_date | date ohne Uhrzeit | Tagesgenaue Filter |
current_time | time with time zone | Selten gebraucht |
localtimestamp | timestamp (ohne TZ) der Session-Zeitzone | Wenn man bewusst die Lokalzeit will |
now() und current_timestamp liefern in einer Transaktion immer denselben Wert (den Transaktionsstart). clock_timestamp() liefert den echten aktuellen Zeitpunkt — manchmal wichtig für Performance-Messung.
Defaults in Tabellen
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
total numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
paid_at timestamptz, -- NULL bis bezahlt
shipped_at timestamptz, -- NULL bis versandt
cancelled_at timestamptz -- NULL bis storniert
);Pattern: _at-Suffix für Zeitstempel, NOT NULL DEFAULT now() für Pflicht-Felder, optionale Lebenszyklus-Marken (paid_at, cancelled_at) als Nullable.
updated_at automatisch zu pflegen geht über einen Trigger (eigenes Thema):
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();interval — Zeitspannen
myapp=> SELECT interval '2 hours 30 minutes';
interval
------------------
02:30:00
myapp=> SELECT now() - interval '7 days';
?column?
----------------------------
2026-04-30 14:30:00+02
myapp=> SELECT '2026-05-01'::date + interval '1 month' - interval '1 day';
?column?
----------------------
2026-05-31 00:00:00
-- Letzter Tag im Mai 2026interval versteht viele Schreibweisen: '1 day', '7 days 12 hours', '1 mon 15 days'. Praktisch in allen Zeitberechnungen.
In WHERE-Klauseln:
SELECT * FROM orders
WHERE created_at >= now() - interval '30 days';intervals können auch in Tabellen gespeichert werden — etwa für „Subscription läuft 1 Jahr”, „Lieferzeit 3 Werktage” o. ä.
AT TIME ZONE — explizite Konvertierung
Mit AT TIME ZONE kann man Zeitwerte zwischen Zonen umrechnen:
myapp=> SELECT now() AT TIME ZONE 'America/New_York';
timezone
----------------------------
2026-05-07 08:30:00.123
-- Ergebnis ist timestamp (ohne TZ): „die Lokalzeit in NY"
myapp=> SELECT '2026-05-07 14:30:00'::timestamp
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'Asia/Tokyo';
timezone
------------------------------
2026-05-07 21:30:00
-- „Wenn 14:30 Berlin-Zeit, was ist's in Tokio?"Die Operator-Mechanik ist subtil:
timestamp AT TIME ZONE 'Z'→ wird als Lokalzeit in Zone Z interpretiert, ergibttimestamptztimestamptz AT TIME ZONE 'Z'→ wird in Zone Z dargestellt, ergibttimestamp
In der Praxis: lieber durchgängig timestamptz arbeiten und AT TIME ZONE nur für Reports verwenden, in denen der User die Lokalzeit sehen will.
Datum-Manipulation
-- Tagesanfang / Monatsanfang
SELECT date_trunc('day', now()); -- 2026-05-07 00:00:00+02
SELECT date_trunc('month', now()); -- 2026-05-01 00:00:00+02
SELECT date_trunc('year', now()); -- 2026-01-01 00:00:00+01
-- Einzelne Komponenten extrahieren
SELECT extract(year FROM now()); -- 2026
SELECT extract(month FROM now()); -- 5
SELECT extract(dow FROM now()); -- 4 (Donnerstag, 0=Sonntag)
SELECT extract(epoch FROM now()); -- Unix-Timestamp
-- Differenz in Tagen / Sekunden
SELECT (now() - '2026-01-01')::interval;
SELECT extract(epoch FROM (now() - '2026-01-01')) AS seconds;
-- Formatieren
SELECT to_char(now(), 'YYYY-MM-DD HH24:MI:SS'); -- 2026-05-07 14:30:00
SELECT to_char(now(), 'Day, DD Month YYYY'); -- Thursday , 07 May 2026date_trunc und extract sind die zwei Arbeitspferde für Reporting-Queries. Mehr im Kapitel Aggregation.
Sommer-/Winterzeit-Falle
Bei timestamptz rechnet Postgres die Zeitzone korrekt um — auch über Zeitumstellungen hinweg. Bei timestamp (ohne TZ) nicht:
-- Berlin: Umstellung von Sommer- auf Winterzeit am 26.10.2025 03:00 → 02:00.
-- Eine Bestellung um '2025-10-26 02:30' Berlin-Zeit ist mehrdeutig.
myapp=> SET TIME ZONE 'Europe/Berlin';
myapp=> SELECT '2025-10-26 02:30'::timestamptz;
timestamptz
------------------------------
2025-10-26 02:30:00+02
-- Postgres nimmt im Zweifel die Sommerzeit (vor der Umstellung)
-- Genaue Disambiguierung mit explizitem Offset:
myapp=> SELECT '2025-10-26 02:30+02'::timestamptz; -- vor Umstellung
myapp=> SELECT '2025-10-26 02:30+01'::timestamptz; -- nach UmstellungIn timestamp-Spalten würden beide Zeitpunkte als '2025-10-26 02:30:00' gespeichert — Information weg. Genau das ist einer der Hauptgründe für timestamptz.
Häufige Stolperfallen
timestamp ohne TZ verliert Information.
Wer einen Zeitpunkt in einer timestamp-Spalte speichert, verliert die Zeitzonen-Information dauerhaft. Bei späteren Anwendungen (z. B. Reporting in einer anderen Zeitzone, oder Datenbank wandert in eine andere Region) ist nicht mehr eindeutig, was der Wert bedeutet. Default-Wahl: immer timestamptz.
timestamptz speichert KEINE Zeitzone.
Trotz des Namens speichert timestamptz einen UTC-Zeitpunkt — die Original-Zeitzone der Eingabe ist nach dem Insert weg. Wer wirklich „dieser Termin findet in Berlin um 14:30 statt, egal welche TZ der User hat” speichern will, braucht zwei Spalten: timestamp (lokale Wallclock-Zeit) plus text (Zeitzonen-Name). Das ist selten nötig — meistens sind UTC-Zeitpunkte richtig.
Session-Zeitzone beeinflusst die Interpretation.
INSERT … VALUES ('2026-05-07 14:30') in eine timestamptz-Spalte interpretiert den Wert als Session-Zeitzone. Wenn deine App-Server in UTC laufen, deine psql-Session aber in Berlin, bekommst du unterschiedliche UTC-Werte. Sicherer: ISO-Format mit Offset ('2026-05-07 14:30+02') oder explizit AT TIME ZONE 'UTC'.
extract(month FROM …) liefert numeric, nicht integer.
Lange Zeit lieferte extract doppelte Genauigkeit (Float), in PG 14+ ist’s numeric. Das bricht oft Code, der einen int-Vergleich erwartete: WHERE extract(year FROM dt) = 2026 funktioniert, aber Cast-Operationen können überraschen. Sicher: explizit casten — extract(year FROM dt)::int.
age() vs. einfaches Subtraktieren.
age('2026-05-07', '2024-05-07') liefert interval '2 years'. '2026-05-07'::date - '2024-05-07'::date liefert 731 (Tage als Integer). age() ist „menschen-freundlicher” (Jahre, Monate, Tage), die Subtraktion ist exakt. Beim Reporting kommt’s drauf an, was du brauchst.
JavaScript-Date-Behandlung in node-postgres.
Standard-pg-Driver wandelt timestamptz automatisch in JavaScript-Date-Objekte. Der Wert ist UTC-korrekt — aber wenn du den Wert in JavaScript in einer anderen Zeitzone formatierst, siehst du natürlich die lokale Zeit dort. Bei Bugs immer prüfen: ist’s ein DB-Problem oder ein Client-Format-Problem?
Indexe auf Datums-Spalten lohnen sich fast immer.
WHERE created_at >= now() - interval '30 days' ist sehr häufig — und ohne Index endet’s in einem Sequential Scan. Standard-B-Tree-Index auf created_at reicht; bei riesigen Tabellen lohnt sich BRIN (für append-only-Daten) oder Partitionierung nach Monat.
Weiterführende Ressourcen
Externe Quellen
- Date/Time Types – PostgreSQL Documentation
- Date/Time Functions and Operators
- Don’t Do This: timestamp without time zone – PostgreSQL Wiki
- Postgres timestamp vs. timestamptz – David Wolever (Crunchy Data Blog)