{"id":598,"date":"2025-04-19T01:22:55","date_gmt":"2025-04-19T00:22:55","guid":{"rendered":"https:\/\/www.javaeinfacherkl\u00e4rt.de\/?p=598"},"modified":"2025-05-10T01:24:18","modified_gmt":"2025-05-10T00:24:18","slug":"offsetdatetime-mit-offset-korrekt-in-postgresql-speichern-und-laden-mit-spring-data","status":"publish","type":"post","link":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/?p=598","title":{"rendered":"OffsetDateTime mit Offset korrekt in PostgreSQL speichern und laden mit Spring Data"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Einleitung<\/h2>\n\n\n\n<p>Zeitangaben mit Offset geh\u00f6ren zu den h\u00e4ufigsten und zugleich sensibelsten Daten in modernen Softwareanwendungen. Die Java-Klasse <code>OffsetDateTime<\/code> aus dem Paket <code>java.time<\/code> erlaubt es, neben Datum und Uhrzeit auch explizit den zugeh\u00f6rigen UTC-Offset (etwa <code>+02:00<\/code>) mitzuf\u00fchren. Dies ist insbesondere in dom\u00e4nenkritischen Anwendungen, wie Protokollierung, Auditing oder internationalen Zeitvergleichen, von gro\u00dfer Bedeutung.<\/p>\n\n\n\n<p>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\u00e4ngige PostgreSQL-Datentyp <code>timestamptz<\/code> (timestamp with time zone) speichert den Offset <strong>nicht<\/strong> mit. Stattdessen wird der Wert intern nach UTC normalisiert, wodurch der urspr\u00fcngliche Offset verloren geht. Das f\u00fchrt dazu, dass bei der sp\u00e4teren Abfrage nicht mehr rekonstruierbar ist, welcher Offset urspr\u00fcnglich verwendet wurde.<\/p>\n\n\n\n<p>In diesem Artikel wird ein Ansatz vorgestellt, wie man <code>OffsetDateTime<\/code>-Werte in einer Spring-Boot-Anwendung mithilfe von Spring Data so speichern und laden kann, dass der Offset vollst\u00e4ndig erhalten bleibt. Der beschriebene Weg ist unabh\u00e4ngig von der Systemzeitzone oder JDBC-Konfiguration und basiert auf einem verlustfreien Speichermodell.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Problemstellung: Der Verlust des Offsets in PostgreSQL<\/h2>\n\n\n\n<p>PostgreSQL unterscheidet zwischen zwei Zeittypen: <code>timestamp without time zone<\/code> und <code>timestamp with time zone<\/code>. W\u00e4hrend ersterer keinerlei Kontext zu Zeitzonen bietet, suggeriert letzterer, Zeitinformationen inklusive Zeitzonen oder Offsets zu speichern. Tats\u00e4chlich speichert PostgreSQL jedoch bei <code>timestamptz<\/code> ausschlie\u00dflich Zeitpunkte in UTC und verwirft jegliche Offset-Angaben der Eingabewerte.<\/p>\n\n\n\n<p>Ein Beispiel veranschaulicht das Verhalten: Wird der Wert <code>2025-05-10T14:00:00+02:00<\/code> gespeichert, legt PostgreSQL ihn intern als <code>2025-05-10T12:00:00+00:00<\/code> ab. Der urspr\u00fcngliche Offset <code>+02:00<\/code> ist damit dauerhaft verloren. Auch beim Auslesen \u00fcber JDBC wird der Offset nicht rekonstruiert, es sei denn, er wird auf Anwendungsebene separat gespeichert.<\/p>\n\n\n\n<p>Da <code>OffsetDateTime<\/code> explizit f\u00fcr solche Informationen gedacht ist, ist der Einsatz von <code>timestamptz<\/code> in Verbindung mit dieser Klasse nur bedingt geeignet. Eine zuverl\u00e4ssige Speicherung des vollst\u00e4ndigen Werts erfordert eine alternative L\u00f6sung.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">L\u00f6sung: Speicherung als ISO-8601-String<\/h2>\n\n\n\n<p>Die zuverl\u00e4ssigste M\u00f6glichkeit, <code>OffsetDateTime<\/code> inklusive Offset zu speichern, besteht darin, ihn als formatierten String in einer PostgreSQL-Spalte vom Typ <code>TEXT<\/code> oder <code>VARCHAR<\/code> abzulegen. Das ISO-8601-Format (<code>yyyy-MM-dd'T'HH:mm:ssXXX<\/code>) eignet sich ideal daf\u00fcr, da es von Java nativ unterst\u00fctzt wird und sowohl den Zeitpunkt als auch den Offset in einem Wert kodiert.<\/p>\n\n\n\n<p>Ein Beispielwert sieht folgenderma\u00dfen aus:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\">2025<span class=\"hljs-selector-tag\">-05-10T14<\/span><span class=\"hljs-selector-pseudo\">:23<\/span><span class=\"hljs-selector-pseudo\">:00+02<\/span><span class=\"hljs-selector-pseudo\">:00<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code-Sprache:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Wird dieser String gespeichert, bleibt der Offset erhalten und l\u00e4sst sich sp\u00e4ter exakt wieder in ein <code>OffsetDateTime<\/code>-Objekt umwandeln. F\u00fcr diese Konvertierung bietet sich in Spring ein benutzerdefinierter <code>AttributeConverter<\/code> an.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Umsetzung mit Spring Boot und Spring Data<\/h2>\n\n\n\n<p>Die Implementierung in einer Spring-Anwendung ist unkompliziert. Die relevanten Schritte umfassen die Definition des Datenbankmodells, die Entit\u00e4tsklasse, den Konverter sowie das Repository.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. Datenbankstruktur<\/h3>\n\n\n\n<p>Die entsprechende Tabelle in PostgreSQL verwendet f\u00fcr die Zeitspalte den Typ <code>TEXT<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\">CREATE TABLE events (\n    id UUID PRIMARY KEY,\n    name TEXT NOT <span class=\"hljs-keyword\">NULL<\/span>,\n    occurred_at TEXT NOT <span class=\"hljs-keyword\">NULL<\/span>\n);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code-Sprache:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Alternativ kann <code>VARCHAR<\/code> mit einer L\u00e4ngenbegrenzung verwendet werden. Ein ISO-8601-String ben\u00f6tigt typischerweise 25 bis 35 Zeichen, abh\u00e4ngig von Genauigkeit und Offset.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Konverter f\u00fcr OffsetDateTime<\/h3>\n\n\n\n<p>Der Konverter \u00fcbernimmt die Umwandlung zwischen <code>OffsetDateTime<\/code> und <code>String<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\">import jakarta.persistence.AttributeConverter;\nimport jakarta.persistence.Converter;\nimport java.time.OffsetDateTime;\nimport java.time.format.DateTimeFormatter;\n\n@Converter(autoApply = <span class=\"hljs-keyword\">true<\/span>)\n<span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">OffsetDateTimeStringConverter<\/span> <span class=\"hljs-keyword\">implements<\/span> <span class=\"hljs-title\">AttributeConverter<\/span>&lt;<span class=\"hljs-title\">OffsetDateTime<\/span>, <span class=\"hljs-title\">String<\/span>&gt; <\/span>{\n\n    <span class=\"hljs-keyword\">private<\/span> <span class=\"hljs-keyword\">static<\/span> <span class=\"hljs-keyword\">final<\/span> DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;\n\n    @Override\n    <span class=\"hljs-keyword\">public<\/span> String convertToDatabaseColumn(OffsetDateTime attribute) {\n        <span class=\"hljs-keyword\">return<\/span> attribute != <span class=\"hljs-keyword\">null<\/span> ? FORMATTER.format(attribute) : <span class=\"hljs-keyword\">null<\/span>;\n    }\n\n    @Override\n    <span class=\"hljs-keyword\">public<\/span> OffsetDateTime convertToEntityAttribute(String dbData) {\n        <span class=\"hljs-keyword\">return<\/span> dbData != <span class=\"hljs-keyword\">null<\/span> ? OffsetDateTime.parse(dbData, FORMATTER) : <span class=\"hljs-keyword\">null<\/span>;\n    }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code-Sprache:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Dank <code>autoApply = true<\/code> wird der Konverter automatisch auf alle Felder vom Typ <code>OffsetDateTime<\/code> angewendet, sofern kein anderer Konverter angegeben ist.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Entity-Klasse<\/h3>\n\n\n\n<p>Die Entit\u00e4t verwendet das OffsetDateTime-Feld wie gewohnt:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> jakarta.persistence.Entity;\n<span class=\"hljs-keyword\">import<\/span> jakarta.persistence.Id;\n<span class=\"hljs-keyword\">import<\/span> java.time.OffsetDateTime;\n<span class=\"hljs-keyword\">import<\/span> java.util.UUID;\n\n@Entity\npublic <span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">Event<\/span> <\/span>{\n\n    @Id\n    private UUID id;\n\n    private <span class=\"hljs-built_in\">String<\/span> name;\n\n    private OffsetDateTime occurredAt;\n\n    public Event() {}\n\n    public Event(UUID id, <span class=\"hljs-built_in\">String<\/span> name, OffsetDateTime occurredAt) {\n        <span class=\"hljs-keyword\">this<\/span>.id = id;\n        <span class=\"hljs-keyword\">this<\/span>.name = name;\n        <span class=\"hljs-keyword\">this<\/span>.occurredAt = occurredAt;\n    }\n\n    <span class=\"hljs-comment\">\/\/ Getter und Setter<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code-Sprache:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">4. Repository und Nutzung<\/h3>\n\n\n\n<p>Das zugeh\u00f6rige Repository ben\u00f6tigt keine besonderen Anpassungen:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">import<\/span> <span class=\"hljs-selector-tag\">org<\/span><span class=\"hljs-selector-class\">.springframework<\/span><span class=\"hljs-selector-class\">.data<\/span><span class=\"hljs-selector-class\">.jpa<\/span><span class=\"hljs-selector-class\">.repository<\/span><span class=\"hljs-selector-class\">.JpaRepository<\/span>;\n<span class=\"hljs-selector-tag\">import<\/span> <span class=\"hljs-selector-tag\">java<\/span><span class=\"hljs-selector-class\">.util<\/span><span class=\"hljs-selector-class\">.UUID<\/span>;\n\n<span class=\"hljs-selector-tag\">public<\/span> <span class=\"hljs-selector-tag\">interface<\/span> <span class=\"hljs-selector-tag\">EventRepository<\/span> <span class=\"hljs-selector-tag\">extends<\/span> <span class=\"hljs-selector-tag\">JpaRepository<\/span>&lt;<span class=\"hljs-selector-tag\">Event<\/span>, <span class=\"hljs-selector-tag\">UUID<\/span>&gt; {\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code-Sprache:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Ein typischer Anwendungscode kann wie folgt aussehen:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\">import org.springframework.stereotype.Service;\nimport java.time.OffsetDateTime;\nimport java.time.ZoneOffset;\nimport java.util.UUID;\n\n@Service\n<span class=\"hljs-keyword\">public<\/span> <span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">EventService<\/span> <\/span>{\n\n    <span class=\"hljs-keyword\">private<\/span> <span class=\"hljs-keyword\">final<\/span> EventRepository repository;\n\n    <span class=\"hljs-keyword\">public<\/span> EventService(EventRepository repository) {\n        this.repository = repository;\n    }\n\n    <span class=\"hljs-keyword\">public<\/span> void createEvent() {\n        OffsetDateTime dateTime = OffsetDateTime.now(ZoneOffset.ofHours(<span class=\"hljs-number\">2<\/span>));\n        Event event = <span class=\"hljs-keyword\">new<\/span> Event(UUID.randomUUID(), <span class=\"hljs-string\">\"Systemstart\"<\/span>, dateTime);\n        repository.save(event);\n    }\n\n    <span class=\"hljs-keyword\">public<\/span> OffsetDateTime getOccurredAt(UUID id) {\n        <span class=\"hljs-keyword\">return<\/span> repository.findById(id)\n                .map(Event::getOccurredAt)\n                .orElseThrow();\n    }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code-Sprache:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\">Validierung und Tests<\/h2>\n\n\n\n<p>Beim Schreiben von Tests empfiehlt es sich, nicht nur den Zeitpunkt, sondern auch den Offset zu pr\u00fcfen:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> <span class=\"hljs-keyword\">static<\/span> org.junit.jupiter.api.Assertions.*;\n\n@Test\n<span class=\"hljs-keyword\">void<\/span> testOffsetDateTimePersistence() {\n    OffsetDateTime expected = OffsetDateTime.of(<span class=\"hljs-number\">2025<\/span>, <span class=\"hljs-number\">5<\/span>, <span class=\"hljs-number\">10<\/span>, <span class=\"hljs-number\">14<\/span>, <span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0<\/span>, ZoneOffset.ofHours(<span class=\"hljs-number\">3<\/span>));\n    Event event = <span class=\"hljs-keyword\">new<\/span> Event(UUID.randomUUID(), <span class=\"hljs-string\">\"Testevent\"<\/span>, expected);\n    repository.save(event);\n\n    Event loaded = repository.findById(event.getId()).orElseThrow();\n    assertEquals(expected, loaded.getOccurredAt());\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code-Sprache:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Damit ist sichergestellt, dass Offset und Zeitpunkt identisch bleiben \u2013 unabh\u00e4ngig von der aktuellen JVM-Zeitzone oder Datenbankkonfiguration.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Alternative: Zeit und Offset getrennt speichern<\/h2>\n\n\n\n<p>Ein alternativer Ansatz besteht darin, den UTC-Zeitpunkt (<code>Instant<\/code>) und den Offset separat zu speichern, etwa in zwei Spalten: <code>TIMESTAMPTZ<\/code> und <code>INTEGER<\/code> (f\u00fcr Minuten-Offset). Diese Methode erlaubt komplexe zeitbasierte SQL-Abfragen, erfordert jedoch mehr manuellen Aufwand im Mapping und der Konvertierung.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Fazit<\/h2>\n\n\n\n<p>Wenn es erforderlich ist, <code>OffsetDateTime<\/code>-Werte in PostgreSQL vollst\u00e4ndig inklusive ihres Offsets zu speichern und wieder exakt zu laden, ist der Standardweg \u00fcber <code>timestamptz<\/code> nicht ausreichend. Der Offset geht bei diesem Datentyp zwangsl\u00e4ufig verloren, da PostgreSQL intern alle Zeitangaben nach UTC konvertiert.<\/p>\n\n\n\n<p>Die zuverl\u00e4ssigste L\u00f6sung besteht darin, die Werte als ISO-8601-konforme Strings in einer <code>TEXT<\/code>-Spalte zu speichern und in der Anwendung mit einem <code>AttributeConverter<\/code> zu behandeln. Dadurch bleibt die gesamte Information \u2013 Datum, Uhrzeit und Offset \u2013 vollst\u00e4ndig erhalten und ist unabh\u00e4ngig von Zeitzonen- oder Konfigurationseinfl\u00fcssen der Datenbank oder des JDBC-Treibers.<\/p>\n\n\n\n<p>Diese Methode ist einfach zu implementieren, transparent zu debuggen und eignet sich besonders f\u00fcr Anwendungen, bei denen zeitliche Pr\u00e4zision und Nachvollziehbarkeit eine entscheidende Rolle spielen.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Einleitung Zeitangaben mit Offset geh\u00f6ren zu den h\u00e4ufigsten 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\u00f6rigen UTC-Offset (etwa +02:00) mitzuf\u00fchren. Dies ist insbesondere in dom\u00e4nenkritischen Anwendungen, wie Protokollierung, Auditing oder internationalen Zeitvergleichen, von gro\u00dfer Bedeutung. Wird eine solche Zeitangabe [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5],"tags":[],"class_list":["post-598","post","type-post","status-publish","format-standard","hentry","category-spring"],"_links":{"self":[{"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=\/wp\/v2\/posts\/598","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=598"}],"version-history":[{"count":1,"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=\/wp\/v2\/posts\/598\/revisions"}],"predecessor-version":[{"id":599,"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=\/wp\/v2\/posts\/598\/revisions\/599"}],"wp:attachment":[{"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=598"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=598"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.xn--javaeinfacherklrt-4qb.de\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=598"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}