Zeitzonen in Software: Sommerzeit, UTC-Offsets und häufige Bugs

9 min4. Juni 2026

Warum Zeitzonen so schwierig sind

Zeitzonen sind kein rein technisches Problem — sie sind ein politisches. Regierungen ändern Zeitzonen-Regeln regelmäßig: Marokko schaffte 2018 die Sommerzeitumstellung ab, führte sie dann wieder ein und schaffte sie erneut ab. Russland wechselte 2011 zu permanenter Sommerzeit, kehrte 2014 zur Winterzeit zurück. Samoa übersprang 2011 einen ganzen Tag.

Die IANA Time Zone Database (auch Olson-Datenbank genannt) wird mehrmals jährlich aktualisiert, um diese politischen Entscheidungen abzubilden. Sie enthält die komplette historische und aktuelle Zuordnung von Regionen zu UTC-Offsets. Wenn deine Software Zeitzonen korrekt handhaben soll, muss sie diese Datenbank verwenden — und sie muss aktuell sein.

Ein weit verbreiteter Irrtum: „Zeitzonen sind feste Offsets von UTC." Das stimmt nicht. Eine Zeitzone ist eine Sammlung von Regeln, die beschreiben, welcher Offset zu welchem Zeitpunkt gilt. Europe/Berlin ist +01:00 im Winter und +02:00 im Sommer. Der Offset ändert sich, die Zeitzone bleibt gleich.

UTC, Offsets und IANA-Bezeichner: Die Grundlagen

UTC (Coordinated Universal Time) ist die Referenzzeit, von der alle Zeitzonen abgeleitet werden. Es ist keine Zeitzone im engeren Sinne, sondern ein Standard. UTC hat keine Sommerzeit und ändert sich nie. Speichere Zeitstempel immer als UTC in der Datenbank.

Ein UTC-Offset beschreibt die Differenz zu UTC zu einem bestimmten Zeitpunkt: +02:00 bedeutet „zwei Stunden vor UTC". Offsets können halbstündig (Indien: +05:30) oder sogar viertelstündig (Nepal: +05:45) sein. Es gibt weltweit über 30 verschiedene aktive Offsets.

IANA-Bezeichner wie Europe/Berlin, America/New_York oder Asia/Tokyo benennen eine Region mit ihrer vollständigen Zeitzonen-Geschichte. Verwende immer IANA-Bezeichner statt Abkürzungen wie „CET" oder „EST" — diese Abkürzungen sind nicht eindeutig (CST kann Central Standard Time oder China Standard Time sein).

Die Beziehung: Ein IANA-Bezeichner enthält Regeln, die zu jedem Zeitpunkt den korrekten UTC-Offset bestimmen. Europe/Berlin ergibt im Januar +01:00 (CET) und im Juli +02:00 (CEST). Der Bezeichner ist stabil, der Offset variiert.

// Zeitstempel korrekt erstellen und konvertieren
const now = new Date(); // Intern immer UTC

// IANA-Bezeichner für die Anzeige verwenden
const berlinTime = now.toLocaleString('de-DE', {
  timeZone: 'Europe/Berlin',
  dateStyle: 'full',
  timeStyle: 'long',
});
// "Mittwoch, 4. Juni 2026 um 14:30:00 MESZ"

// UTC-Timestamp für die Datenbank
const utcISO = now.toISOString();
// "2026-06-04T12:30:00.000Z"

// Offset programmatisch ermitteln
const formatter = new Intl.DateTimeFormat('de-DE', {
  timeZone: 'Europe/Berlin',
  timeZoneName: 'longOffset',
});
// Enthält "GMT+02:00" im Sommer

Sommerzeit (DST): Die häufigsten Fallen

Bei der Umstellung auf Sommerzeit „verschwindet" eine Stunde. Am letzten Sonntag im März springt die Uhr in Europa von 02:00 direkt auf 03:00. Zeiten zwischen 02:00 und 03:00 existieren an diesem Tag nicht. Wenn dein System einen Termin um 02:30 speichert, ist er ungültig.

Bei der Umstellung auf Winterzeit „wiederholt" sich eine Stunde. Die Uhr springt von 03:00 zurück auf 02:00. Jede Zeit zwischen 02:00 und 03:00 existiert zweimal an diesem Tag. Ohne expliziten Offset ist eine solche Zeitangabe mehrdeutig — du weißt nicht, ob die erste oder die zweite 02:30 gemeint ist.

