SQL Datumsformat — Tipps & Tricks aus der Praxis
von Frank Glück — IT-Trainer & Datenbank-Architekt
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
Zwei Szenarien brechen regelmäßig:
- 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.
- 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?
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()liefertDATETIME(3.33 ms-Rundung) — besser
SYSDATETIME() / SYSUTCDATETIME() (nanosekundengenau).
- Implizite Konvertierung
VARCHAR → DATETIMEhängt vonSET LANGUAGEab. AT TIME ZONEseit SQL Server 2016:created_at AT TIME ZONE 'UTC' AT TIME ZONE 'W. Europe Standard Time'.
5.2 PostgreSQL
TIMESTAMPspeichert keine TZ.TIMESTAMPTZspeichert intern UTC,
konvertiert nur bei Ein-/Ausgabe.
CURRENT_DATEundCURRENT_TIMESTAMPrespektierenSET TIME ZONEder Session.NOW()ist äquivalent zuCURRENT_TIMESTAMP(inkl. TZ).date_trunc('month', col)ist nicht sargable gegen einen Index aufcol—
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 ZONEspeichert in DB-TZ, liefert in Session-TZ
aus. Elegant in Einzel-Mandant-Systemen — gefährlich bei Replikation.
SYSDATEnutzt die DB-Server-TZ,CURRENT_DATEdie 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 TIMEZONEliefert 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.