SQL Datumsformat — Tipps & Tricks aus der Praxis

von Frank Glück — IT-Trainer & Datenbank-Architekt

SQL Datumsformat — vier Datenbanken, eine Regel Speichere niemals ein Datum als String — und wenn, dann nur als ISO 8601. ISO 8601 — die einzige portable Notation 2026-04-21T14:30:00+02:00 SQL Server DATE DATETIME2(7) DATETIMEOFFSET PostgreSQL DATE TIMESTAMP TIMESTAMPTZ Oracle DATE (mit Uhrzeit!) TIMESTAMP(9) TIMESTAMP WITH TZ Db2 DATE TIMESTAMP(12) TIMESTAMP WITH TZ

Datum und Uhrzeit sind in fast jedem Projekt die unscheinbarste Bug-Quelle. Sie
sehen harmlos aus, bis der erste Report zeigt, dass im Monatswechsel 24 Stunden
fehlen, die Sommerzeit die Umsätze verdoppelt oder ein Index mit Millionen
Zeilen plötzlich ignoriert wird. Dieser Artikel bündelt die wichtigsten Regeln
für SQL Server, PostgreSQL, Oracle und IBM Db2 — mit konkreten Beispielen,
typischen Fallen und Performance-Hinweisen.


1. Die goldene Regel zuerst

Speichere ein Datum niemals als VARCHAR. Und wenn du musst, dann nur als ISO 8601.

ISO 8601 (YYYY-MM-DD bzw. YYYY-MM-DDThh:mm:ss±hh:mm) ist die einzige
Notation, die sortierbar, eindeutig, unabhängig von Sprache und Locale ist.
Alles andere — 21.04.2026, 04/21/26, 21-APR-26 — ist ein Bug, der auf
seinen Auftritt wartet.

Format Problem
21.04.2026 DE, USA liest das als April-21 oder Feb-04?
04/21/2026 US, in DE oft als Schreibfehler zurückgewiesen
21-APR-26 Monatsname sprachabhängig, Jahrhundert unklar
2026-04-21 ISO 8601 — eindeutig, sortierbar, portabel

2. Native Datumstypen — die einzige akzeptable Lösung

Jedes RDBMS hat passende Typen. Sie kosten weniger Speicher als ein String,
sind schneller zu vergleichen, greifbar für Indexe und für Funktionen.

2.1 Typen-Überblick

Anwendungsfall SQL Server PostgreSQL Oracle Db2
nur Datum DATE DATE DATE DATE
Datum + Uhrzeit (ohne TZ) DATETIME2(n) TIMESTAMP(n) TIMESTAMP(n) TIMESTAMP(n)
Datum + Uhrzeit mit TZ DATETIMEOFFSET(n) TIMESTAMPTZ TIMESTAMP(n) WITH TIME ZONE TIMESTAMP(n) WITH TIME ZONE
nur Uhrzeit TIME(n) TIME / TIMETZ TIME
Intervall INTERVAL INTERVAL

Oracle-Falle: DATE enthält in Oracle immer auch eine Uhrzeit bis
zur Sekunde. Wer „nur das Datum" braucht, arbeitet mit TRUNC(col) oder
nutzt explizit TIMESTAMP und normalisiert im Code. Das überrascht regelmäßig
Entwickler, die von PostgreSQL oder SQL Server kommen.

2.2 Was man in neuen Projekten NICHT mehr nimmt

Typ Warum vermeiden
SQL Server DATETIME 3.33 ms Rundung, nur bis 9999, kein TZ-Support → durch DATETIME2 ersetzen
SQL Server SMALLDATETIME nur Minuten-Genauigkeit, Bereich 1900-2079
MySQL DATETIME ohne TZ wird als String-UTF-8 sortiert, kein echter TZ-Support
Unix-Timestamp in BIGINT kein TZ-Kontext, kein Lesbarkeit im DBeaver/TOAD
VARCHAR mit deutschem Datum nicht sortierbar, nicht indexierbar, Locale-Bomb

3. Die drei klassischen Fallen

3.1 Die BETWEEN-Falle am Tagesende

Der Klassiker — und er kostet in fast jedem Audit Stunden:

-- FALSCH: verliert alle Buchungen zwischen 00:00:00.001 und 23:59:59.999 am 30.04.
SELECT * FROM orders
 WHERE created_at BETWEEN '2026-04-01' AND '2026-04-30';

-- FALSCH: 23:59:59 verliert Einträge der letzten Sekunde
SELECT * FROM orders
 WHERE created_at BETWEEN '2026-04-01 00:00:00' AND '2026-04-30 23:59:59';