Berechnungen über DST-Grenzen hinweg sind tückisch. „24 Stunden nach Mitternacht" ist an den meisten Tagen Mitternacht des nächsten Tages — aber am Tag der Umstellung auf Sommerzeit ist es 01:00 Uhr. Verwende kalendarische Arithmetik (addiere 1 Tag), nicht zeitliche (addiere 86400 Sekunden).

Nicht alle Länder verwenden Sommerzeit. Die meisten tropischen Länder, China, Japan, Indien und Russland haben keine Umstellung. Und die Länder, die DST verwenden, stellen nicht alle am selben Tag um — die USA und Europa haben unterschiedliche Umstellungstermine. In den Wochen dazwischen ändern sich die relativen Offsets.

// DST-Problem: Die "fehlende" Stunde
// Am 30. März 2026 springt Europe/Berlin von 02:00 auf 03:00

// Diese Zeit existiert NICHT:
const invalid = new Date('2026-03-30T02:30:00+01:00');
// JavaScript "repariert" sie stillschweigend → 03:30 MESZ

// DST-Problem: Die "doppelte" Stunde
// Am 26. Oktober 2026 springt 03:00 zurück auf 02:00
// 02:30 existiert zweimal — einmal in MESZ, einmal in MEZ

// Lösung: Temporal API (ECMAScript 2024+)
// Temporal erzwingt explizite Disambiguierung
const zt = Temporal.ZonedDateTime.from({
  year: 2026,
  month: 10,
  day: 26,
  hour: 2,
  minute: 30,
  timeZone: 'Europe/Berlin',
}, { disambiguation: 'earlier' }); // Explizit die erste 02:30 wählen

Speicherung und Datenbank-Design

Goldene Regel: Speichere Zeitstempel als UTC. In PostgreSQL verwende timestamptz (timestamp with time zone), das intern als UTC speichert und bei der Ausgabe in die Sitzungs-Zeitzone konvertiert. Verwende NICHT timestamp without time zone — es sieht gleich aus, verliert aber die Zeitzonen-Information.

MySQL/MariaDB: Der TIMESTAMP-Typ konvertiert automatisch zwischen UTC und der Server-Zeitzone. DATETIME hingegen speichert den Wert unverändert ohne Zeitzonen-Kontext. Verwende TIMESTAMP für Ereigniszeitpunkte und DATETIME nur für Kalendereinträge, bei denen die lokale Zeit die „Wahrheit" ist.

Für Kalendereinträge (Meetings, Erinnerungen) brauchst du neben dem UTC-Zeitstempel auch den IANA-Bezeichner der Zeitzone. Grund: Wenn ein Meeting „jeden Dienstag um 10:00 in Europe/Berlin" stattfindet und Deutschland die DST-Regeln ändert, muss das Meeting weiterhin um 10:00 Lokalzeit sein, auch wenn sich der UTC-Offset ändert.

Speichere den Offset zur Erstellungszeit mit, wenn du nachvollziehen musst, welche Lokalzeit der Nutzer sah als er den Eintrag erstellte. ISO 8601 bietet dafür das Format: 2026-06-04T14:30:00+02:00[Europe/Berlin]. Die Temporal API unterstützt diese Notation nativ.

-- PostgreSQL: Korrektes Schema für Ereignisse
CREATE TABLE events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  -- UTC-Zeitstempel (intern als UTC gespeichert)
  starts_at TIMESTAMPTZ NOT NULL,
  ends_at TIMESTAMPTZ NOT NULL,
  -- IANA-Zeitzone für wiederkehrende Ereignisse
  timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
  -- Offset zum Zeitpunkt der Erstellung (für Audit)
  created_offset INTERVAL NOT NULL DEFAULT INTERVAL '0',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Abfrage: Alle Events heute in Berlin
SELECT title, starts_at AT TIME ZONE 'Europe/Berlin' AS local_start
FROM events
WHERE starts_at >= DATE_TRUNC('day', NOW() AT TIME ZONE 'Europe/Berlin')
      AT TIME ZONE 'Europe/Berlin'
  AND starts_at < DATE_TRUNC('day', NOW() AT TIME ZONE 'Europe/Berlin')
      AT TIME ZONE 'Europe/Berlin' + INTERVAL '1 day';

JavaScript und die Temporal API

Das Date-Objekt in JavaScript hat fundamentale Probleme: Es ist immer UTC intern, die Zeitzone des Systems ist implizit, Parsing ist inkonsistent und es gibt keine Möglichkeit, eine bestimmte Zeitzone zu erzwingen. Libraries wie Luxon, date-fns-tz und Day.js haben jahrelang die Lücken gefüllt.

