Einleitung
Zeitangaben mit Offset gehören zu den häufigsten und zugleich sensibelsten Daten in modernen Softwareanwendungen. Die Java-Klasse OffsetDateTime
aus dem Paket java.time
erlaubt es, neben Datum und Uhrzeit auch explizit den zugehörigen UTC-Offset (etwa +02:00
) mitzuführen. Dies ist insbesondere in domänenkritischen Anwendungen, wie Protokollierung, Auditing oder internationalen Zeitvergleichen, von großer Bedeutung.
Wird eine solche Zeitangabe mit Offset in einer PostgreSQL-Datenbank gespeichert, erwartet man typischerweise, dass der Offset ebenfalls persistiert wird. Doch genau hier liegt die Herausforderung: Der gängige PostgreSQL-Datentyp timestamptz
(timestamp with time zone) speichert den Offset nicht mit. Stattdessen wird der Wert intern nach UTC normalisiert, wodurch der ursprüngliche Offset verloren geht. Das führt dazu, dass bei der späteren Abfrage nicht mehr rekonstruierbar ist, welcher Offset ursprünglich verwendet wurde.
In diesem Artikel wird ein Ansatz vorgestellt, wie man OffsetDateTime
-Werte in einer Spring-Boot-Anwendung mithilfe von Spring Data so speichern und laden kann, dass der Offset vollständig erhalten bleibt. Der beschriebene Weg ist unabhängig von der Systemzeitzone oder JDBC-Konfiguration und basiert auf einem verlustfreien Speichermodell.
Problemstellung: Der Verlust des Offsets in PostgreSQL
PostgreSQL unterscheidet zwischen zwei Zeittypen: timestamp without time zone
und timestamp with time zone
. Während ersterer keinerlei Kontext zu Zeitzonen bietet, suggeriert letzterer, Zeitinformationen inklusive Zeitzonen oder Offsets zu speichern. Tatsächlich speichert PostgreSQL jedoch bei timestamptz
ausschließlich Zeitpunkte in UTC und verwirft jegliche Offset-Angaben der Eingabewerte.
Ein Beispiel veranschaulicht das Verhalten: Wird der Wert 2025-05-10T14:00:00+02:00
gespeichert, legt PostgreSQL ihn intern als 2025-05-10T12:00:00+00:00
ab. Der ursprüngliche Offset +02:00
ist damit dauerhaft verloren. Auch beim Auslesen über JDBC wird der Offset nicht rekonstruiert, es sei denn, er wird auf Anwendungsebene separat gespeichert.
Da OffsetDateTime
explizit für solche Informationen gedacht ist, ist der Einsatz von timestamptz
in Verbindung mit dieser Klasse nur bedingt geeignet. Eine zuverlässige Speicherung des vollständigen Werts erfordert eine alternative Lösung.
Lösung: Speicherung als ISO-8601-String
Die zuverlässigste Möglichkeit, OffsetDateTime
inklusive Offset zu speichern, besteht darin, ihn als formatierten String in einer PostgreSQL-Spalte vom Typ TEXT
oder VARCHAR
abzulegen. Das ISO-8601-Format (yyyy-MM-dd'T'HH:mm:ssXXX
) eignet sich ideal dafür, da es von Java nativ unterstützt wird und sowohl den Zeitpunkt als auch den Offset in einem Wert kodiert.
Ein Beispielwert sieht folgendermaßen aus:
2025-05-10T14:23:00+02:00
Code-Sprache: CSS (css)
Wird dieser String gespeichert, bleibt der Offset erhalten und lässt sich später exakt wieder in ein OffsetDateTime
-Objekt umwandeln. Für diese Konvertierung bietet sich in Spring ein benutzerdefinierter AttributeConverter
an.
Umsetzung mit Spring Boot und Spring Data
Die Implementierung in einer Spring-Anwendung ist unkompliziert. Die relevanten Schritte umfassen die Definition des Datenbankmodells, die Entitätsklasse, den Konverter sowie das Repository.
1. Datenbankstruktur
Die entsprechende Tabelle in PostgreSQL verwendet für die Zeitspalte den Typ TEXT
:
CREATE TABLE events (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
occurred_at TEXT NOT NULL
);
Code-Sprache: PHP (php)
Alternativ kann VARCHAR
mit einer Längenbegrenzung verwendet werden. Ein ISO-8601-String benötigt typischerweise 25 bis 35 Zeichen, abhängig von Genauigkeit und Offset.
2. Konverter für OffsetDateTime
Der Konverter übernimmt die Umwandlung zwischen OffsetDateTime
und String
:
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
@Converter(autoApply = true)
public class OffsetDateTimeStringConverter implements AttributeConverter<OffsetDateTime, String> {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
@Override
public String convertToDatabaseColumn(OffsetDateTime attribute) {
return attribute != null ? FORMATTER.format(attribute) : null;
}
@Override
public OffsetDateTime convertToEntityAttribute(String dbData) {
return dbData != null ? OffsetDateTime.parse(dbData, FORMATTER) : null;
}
}
Code-Sprache: PHP (php)
Dank autoApply = true
wird der Konverter automatisch auf alle Felder vom Typ OffsetDateTime
angewendet, sofern kein anderer Konverter angegeben ist.
3. Entity-Klasse
Die Entität verwendet das OffsetDateTime-Feld wie gewohnt:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
public class Event {
@Id
private UUID id;
private String name;
private OffsetDateTime occurredAt;
public Event() {}
public Event(UUID id, String name, OffsetDateTime occurredAt) {
this.id = id;
this.name = name;
this.occurredAt = occurredAt;
}
// Getter und Setter
}
Code-Sprache: JavaScript (javascript)
4. Repository und Nutzung
Das zugehörige Repository benötigt keine besonderen Anpassungen:
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface EventRepository extends JpaRepository<Event, UUID> {
}
Code-Sprache: CSS (css)
Ein typischer Anwendungscode kann wie folgt aussehen:
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
@Service
public class EventService {
private final EventRepository repository;
public EventService(EventRepository repository) {
this.repository = repository;
}
public void createEvent() {
OffsetDateTime dateTime = OffsetDateTime.now(ZoneOffset.ofHours(2));
Event event = new Event(UUID.randomUUID(), "Systemstart", dateTime);
repository.save(event);
}
public OffsetDateTime getOccurredAt(UUID id) {
return repository.findById(id)
.map(Event::getOccurredAt)
.orElseThrow();
}
}
Code-Sprache: PHP (php)
Validierung und Tests
Beim Schreiben von Tests empfiehlt es sich, nicht nur den Zeitpunkt, sondern auch den Offset zu prüfen:
import static org.junit.jupiter.api.Assertions.*;
@Test
void testOffsetDateTimePersistence() {
OffsetDateTime expected = OffsetDateTime.of(2025, 5, 10, 14, 0, 0, 0, ZoneOffset.ofHours(3));
Event event = new Event(UUID.randomUUID(), "Testevent", expected);
repository.save(event);
Event loaded = repository.findById(event.getId()).orElseThrow();
assertEquals(expected, loaded.getOccurredAt());
}
Code-Sprache: JavaScript (javascript)
Damit ist sichergestellt, dass Offset und Zeitpunkt identisch bleiben – unabhängig von der aktuellen JVM-Zeitzone oder Datenbankkonfiguration.
Alternative: Zeit und Offset getrennt speichern
Ein alternativer Ansatz besteht darin, den UTC-Zeitpunkt (Instant
) und den Offset separat zu speichern, etwa in zwei Spalten: TIMESTAMPTZ
und INTEGER
(für Minuten-Offset). Diese Methode erlaubt komplexe zeitbasierte SQL-Abfragen, erfordert jedoch mehr manuellen Aufwand im Mapping und der Konvertierung.
Fazit
Wenn es erforderlich ist, OffsetDateTime
-Werte in PostgreSQL vollständig inklusive ihres Offsets zu speichern und wieder exakt zu laden, ist der Standardweg über timestamptz
nicht ausreichend. Der Offset geht bei diesem Datentyp zwangsläufig verloren, da PostgreSQL intern alle Zeitangaben nach UTC konvertiert.
Die zuverlässigste Lösung besteht darin, die Werte als ISO-8601-konforme Strings in einer TEXT
-Spalte zu speichern und in der Anwendung mit einem AttributeConverter
zu behandeln. Dadurch bleibt die gesamte Information – Datum, Uhrzeit und Offset – vollständig erhalten und ist unabhängig von Zeitzonen- oder Konfigurationseinflüssen der Datenbank oder des JDBC-Treibers.
Diese Methode ist einfach zu implementieren, transparent zu debuggen und eignet sich besonders für Anwendungen, bei denen zeitliche Präzision und Nachvollziehbarkeit eine entscheidende Rolle spielen.