-- RICHTIG: halboffenes Intervall [ start, next )
SELECT * FROM orders
 WHERE created_at >= '2026-04-01'
   AND created_at <  '2026-05-01';

Merksatz: Für Zeiträume IMMER >= start AND < next — dann ist es egal,
ob die Spalte DATE, TIMESTAMP oder TIMESTAMPTZ ist.

3.2 Die Locale-Falle

-- Oracle — läuft lokal, bricht auf dem Kunden-Server:
INSERT INTO log (ts) VALUES ('21.04.2026');
--> funktioniert nur, wenn NLS_DATE_FORMAT zufällig 'DD.MM.YYYY' ist

-- Korrekt: explizite Konvertierung oder Literal
INSERT INTO log (ts) VALUES (DATE '2026-04-21');
INSERT INTO log (ts) VALUES (TO_DATE('2026-04-21', 'YYYY-MM-DD'));

Analog bei SQL Server: SET DATEFORMAT dmy rettet keine fremden Sessions.
Die einzige zuverlässige Konvertierung läuft über Datumsliterale
(DATE '...', TIMESTAMP '...') oder parametrisierte Queries mit
nativen Treibertypen.

3.3 Die Zeitzonen-Falle

Zeitzonen-Falle — DST-Sprung in Europe/Berlin Speichere Zeitstempel als UTC (TIMESTAMPTZ), konvertiere erst an der Oberfläche. Client / App-Server TZ: Europe/Berlin Eingabe: 2026-03-29 02:30 existiert nicht! (DST-Sprung 02→03) UTC-Konv. Datenbank Spalte: TIMESTAMPTZ intern immer UTC 2026-03-29 01:30Z ein Punkt auf Zeitstrahl — keine Zweideutigkeit Render Report / UI Berlin-Viewer: 29.03. 03:30 MESZ NY-Viewer: 28.03. 21:30 EDT Regel: Speichere UTC. Konvertiere erst beim Rendern. Niemals lokale Zeit in TIMESTAMP WITHOUT TIME ZONE.

Zwei Szenarien brechen regelmäßig:

  1. DST-Sprung: In Europa existiert die lokale Zeit 2026-03-29 02:30

schlicht nicht — um 02:00 springt die Uhr auf 03:00. Wer das als
TIMESTAMP WITHOUT TIME ZONE abspeichert, bekommt je nach DB einen Fehler
oder einen stillen Offset.

  1. Reporting über Länder: NY- und Berlin-Nutzer schauen auf dieselbe Zeile

und sehen unterschiedliche Datumsteile. Wer lokal speichert, muss für
jeden Report neu konvertieren — falls die Quell-TZ überhaupt noch bekannt ist.

Regel: Anwendungs-Timestamps immer in UTC speichern
(TIMESTAMPTZ / DATETIMEOFFSET / TIMESTAMP WITH TIME ZONE) und **erst an
der Oberfläche** in die Anzeige-TZ konvertieren. Fachliche Datumsangaben
(„Geburtstag", „Rechnungsdatum") bleiben DATE ohne Zeit — die sind per
Definition unabhängig von der TZ.


4. Performance — sargable oder nicht?

Sargable vs. Non-Sargable — wann der Index greift Funktionen auf der indizierten Spalte zerstören jede Index-Nutzung. Non-Sargable — Full Table Scan WHERE YEAR(created_at) = 2026 WHERE created_at::date = '2026-04-21' WHERE TO_CHAR(dt,'YYYY') = '2026' B-Tree Index ix_created_at: — wird ignoriert — Planner: Seq Scan / Table Scan Kosten: O(n) — skaliert mit Tabellengröße Sargable — Index Range Scan WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' -- Halboffenes Intervall! B-Tree Index ix_created_at: Range Planner: Index Range Scan Kosten: O(log n + k) — nur Treffer gelesen

Der teuerste Datums-Bug in Production-Systemen ist nicht das falsche Ergebnis,
sondern der zerstörte Index. Der Begriff sargable (Search ARGument
ABLE) beschreibt, ob ein Prädikat den Index nutzen kann. Sobald eine Funktion
auf der indizierten Spalte steht, ist Schluss.

4.1 Non-Sargable — das sollte in keinem Review durchkommen