Die Temporal API (ECMAScript 2024+, mit Polyfill verfügbar) löst diese Probleme grundlegend. Sie führt neue Typen ein: Temporal.Instant (ein Punkt auf der Zeitlinie), Temporal.ZonedDateTime (Instant + Zeitzone), Temporal.PlainDateTime (Datum und Zeit ohne Zeitzone) und weitere.

Der entscheidende Vorteil von Temporal: Du musst explizit sein. Ein PlainDateTime hat keine Zeitzone — du kannst ihn nicht versehentlich als UTC interpretieren. Ein ZonedDateTime kennt seine Zeitzone — DST-Übergänge werden automatisch korrekt behandelt. Die API erzwingt Klarheit über die Semantik deiner Daten.

Für Bestandsprojekte, die noch nicht auf Temporal migrieren können: Luxon ist die robusteste Alternative mit voller IANA-Unterstützung und expliziter Zeitzonen-Handhabung. date-fns-tz ist leichtgewichtiger, erfordert aber mehr manuelle Sorgfalt bei DST-Übergängen.

// Temporal API: Kalendarische Arithmetik über DST-Grenzen
import { Temporal } from '@js-temporal/polyfill';

// Ein Termin in Berlin, der in die DST-Umstellung fällt
const meeting = Temporal.ZonedDateTime.from(
  '2026-03-29T10:00:00+01:00[Europe/Berlin]'
);

// "Einen Tag später" → kalendarisch korrekt, egal ob DST
const nextDay = meeting.add({ days: 1 });
// → 2026-03-30T10:00:00+02:00[Europe/Berlin]
// Offset hat sich geändert, aber Lokalzeit bleibt 10:00

// Vergleich: Stunden addieren (FALSCH für Kalender-Logik)
const wrong = meeting.add({ hours: 24 });
// → 2026-03-30T11:00:00+02:00[Europe/Berlin]
// 24 Stunden ≠ "nächster Tag" an DST-Tagen

// Zeitdifferenz zwischen zwei Zeitzonen
const berlin = Temporal.Now.zonedDateTimeISO('Europe/Berlin');
const tokyo = berlin.withTimeZone('Asia/Tokyo');
console.log(tokyo.toLocaleString('de-DE'));

Backend und API-Design

APIs sollten Zeitstempel immer als ISO 8601 mit Offset oder als UTC (Z-Suffix) kommunizieren: 2026-06-04T12:30:00Z oder 2026-06-04T14:30:00+02:00. Verwende niemals lokale Zeiten ohne Offset in API-Responses — der Client kann sie nicht korrekt interpretieren.

