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

TypWas speichert erBeispielGröße
dateNur Datum'2026-05-07'4 Bytes
timeNur Uhrzeit'14:30:00'8 Bytes
timestampDatum + Uhrzeit, ohne Zeitzone'2026-05-07 14:30:00'8 Bytes
timestamptzDatum + Uhrzeit, mit Zeitzonen-Bezug'2026-05-07 14:30:00+00'8 Bytes
intervalZeitspanne'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:

SQL
CREATE TABLE events_a (occurred_at timestamp);    -- ohne TZ
CREATE TABLE events_b (occurred_at timestamptz);  -- mit TZ

Beide Spalten brauchen 8 Bytes. Was passiert, wenn wir denselben Wert einfügen?

SQL Insert ohne explizite Zeitzone
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 interpretiert

events_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:

SQL
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 Moment

events_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

SQL Verschiedene 'Jetzt'-Funktionen
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
FunktionLiefertWann nutzen?
now()timestamptz mit MikrosekundenStandard-Wahl für Default-Spalten
current_timestampwie now()SQL-Standard-Variante
clock_timestamp()timestamptz zum Zeitpunkt des Aufrufs (nicht Transaktionsstart)Für Performance-Messung innerhalb einer Transaktion
current_datedate ohne UhrzeitTagesgenaue Filter
current_timetime with time zoneSelten gebraucht
localtimestamptimestamp (ohne TZ) der Session-ZeitzoneWenn 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

SQL Übliche timestamptz-Pattern
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):

SQL
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

SQL Interval-Beispiele
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 2026

interval versteht viele Schreibweisen: '1 day', '7 days 12 hours', '1 mon 15 days'. Praktisch in allen Zeitberechnungen.

In WHERE-Klauseln:

SQL Aktivität der letzten 30 Tage
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:

SQL Konvertierungen
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, ergibt timestamptz
  • timestamptz AT TIME ZONE 'Z' → wird in Zone Z dargestellt, ergibt timestamp

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

SQL Häufige Operationen
-- 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      2026

date_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:

SQL Was bei der Zeitumstellung passiert
-- 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 Umstellung

In 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

/ Weiter

Zurück zu Datentypen

Zur Übersicht