-- alle: Full Table Scan
WHERE YEAR(created_at) = 2026                     -- SQL Server
WHERE DATE(created_at) = '2026-04-21'             -- MySQL
WHERE TO_CHAR(created_at, 'YYYY-MM') = '2026-04'  -- Oracle
WHERE created_at::date = CURRENT_DATE             -- PostgreSQL
WHERE EXTRACT(MONTH FROM created_at) = 4          -- alle
WHERE CAST(created_at AS DATE) = '2026-04-21'     -- alle

4.2 Sargable — Range mit halboffenem Intervall

-- alle Dialekte, alle benutzen den Index auf created_at
WHERE created_at >= DATE '2026-04-21'
  AND created_at <  DATE '2026-04-22';           -- ein Tag

WHERE created_at >= DATE '2026-04-01'
  AND created_at <  DATE '2026-05-01';           -- ein Monat

WHERE created_at >= DATE '2026-01-01'
  AND created_at <  DATE '2027-01-01';           -- ein Jahr

4.3 Wenn die Funktion unvermeidbar ist: Ausdrucks-Index

Manchmal ist das Prädikat fachlich nun mal eine Funktion. Dann baut man den
Index auf die Funktion, nicht auf die Rohspalte:

-- PostgreSQL
CREATE INDEX ix_orders_created_date ON orders ((created_at::date));

-- Oracle (function-based index)
CREATE INDEX ix_orders_year ON orders (EXTRACT(YEAR FROM created_at));

-- SQL Server (computed column + Index)
ALTER TABLE orders ADD created_date AS CAST(created_at AS DATE) PERSISTED;
CREATE INDEX ix_orders_created_date ON orders (created_date);

-- Db2 (expression-based index ab v10.5)
CREATE INDEX ix_orders_created_date ON orders (DATE(created_at));

4.4 Partitionierung für Zeitreihen

Bei Tabellen jenseits von 50–100 Mio. Zeilen lohnt sich **Range-Partitionierung
nach Monat oder Jahr**:

  • PostgreSQL: PARTITION BY RANGE (created_at)
  • SQL Server: Partition Function + Partition Scheme
  • Oracle: PARTITION BY RANGE (created_at) INTERVAL (NUMTOYMINTERVAL(1,'MONTH'))
  • Db2: PARTITION BY RANGE (created_at)

Vorteil: Der Planner macht Partition Pruning — bei einer Abfrage auf einen
Monat werden nur dessen Segmente gelesen. Alte Partitionen lassen sich mit
einem Statement droppen (DETACH PARTITION) statt mit DELETE und
Bloat-Folgekosten.

4.5 Kleine Helfer, die viel bringen

-- PostgreSQL: BRIN-Index für sequentiell wachsende Zeitstempel
CREATE INDEX ix_log_ts_brin ON log USING BRIN (created_at);
-- Bruchteil der Größe eines B-Tree, perfekt für append-only

-- Alle DBMS: Covering Index für häufige Bereichsabfragen
CREATE INDEX ix_orders_covering
    ON orders (created_at)
 INCLUDE (customer_id, amount);  -- oder nachgestellte Key-Columns in Oracle/Db2

5. DBMS-spezifische Stolperfallen

5.1 SQL Server

  • GETDATE() liefert DATETIME (3.33 ms-Rundung) — besser

SYSDATETIME() / SYSUTCDATETIME() (nanosekundengenau).

  • Implizite Konvertierung VARCHAR → DATETIME hängt von SET LANGUAGE ab.
  • AT TIME ZONE seit SQL Server 2016: created_at AT TIME ZONE 'UTC' AT TIME ZONE 'W. Europe Standard Time'.

5.2 PostgreSQL

  • TIMESTAMP speichert keine TZ. TIMESTAMPTZ speichert intern UTC,

konvertiert nur bei Ein-/Ausgabe.

  • CURRENT_DATE und CURRENT_TIMESTAMP respektieren SET TIME ZONE der Session.
  • NOW() ist äquivalent zu CURRENT_TIMESTAMP (inkl. TZ).
  • date_trunc('month', col) ist nicht sargable gegen einen Index auf col

aber ein Ausdrucks-Index ((date_trunc('month', col))) rettet das Prädikat.

5.3 Oracle

  • DATE = Datum + Sekunden-Uhrzeit (kein reiner Datumstyp).
  • TIMESTAMP WITH LOCAL TIME ZONE speichert in DB-TZ, liefert in Session-TZ

aus. Elegant in Einzel-Mandant-Systemen — gefährlich bei Replikation.

  • SYSDATE nutzt die DB-Server-TZ, CURRENT_DATE die Session-TZ.

Das ist in Produktiv-Jobs ein häufiger Grund für „gestern-vs-heute"-Bugs.