Für APIs, die zeitzonenbezogene Operationen anbieten (z.B. „zeige mir alle Events am Dienstag in Berlin"), akzeptiere den IANA-Bezeichner als Parameter. Validiere ihn gegen die aktuelle IANA-Datenbank. Neue Zeitzonen werden selten hinzugefügt, aber Regeln ändern sich — halte deine tzdata-Pakete aktuell.

Cron-Jobs und Scheduler brauchen besondere Aufmerksamkeit. Ein Job, der „täglich um 02:30 Europe/Berlin" laufen soll, fällt am Tag der Sommerzeitumstellung aus (die Stunde existiert nicht) oder läuft doppelt (bei der Winterzeitumstellung). Moderne Scheduler wie systemd-Timer oder Kubernetes CronJobs unterstützen IANA-Zeitzonen direkt.

Logging: Logge Timestamps immer als UTC. Mische niemals lokale Zeiten verschiedener Server in einem zentralen Log. Die Konvertierung in die lokale Zeit des Betrachters ist Aufgabe des Log-Viewers, nicht des Loggers. Verwende RFC 3339-Format für maschinelle Lesbarkeit.

// API-Response: Immer UTC oder expliziten Offset verwenden
interface EventResponse {
  id: string;
  title: string;
  // ISO 8601 mit Offset — eindeutig und parsebar
  startsAt: string;  // "2026-06-04T14:30:00+02:00"
  endsAt: string;    // "2026-06-04T15:30:00+02:00"
  // IANA-Zeitzone für Kalender-Kontextanzeige
  timezone: string;  // "Europe/Berlin"
}

// Client: Korrekt in lokale Zeit konvertieren
function formatEventTime(isoString: string, userTz: string): string {
  const instant = Temporal.Instant.from(isoString);
  const local = instant.toZonedDateTimeISO(userTz);
  return local.toLocaleString('de-DE', {
    dateStyle: 'medium',
    timeStyle: 'short',
  });
}

Testen von Zeitzonen-Code

Zeitzonen-Bugs treten oft nur an bestimmten Tagen im Jahr auf (DST-Übergänge) oder nur für Nutzer in bestimmten Regionen. Automatisierte Tests müssen diese Szenarien explizit abdecken. Teste mit festen Zeitpunkten, die bekannte DST-Übergänge einschließen.

Mocke die Systemzeit, nicht die Zeitzone. In JavaScript verwende fake-timers (Sinon, Vitest usw.) um Date.now() zu kontrollieren. Für Zeitzonen-spezifische Tests verwende explizite IANA-Bezeichner im Code statt die Systemzeitzone. Dein Code sollte nie von process.env.TZ abhängen.

Edge Cases für Tests: Der 29. Februar (Schaltjahre), die „fehlende" Stunde bei Sommerzeitbeginn, die „doppelte" Stunde bei Sommerzeitende, der Jahreswechsel über Zeitzonen hinweg (in Tokio ist schon morgen während es in Berlin noch heute ist), und Zeitzonen mit ungewöhnlichen Offsets (Chatham Islands: +12:45).

Unser Timezone Converter hilft dir, schnell zu prüfen, welche Uhrzeit ein bestimmter UTC-Zeitstempel in verschiedenen Zeitzonen anzeigt. Der Timestamp Converter wandelt Unix-Timestamps in lesbare Datumsangaben um — nützlich zum Debuggen von Log-Einträgen und Datenbank-Werten.

// Vitest: Zeitzonen-Edge-Cases testen
import { describe, it, expect, vi } from 'vitest';
import { getLocalEventTime } from './event-utils';

describe('Zeitzonen-Handling', () => {
  it('behandelt die fehlende Stunde bei DST-Start korrekt', () => {
    // 30. März 2026, 01:59 UTC → 02:59 MEZ → springt auf 03:00 MESZ
    const dstStart = new Date('2026-03-30T01:00:00Z');
    const result = getLocalEventTime(dstStart, 'Europe/Berlin');
    // Sollte 03:00 zeigen, nicht 02:00
    expect(result).toContain('03:00');
  });

  it('behandelt die doppelte Stunde bei DST-Ende korrekt', () => {
    // 26. Oktober 2026, 01:30 UTC → nach DST-Ende ist das 02:30 MEZ
    const dstEnd = new Date('2026-10-26T01:30:00Z');
    const result = getLocalEventTime(dstEnd, 'Europe/Berlin');
    expect(result).toContain('02:30');
  });

  it('berechnet korrekte Tagesdifferenz über DST-Grenze', () => {
    // Freitag vor DST-Umstellung → Montag danach = 3 Tage
    const friday = Temporal.PlainDate.from('2026-03-28');
    const monday = Temporal.PlainDate.from('2026-03-31');
    expect(friday.until(monday).days).toBe(3);
  });
});

Häufige Bugs und wie man sie vermeidet

Bug #1: „new Date() auf dem Server verwendet die Server-Zeitzone." Wenn dein Server in UTC konfiguriert ist, fällt das nicht auf. Aber auf einem Entwickler-Laptop in Europe/Berlin produziert new Date("2026-06-04") Mitternacht in der lokalen Zeitzone, nicht UTC. Lösung: Verwende immer explizite Zeitzonen oder arbeite ausschließlich mit UTC.

Bug #2: „Datums-Vergleiche ignorieren Zeitzonen." Wenn ein Event starts_at = 2026-06-04T23:00:00Z hat und du prüfst ob es „am 4. Juni in Berlin" ist, ist die Antwort ja (01:00 am 5. Juni in Berlin). Konvertiere in die Nutzer-Zeitzone VOR dem Datums-Vergleich.

Bug #3: „Relative Zeitangaben ohne Zeitzonen-Kontext." Eine Anzeige wie „vor 2 Stunden" ist nur korrekt, wenn sowohl der Referenzzeitpunkt als auch die aktuelle Zeit im gleichen Zeitzonen-Kontext verglichen werden. Berechne relative Zeiten immer auf Basis von UTC-Instants, nie auf Basis von lokalen Zeiten.

Bug #4: „Die IANA-Datenbank ist veraltet." Wenn ein Land seine Zeitzonen-Regeln ändert und dein System die alte tzdata verwendet, werden Zeitstempel falsch konvertiert. In Docker-Containern wird das tzdata-Paket oft nicht automatisch aktualisiert. Pinne eine aktuelle Version und aktualisiere regelmäßig. Unser Date Difference Calculator hilft dir, schnell zu prüfen, ob Datumsberechnungen die erwarteten Ergebnisse liefern.