5.4 IBM Db2

  • CURRENT TIMESTAMP (ohne Klammern!) liefert TIMESTAMP(6) oder -(12) je

nach Konfiguration.

  • TIMESTAMP_FORMAT() / VARCHAR_FORMAT() sind in Db2 die Entsprechungen

zu Oracles TO_DATE / TO_CHAR.

  • Registervariable CURRENT TIMEZONE liefert die Serverzeit-Zonen-Differenz.

6. Best-Practice-Checkliste

Schema-Design

  • [ ] Nur native Datumstypen, nie VARCHAR
  • [ ] Zeitstempel mit TZ-Kontext → TIMESTAMPTZ / DATETIMEOFFSET
  • [ ] Reine Datumsangaben (Geburtstag, Rechnungsdatum) → DATE
  • [ ] Präzision bewusst wählen (TIMESTAMP(3) für ms reicht meist)

Queries

  • [ ] Bereiche immer mit >= start AND < next (halboffen)
  • [ ] Keine Funktionen auf der indizierten Spalte
  • [ ] Datumsliterale (DATE '2026-04-21') statt String-Konvertierungen
  • [ ] Parametrisierte Queries statt String-Konkatenation

Index-Strategie

  • [ ] B-Tree für Range-Queries
  • [ ] Ausdrucks-Index, wenn Prädikat zwingend eine Funktion braucht
  • [ ] BRIN (PG) / Partition Pruning bei Zeitreihen > 50 M Zeilen
  • [ ] Covering-/Include-Spalten für häufige Report-Abfragen

Zeitzonen

  • [ ] Applikation speichert UTC
  • [ ] Konvertierung nur an der Oberfläche (Client oder View)
  • [ ] DST-sichere Rechnung immer mit AT TIME ZONE / timezone(), nie mit + INTERVAL

Migration & Deploy

  • [ ] ISO 8601 als einziges Austausch-Format für CSV/JSON/API
  • [ ] Bei Typ-Änderung erst Staging mit realen Zeitreihen testen
  • [ ] Session-Einstellungen (NLS_DATE_FORMAT, SET DATEFORMAT) nie als Funktionskontrakt nutzen

7. Praxis-Snippet: Monatsbericht portabel

-- PostgreSQL
SELECT date_trunc('month', created_at)::date AS monat
     , count(*)                              AS bestellungen
     , sum(amount)                           AS umsatz
  FROM orders
 WHERE created_at >= DATE '2026-01-01'
   AND created_at <  DATE '2027-01-01'
 GROUP BY 1
 ORDER BY 1;

-- SQL Server
SELECT DATEFROMPARTS(YEAR(created_at), MONTH(created_at), 1) AS monat
     , COUNT(*)                                              AS bestellungen
     , SUM(amount)                                           AS umsatz
  FROM orders
 WHERE created_at >= '20260101'
   AND created_at <  '20270101'
 GROUP BY DATEFROMPARTS(YEAR(created_at), MONTH(created_at), 1)
 ORDER BY 1;

-- Oracle
SELECT TRUNC(created_at, 'MM') AS monat
     , COUNT(*)                AS bestellungen
     , SUM(amount)             AS umsatz
  FROM orders
 WHERE created_at >= DATE '2026-01-01'
   AND created_at <  DATE '2027-01-01'
 GROUP BY TRUNC(created_at, 'MM')
 ORDER BY 1;

-- Db2
SELECT DATE_TRUNC('MONTH', created_at) AS monat
     , COUNT(*)                        AS bestellungen
     , SUM(amount)                     AS umsatz
  FROM orders
 WHERE created_at >= DATE '2026-01-01'
   AND created_at <  DATE '2027-01-01'
 GROUP BY DATE_TRUNC('MONTH', created_at)
 ORDER BY 1;

Alle vier Varianten nutzen denselben halboffenen Filter auf created_at
und gruppieren nach dem Monats-Anfang, nicht per Funktion im WHERE
so bleibt der Index unberührt und der Plan liest nur den Jahres-Range.


8. Zusammenfassung in einem Satz

Native Typen, ISO 8601, halboffene Intervalle, UTC speichern, keine Funktionen
auf indizierten Spalten — wer diese fünf Regeln konsequent durchhält, hat
90 % aller Datumsprobleme bereits gelöst, bevor sie entstehen.


Diesen Artikel nutze ich regelmäßig in Schulungen zu PostgreSQL-Performance und DB-übergreifender Migration. Rückmeldungen und Ergänzungen aus der Praxis sind willkommen — Kontakt aufnehmen.