<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Date on Vitalii Sukach</title><link>https://sukachvitally.github.io/tags/date/</link><description>Recent content in Date on Vitalii Sukach</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sat, 15 Aug 2026 14:24:50 +0000</lastBuildDate><atom:link href="https://sukachvitally.github.io/tags/date/index.xml" rel="self" type="application/rss+xml"/><item><title>DateTime Handling in Java and PostgreSQL: A Practical Guide</title><link>https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/</link><pubDate>Sat, 15 Aug 2026 14:24:50 +0000</pubDate><guid>https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/</guid><description>&lt;p>&lt;em>Everything I wish someone had told me before I stored a meeting time in a&lt;/em> &lt;em>timestamp column.&lt;/em>&lt;/p>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-01.png">&lt;/p>
&lt;p>There is a specific kind of bug that only shows up in production, only in October, and only for users in one country. It gets closed as “not reproducible” twice before someone finally sits down with a coffee and reads the column definition.&lt;/p>
&lt;p>It is almost always a datetime bug.&lt;/p>
&lt;p>The frustrating part is that both Java and PostgreSQL have &lt;em>excellent&lt;/em> date/time support. java.time is one of the best-designed APIs in the JDK. PostgreSQL&amp;rsquo;s temporal types are precise and well documented. The bugs happen at the seam — in the assumptions each side makes about what the other means, and in the two or three words in the type names that mean the opposite of what you&amp;rsquo;d guess.&lt;/p></description><content:encoded><![CDATA[<p><em>Everything I wish someone had told me before I stored a meeting time in a</em> <em>timestamp column.</em></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-01.png"></p>
<p>There is a specific kind of bug that only shows up in production, only in October, and only for users in one country. It gets closed as “not reproducible” twice before someone finally sits down with a coffee and reads the column definition.</p>
<p>It is almost always a datetime bug.</p>
<p>The frustrating part is that both Java and PostgreSQL have <em>excellent</em> date/time support. java.time is one of the best-designed APIs in the JDK. PostgreSQL&rsquo;s temporal types are precise and well documented. The bugs happen at the seam — in the assumptions each side makes about what the other means, and in the two or three words in the type names that mean the opposite of what you&rsquo;d guess.</p>
<p>This guide walks through both models, then spends real time on the part that actually causes outages: time zones.</p>
<p>Every code output and query result below was executed — Java 21 and PostgreSQL 16 — not written from memory.</p>
<h3 id="part-1-the-javaside">Part 1: The Java Side</h3>
<h4 id="ask-three-questions-get-onetype">Ask three questions, get one type</h4>
<p>Every java.time class is an answer to some combination of three questions:</p>
<ol>
<li>Do I know the <strong>date</strong>?</li>
<li>Do I know the <strong>time of day</strong>?</li>
<li>Do I know which <strong>point on the global timeline</strong> this is?</li>
</ol>
<p>That last one is the important one, and it’s the one people skip. Here’s the map:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-02.png"></p>
<p>The single most useful sentence in this article: <strong>LocalDateTime is not a point in time.</strong> 2026-08-15T12:00 is twelve o&rsquo;clock <em>somewhere</em>, and until you say where, it refers to a 26-hour-wide smear across the planet. If you have an Instant and you call .toLocalDateTime(), you have thrown away information and the compiler will not warn you.</p>
<h4 id="instant-is-the-boring-correctdefault">Instant is the boring, correct default</h4>
<pre tabindex="0"><code>Instant now = Instant.now();     // 2026-08-15T10:00:00.123456789Z
</code></pre><p>An Instant is nanoseconds since the epoch. No zone, no offset, no calendar. When it prints with a Z, that isn&rsquo;t a stored time zone — it&rsquo;s the ISO-8601 way of rendering an epoch value.</p>
<p>Anything that <em>happened</em> — a login, a payment, a row insert — is an Instant. Reach for something else only when you have a reason.</p>
<h4 id="zoneid-vs-zoneoffset-not-the-samething">ZoneId vs ZoneOffset: not the same thing</h4>
<p>This distinction matters more than any other in this article.</p>
<pre tabindex="0"><code>ZoneId.of(&#34;Europe/London&#34;)  // -&gt; java.time.ZoneRegion
ZoneId.of(&#34;+01:00&#34;)         // -&gt; java.time.ZoneOffset
</code></pre><p>A <strong>ZoneOffset</strong> is a fixed number of hours and minutes from UTC. +01:00. That&rsquo;s the whole thing. It&rsquo;s a fact about one moment.</p>
<p>A <strong>ZoneId</strong> (specifically a ZoneRegion, like Europe/London) is a <em>rulebook</em>. It knows that London was +00:00 in January, +01:00 in August, and it knows the exact instants at which it flips — going back decades and forward as far as legislators have declared.</p>
<p>ZoneOffset is a value. ZoneId is a function from instant to offset. Confusing them is the root cause of most scheduling bugs.</p>
<h4 id="offsetdatetime-vs-zoneddatetime">OffsetDateTime vs ZonedDateTime</h4>
<p>Both identify a real instant. The difference is what survives.</p>
<pre tabindex="0"><code>ZonedDateTime nyc = ZonedDateTime.of(2026, 7, 4, 12, 0, 0, 0,
                                     ZoneId.of(&#34;America/New_York&#34;));
nyc                    // 2026-07-04T12:00-04:00[America/New_York]
nyc.toOffsetDateTime() // 2026-07-04T12:00-04:00
nyc.toInstant()        // 2026-07-04T16:00:00Z
</code></pre><p>All three describe the same moment. But the ZonedDateTime still knows it&rsquo;s in New York, so if you add six months to it, it will correctly land on -05:00. The OffsetDateTime has been flattened — it remembers <em>that</em> the offset was -04:00, not <em>why</em>. Add six months and you get a wrong wall-clock time.</p>
<p>Rule of thumb: ZonedDateTime for calculating, OffsetDateTime for transporting, Instant for storing.</p>
<h4 id="duration-vsperiod">Duration vs Period</h4>
<pre tabindex="0"><code>LocalDate.of(2026, 1, 31).plusMonths(1)  // 2026-02-28
</code></pre><p>Duration is exact elapsed time — seconds and nanos. Period is calendar time — years, months, days. &ldquo;One month&rdquo; is not a fixed number of seconds, and plusMonths clamps rather than overflowing. Both behaviors are correct; mixing them up is not.</p>
<p>This distinction resurfaces the moment DST enters the picture, and we’ll come back to it.</p>
<h4 id="a-word-on-the-oldapi">A word on the old API</h4>
<p>java.util.Date is a mislabeled Instant (it has no time zone despite what toString() implies). Calendar is mutable and lenient. java.sql.Timestamp extends java.util.Date and adds nanoseconds while breaking the equals contract.</p>
<p>If you’re on a legacy codebase, convert at the boundary and never let them into your domain:</p>
<pre tabindex="0"><code>Instant i = oldDate.toInstant();
Date    d = Date.from(instant);
</code></pre><h3 id="part-2-the-postgresql-side">Part 2: The PostgreSQL Side</h3>
<h4 id="timestamptz-does-not-store-a-timezone">timestamptz does not store a time zone</h4>
<p>This is the single biggest source of confusion, and it comes straight from the type name. TIMESTAMP WITH TIME ZONE is an 8-byte value. So is TIMESTAMP WITHOUT TIME ZONE. There is no room in there for a zone, and PostgreSQL doesn&rsquo;t try:</p>
<pre tabindex="0"><code>SELECT pg_column_size(&#39;2026-08-15 12:00:00&#39;::timestamp)     AS ts_bytes,     
       --  8
       pg_column_size(&#39;2026-08-15 12:00:00+02&#39;::timestamptz) AS tstz_bytes,  
       --  8
       pg_column_size(&#39;12:00:00+02&#39;::timetz)                 AS timetz_bytes; 
       -- 12
</code></pre><p>What timestamptz actually does is <strong>convert to UTC on write and convert back on read</strong>. The offset you supplied on input is used and then discarded. From the <a href="https://www.postgresql.org/docs/current/datatype-datetime.html">official docs</a>:</p>
<blockquote>
<p><em>In either case, the value is stored internally as UTC, and the originally stated or assumed time zone is not retained.</em></p></blockquote>
<p>A better mental model: timestamptz is Instant. timestamp is LocalDateTime. The names lie; the semantics don&rsquo;t.</p>
<h4 id="the-demo-that-makes-itclick">The demo that makes it click</h4>
<p>Insert the same two literals into both column types, with the session zone set to London:</p>
<pre tabindex="0"><code>SET TimeZone = &#39;Europe/London&#39;;
CREATE TABLE t (id int, ts timestamp, tstz timestamptz);
INSERT INTO t VALUES (1, &#39;2026-08-15 12:00:00&#39;,    &#39;2026-08-15 12:00:00&#39;);
INSERT INTO t VALUES (2, &#39;2026-08-15 12:00:00+00&#39;, &#39;2026-08-15 12:00:00+00&#39;);
SELECT * FROM t;
</code></pre><pre tabindex="0"><code> id |         ts          |          tstz
----+---------------------+------------------------
  1 | 2026-08-15 12:00:00 | 2026-08-15 12:00:00+01
  2 | 2026-08-15 12:00:00 | 2026-08-15 13:00:00+01
</code></pre><p>Two things happened. Row 1’s tstz had no offset, so PostgreSQL <em>assumed</em> the session zone. Row 2&rsquo;s explicit +00 was honoured and re-rendered as 13:00+01. Meanwhile the ts column ignored the offset entirely and stored the digits.</p>
<p>Now reconnect from New York and run the identical SELECT:</p>
<pre tabindex="0"><code> id |         ts          |          tstz
----+---------------------+------------------------
  1 | 2026-08-15 12:00:00 | 2026-08-15 07:00:00-04
  2 | 2026-08-15 12:00:00 | 2026-08-15 08:00:00-04
</code></pre><p>The tstz column re-renders per client. The ts column is frozen. <strong>Neither row changed on disk.</strong></p>
<p>That last point deserves emphasis, because it’s where the panic starts: when a developer sees a different value than QA saw, the instinct is “the data is corrupted.” It isn’t. Two clients with different TimeZone settings are looking at the same UTC value through different windows.</p>
<h4 id="at-time-zone-runs-bothways">AT TIME ZONE runs both ways</h4>
<p>AT TIME ZONE is the one operator you need, and its behavior flips depending on the input type:</p>
<pre tabindex="0"><code>-- timestamptz -&gt; timestamp: &#34;what does the clock read there?&#34;
SELECT &#39;2026-08-15 12:00:00+00&#39;::timestamptz AT TIME ZONE &#39;Asia/Tokyo&#39;;
-- 2026-08-15 21:00:00   (type: timestamp without time zone)
</code></pre><pre tabindex="0"><code>-- timestamp -&gt; timestamptz: &#34;this wall clock reading WAS in Tokyo&#34;
SELECT &#39;2026-08-15 12:00:00&#39;::timestamp AT TIME ZONE &#39;Asia/Tokyo&#39;;
-- 2026-08-15 03:00:00+00   (type: timestamp with time zone)
</code></pre><p>One removes zone context, the other attaches it. Read it as “interpret this value in that zone.” The function form timezone(&lsquo;Asia/Tokyo&rsquo;, value) is identical.</p>
<h4 id="the-rest-of-thefamily">The rest of the family</h4>
<ul>
<li><strong>date</strong> — 4 bytes, no zone, no ambiguity. Great for things that are genuinely dates.</li>
<li><strong>time</strong> — time of day, no zone.</li>
<li><strong>timetz</strong> — 12 bytes, and <a href="https://www.postgresql.org/docs/current/datatype-datetime.html">PostgreSQL itself recommends against it</a>. A time of day with an offset but no date can&rsquo;t resolve DST, so it&rsquo;s not meaningfully anchored. It won&rsquo;t even accept a zone name:</li>
</ul>
<pre tabindex="0"><code>SELECT &#39;12:00:00 Europe/London&#39;::timetz; -- ERROR: invalid input syntax for type time with time zone
</code></pre><ul>
<li><strong>interval</strong> — 16 bytes storing months, days, and microseconds <em>separately</em>, which is exactly why it can be DST-aware.</li>
</ul>
<h4 id="now-vs-localtimestamp">now() vs localtimestamp</h4>
<pre tabindex="0"><code>SELECT pg_typeof(now()),            -- timestamp with time zone
       pg_typeof(current_timestamp), -- timestamp with time zone
       pg_typeof(localtimestamp),    -- timestamp without time zone
       pg_typeof(current_date);      -- date
</code></pre><p>now() and current_timestamp are timestamptz and are safe. localtimestamp and current_date are evaluated in the session zone, which means a DEFAULT current_date column produces different answers for different clients. Be deliberate.</p>
<h4 id="so-which-do-youpick">So which do you pick?</h4>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-03.png"></p>
<p>The short version: <strong>use</strong> <strong>timestamptz for essentially everything.</strong> Reach for timestamp only when you genuinely mean a floating wall-clock time that must not shift — &ldquo;the store opens at 09:00 local, whatever local means at that branch&rdquo; — and store the zone ID beside it.</p>
<h3 id="part-3-time-zonesproperly">Part 3: Time Zones, Properly</h3>
<p>This is where the real complexity lives, so let’s slow down.</p>
<h4 id="three-things-called-timezone">Three things called “time zone”</h4>
<ol>
<li><strong>An offset</strong> — +01:00. A number.</li>
<li><strong>An IANA zone ID</strong> — Europe/London. A named set of historical and future rules. PostgreSQL ships ~499 of these in pg_timezone_names; Java reads the same tzdata.</li>
<li><strong>An abbreviation</strong> — EST, BST, IST. Ambiguous, unversioned, and a trap.</li>
</ol>
<p>That third one is genuinely dangerous:</p>
<pre tabindex="0"><code>SELECT &#39;2026-08-15 12:00:00 EST&#39;::timestamptz,               -- 2026-08-15 17:00:00+00
       &#39;2026-08-15 12:00:00 America/New_York&#39;::timestamptz;  -- 2026-08-15 16:00:00+00
</code></pre><p>One hour apart. EST is a <em>fixed</em> -05:00 in PostgreSQL — it does not mean &ldquo;Eastern time,&rdquo; it means &ldquo;Eastern Standard Time, always.&rdquo; In August, New York is on -04:00. If you accept zone abbreviations from user input, you have already shipped a bug. (IST is worse — it can mean India, Ireland, or Israel depending on who you ask.)</p>
<p><strong>Always use full IANA names.</strong></p>
<p>Offsets aren’t zones either. Nepal is +05:45; the Chatham Islands are +12:45:</p>
<pre tabindex="0"><code>ZonedDateTime.of(2026,8,15,12,0,0,0, ZoneId.of(&#34;Asia/Kathmandu&#34;))
// 2026-08-15T12:00+05:45[Asia/Kathmandu]
</code></pre><p>If your API contract says “offset in whole hours,” it’s wrong.</p>
<h3 id="where-postgresqls-session-zone-comesfrom">Where PostgreSQL’s session zone comes from</h3>
<p>The TimeZone GUC is a <strong>per-connection setting</strong>. Its value is decided by, in order: an explicit SET TimeZone, the connection&rsquo;s startup parameters, the postgresql.conf value, and finally the server&rsquo;s OS zone.</p>
<p>That last fallback is why the same query returns different results on a developer laptop and in CI. And with connection pooling, SET TimeZone on a borrowed connection leaks to whoever gets it next.</p>
<p>Pin it. Set timezone = &lsquo;UTC&rsquo; in postgresql.conf, and add it to the JDBC URL so it survives pool reuse:</p>
<pre tabindex="0"><code>jdbc:postgresql://host:5432/db?options=-c%20TimeZone%3DUTC
</code></pre><p>Then make the client responsible for rendering. Formatting a timestamp for a human is a presentation concern, not a database one.</p>
<h4 id="the-spring-forward-gap">The spring-forward gap</h4>
<p>On 29 March 2026, London jumps from 01:00 to 02:00. The local times between them <strong>do not exist</strong>.</p>
<p>Ask Java for one anyway:</p>
<pre tabindex="0"><code>LocalDateTime gap = LocalDateTime.of(2026, 3, 29, 1, 30);
gap.atZone(ZoneId.of(&#34;Europe/London&#34;));
// 2026-03-29T02:30+01:00[Europe/London]
ZoneId.of(&#34;Europe/London&#34;).getRules().getTransition(gap);
// Transition[Gap at 2026-03-29T01:00Z to +01:00]
</code></pre><p>Java doesn’t throw. It silently pushes the value forward by the length of the gap. PostgreSQL does the same thing:</p>
<pre tabindex="0"><code>SET TimeZone=&#39;Europe/London&#39;;
SELECT &#39;2026-03-29 01:30:00&#39;::timestamptz;
-- 2026-03-29 02:30:00+01
</code></pre><p>Both are “correct” — but neither told you. If a user sets a 01:30 alarm on that date, you should be validating with getTransition() and telling them, not letting the platform quietly reschedule.</p>
<h4 id="the-fall-back-overlapwhere-java-and-postgresql-disagree">The fall-back overlap — where Java and PostgreSQL disagree</h4>
<p>On 25 October 2026, London goes from 02:00 back to 01:00. Local times between 01:00 and 02:00 happen <strong>twice</strong>, one hour apart.</p>
<p>Java’s rules expose both:</p>
<pre tabindex="0"><code>ZoneId london = ZoneId.of(&#34;Europe/London&#34;);
LocalDateTime overlap = LocalDateTime.of(2026, 10, 25, 1, 30);
london.getRules().getValidOffsets(overlap);
// [+01:00, Z]
ZonedDateTime earlier = overlap.atZone(london);
// 2026-10-25T01:30+01:00[Europe/London]  instant = 2026-10-25T00:30:00Z
ZonedDateTime later = earlier.withLaterOffsetAtOverlap();
// 2026-10-25T01:30Z[Europe/London]       instant = 2026-10-25T01:30:00Z
</code></pre><p>Java’s default for an ambiguous local time is the <strong>earlier</strong> offset — 00:30Z.</p>
<p>Now ask PostgreSQL the same question:</p>
<pre tabindex="0"><code>SELECT &#39;2026-10-25 01:30:00&#39;::timestamp AT TIME ZONE &#39;Europe/London&#39;;
-- 2026-10-25 01:30:00+00
</code></pre><p>PostgreSQL picked 01:30Z — the <strong>later</strong> one.</p>
<p>Same input, same tzdata, one hour apart. If your application resolves an ambiguous local time in Java and your reporting job resolves it in SQL, they will disagree for one hour, twice a year, in every DST zone.</p>
<p>There is no clever fix. The lesson is: <strong>never let a</strong> <strong>LocalDateTime cross the boundary and get resolved on the other side.</strong> Resolve to an Instant in exactly one place, with explicit overlap handling, and send the instant.</p>
<h4 id="dst-arithmetic-plusdays-is-not-plushours24">DST arithmetic: plusDays is not plusHours(24)</h4>
<pre tabindex="0"><code>ZonedDateTime start = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, london);
// 2026-03-28T12:00Z[Europe/London]
start.plusDays(1);                 // 2026-03-29T12:00+01:00  (23 real hours)
start.plus(Duration.ofDays(1));    // 2026-03-29T13:00+01:00  (24 real hours)
start.plusHours(24);               // 2026-03-29T13:00+01:00
</code></pre><p>plusDays preserves the wall clock. Duration preserves elapsed time. PostgreSQL draws the same line, using the same value:</p>
<pre tabindex="0"><code>SET TimeZone=&#39;Europe/London&#39;;
SELECT &#39;2026-03-28 12:00:00+00&#39;::timestamptz + interval &#39;1 day&#39;,   -- 2026-03-29 12:00:00+01
       &#39;2026-03-28 12:00:00+00&#39;::timestamptz + interval &#39;24 hours&#39;; -- 2026-03-29 13:00:00+01
</code></pre><p>This is one place the two platforms agree beautifully — interval &lsquo;1 day&rsquo; and plusDays(1) mean the same thing, and interval &lsquo;24 hours&rsquo; and Duration.ofDays(1) mean the same thing. &ldquo;Every day at 9am&rdquo; is plusDays / interval &lsquo;1 day&rsquo;. &ldquo;Every 24 hours&rdquo; is Duration / interval &lsquo;24 hours&rsquo;. Pick the one the product actually means.</p>
<h4 id="future-events-an-instant-is-notenough">Future events: an instant is not enough</h4>
<pre tabindex="0"><code>ZoneId ny = ZoneId.of(&#34;America/New_York&#34;);
ZonedDateTime.of(2027, 1, 15, 9, 0, 0, 0, ny).withZoneSameInstant(UTC); // 2027-01-15T14:00Z
ZonedDateTime.of(2027, 7, 15, 9, 0, 0, 0, ny).withZoneSameInstant(UTC); // 2027-07-15T13:00Z
</code></pre><p>“9am in New York” is a different UTC instant depending on the date. So if a user schedules a recurring 9am standup and a government later changes DST rules — which happens several times a year somewhere — the stored instant becomes wrong.</p>
<p>For <strong>past events</strong>, store the instant. That’s history; it can’t change.</p>
<p>For <strong>future user-scheduled events</strong>, store three things:</p>
<pre tabindex="0"><code>CREATE TABLE meetings (
  id            bigserial PRIMARY KEY,
  local_start   timestamp   NOT NULL,  -- 2027-01-15 09:00
  zone_id       text        NOT NULL,  -- &#39;America/New_York&#39;
  computed_utc  timestamptz NOT NULL   -- cache; recompute on tzdata update
);
</code></pre><p>The first two are the truth. The third is a derived index for querying, refreshed when tzdata changes.</p>
<h4 id="tzdata-is-a-movingtarget">tzdata is a moving target</h4>
<p>Java bundles a copy of the IANA database (2025b on the JDK I ran this on); PostgreSQL bundles its own; your OS has a third. They drift. When Java and PostgreSQL are on different tzdata versions, they will resolve recently-changed zones differently.</p>
<p>Check what you have:</p>
<pre tabindex="0"><code>ZoneRulesProvider.getVersions(&#34;Europe/London&#34;).lastKey();  // 2025b
</code></pre><pre tabindex="0"><code>SELECT name, utc_offset, is_dst FROM pg_timezone_names WHERE name = &#39;Europe/London&#39;;
</code></pre><p>Pin tzdata in your base images, and treat a tzdata bump as a deployment that requires recomputing cached future instants.</p>
<h3 id="part-4-wiring-java-to-postgresql">Part 4: Wiring Java to PostgreSQL</h3>
<h4 id="the-mappingtable">The mapping table</h4>
<p>Per the <a href="https://jdbc.postgresql.org/documentation/query/">pgJDBC documentation</a>:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-04.png"></p>
<p>Note the omission, in the driver’s own words:</p>
<blockquote>
<p><em>ZonedDateTime, Instant and OffsetTime / TIME WITH TIME ZONE are not supported.</em></p></blockquote>
<p>So don’t hand the driver an Instant and hope — depending on the driver version you&rsquo;ll either get a Can&rsquo;t infer the SQL type to use for an instance of java.time.Instant exception or undocumented behavior. Convert explicitly at the boundary instead:</p>
<pre tabindex="0"><code>// write
ps.setObject(1, instant.atOffset(ZoneOffset.UTC));
// read
Instant i = rs.getObject(&#34;occurred_at&#34;, OffsetDateTime.class).toInstant();
</code></pre><p>Note that reading a timestamptz gives you an OffsetDateTime normalized to UTC — the driver&rsquo;s offset is always Z, because that&rsquo;s what the backend holds. The offset carries no information. Convert straight to Instant and don&rsquo;t be tempted to read meaning into it.</p>
<h4 id="avoid-settimestamp--gettimestamp">Avoid setTimestamp / getTimestamp</h4>
<p>The java.sql.Timestamp overloads convert using the <strong>JVM default time zone</strong> unless you pass an explicit Calendar. That makes your persistence layer&rsquo;s correctness depend on the container&rsquo;s TZ environment variable. Use the java.time setObject/getObject methods instead — they&rsquo;re unambiguous.</p>
<p>If you must keep the JVM zone from mattering at all, set it explicitly at startup:</p>
<pre tabindex="0"><code>-Duser.timezone=UTC
</code></pre><p>Belt and braces, not a substitute for correct types.</p>
<h4 id="precision-java-has-nanos-postgresql-hasmicros">Precision: Java has nanos, PostgreSQL has micros</h4>
<pre tabindex="0"><code>Instant.parse(&#34;2026-08-15T10:00:00.123456789Z&#34;)           // .123456789
    .truncatedTo(ChronoUnit.MICROS)                        // .123456
</code></pre><p>PostgreSQL stores microseconds. Those last three digits are gone on the round trip, and PostgreSQL may <strong>round</strong> rather than truncate:</p>
<pre tabindex="0"><code>SELECT &#39;2026-08-15 10:00:00.123456789&#39;::timestamptz;  -- 10:00:00.123457
</code></pre><p>If you have an equality test or an optimistic-lock check comparing an in-memory Instant to one that&rsquo;s been through the database, truncate to micros <em>before</em> you persist so both sides agree.</p>
<h4 id="dont-wrap-the-column-in-afunction">Don’t wrap the column in a function</h4>
<p>Because timestamptz is stored as UTC, &ldquo;give me everything that happened on 1 Feb in New York&rdquo; tempts you into this:</p>
<pre tabindex="0"><code>EXPLAIN SELECT count(*) FROM events
WHERE (occurred_at AT TIME ZONE &#39;America/New_York&#39;)::date = DATE &#39;2026-02-01&#39;;
--  Seq Scan on events
</code></pre><p>Index gone. Compute the boundary instants instead and do a half-open range scan:</p>
<pre tabindex="0"><code>SELECT (DATE &#39;2026-02-01&#39; + time &#39;00:00&#39;) AT TIME ZONE &#39;America/New_York&#39;,  -- 2026-02-01 05:00+00
       (DATE &#39;2026-02-02&#39; + time &#39;00:00&#39;) AT TIME ZONE &#39;America/New_York&#39;;  -- 2026-02-02 05:00+00
EXPLAIN SELECT count(*) FROM events
WHERE occurred_at &gt;= &#39;2026-02-01 05:00:00+00&#39;
  AND occurred_at &lt;  &#39;2026-02-02 05:00:00+00&#39;;
--  Index Only Scan using events_occurred_at_idx
</code></pre><p>Note that this “day” is 24 hours only because February has no DST transition. On a transition day it’s 23 or 25 — which the boundary-instant approach handles correctly and a naive + interval &lsquo;24 hours&rsquo; does not. Half-open ranges (&gt;= and &lt;) also dodge the microsecond-boundary problem that BETWEEN creates.</p>
<h3 id="part-5-jpa-and-hibernate">Part 5: JPA and Hibernate</h3>
<p>Hibernate 6 made zoned types a first-class concern via TimeZoneStorageType:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-05.png"></p>
<p>For PostgreSQL, the practical consequence is settled by the database, not the ORM: timestamptz discards the offset, so under DEFAULT your OffsetDateTime round-trips as UTC. Which is fine — as long as you expected it.</p>
<p>Sensible defaults:</p>
<pre tabindex="0"><code>spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          time_zone: UTC
        timezone:
          default_storage: NORMALIZE_UTC
</code></pre><p>And in the entity, be explicit about intent:</p>
<pre tabindex="0"><code>@Entity
public class Booking {
    
    // A moment. Maps to timestamptz.
    @Column(nullable = false)
    private Instant createdAt;
    
    // A date. Not a moment.
    @Column(nullable = false)
    private LocalDate serviceDate;
    
    // A wall-clock time that must not shift. Maps to timestamp.
    @Column(nullable = false)
    private LocalDateTime localStart;
    
    @Column(nullable = false)
    private String zoneId;   // &#39;Europe/London&#39;
    
    public ZonedDateTime start() {
        return localStart.atZone(ZoneId.of(zoneId));
    }
}
</code></pre><p>If you genuinely need the user’s original offset preserved, @TimeZoneStorage(TimeZoneStorageType.COLUMN) with @TimeZoneColumn writes it to a companion column. Use it when the offset is <em>data</em> — an audit trail of what the user&rsquo;s device claimed — not as a default.</p>
<h3 id="part-6-testing-thisstuff">Part 6: Testing This Stuff</h3>
<p>Datetime bugs are only cheap if you catch them before the clocks change. Three habits do most of the work.</p>
<h4 id="1-inject-aclock">1. Inject a Clock</h4>
<p>Instant.now() scattered through business logic is untestable. Inject java.time.Clock and you can move time at will:</p>
<pre tabindex="0"><code>@Bean Clock clock() { return Clock.systemUTC(); }
// in a test
Clock fixed = Clock.fixed(Instant.parse(&#34;2026-10-25T00:30:00Z&#34;), ZoneOffset.UTC);
</code></pre><h4 id="2-run-ci-in-a-hostile-timezone">2. Run CI in a hostile time zone</h4>
<p>If your JVM, your database, and your CI runner are all UTC, every zone bug hides until production. Force one of them somewhere awkward:</p>
<pre tabindex="0"><code>-Duser.timezone=Pacific/Chatham
</code></pre><p>+12:45 and it observes DST. If your test suite survives Chatham, it will survive most things. Do the same for the PostgreSQL container:</p>
<pre tabindex="0"><code>@Container
static PostgreSQLContainer&lt;?&gt; pg = new PostgreSQLContainer&lt;&gt;(&#34;postgres:16&#34;)
        .withEnv(&#34;TZ&#34;, &#34;Asia/Kathmandu&#34;)
        .withUrlParam(&#34;options&#34;, &#34;-c TimeZone=Asia/Kathmandu&#34;);
</code></pre><p>The point isn’t that production runs there — it’s that a test which only passes in UTC is a test that has proven nothing about zone handling.</p>
<h4 id="3-write-the-four-transition-tests">3. Write the four transition tests</h4>
<p>For any code that converts between local and instant, pin these explicitly:</p>
<pre tabindex="0"><code>static final ZoneId LONDON = ZoneId.of(&#34;Europe/London&#34;);
@Test void gapTimesAreRejectedNotSilentlyShifted() {
    LocalDateTime gap = LocalDateTime.of(2026, 3, 29, 1, 30);
    assertThat(LONDON.getRules().getValidOffsets(gap)).isEmpty();
    assertThatThrownBy(() -&gt; scheduler.schedule(gap, LONDON))
        .isInstanceOf(NonExistentLocalTimeException.class);
}
@Test void overlapTimesResolveToTheDocumentedChoice() {
    LocalDateTime overlap = LocalDateTime.of(2026, 10, 25, 1, 30);
    assertThat(LONDON.getRules().getValidOffsets(overlap)).hasSize(2);
    assertThat(scheduler.resolve(overlap, LONDON))
        .isEqualTo(Instant.parse(&#34;2026-10-25T00:30:00Z&#34;)); // earlier, by policy
}
@Test void dailyRecurrenceKeepsWallClockAcrossDst() {
    ZonedDateTime start = ZonedDateTime.of(2026, 3, 28, 9, 0, 0, 0, LONDON);
    assertThat(recurrence.next(start).toLocalTime()).isEqualTo(LocalTime.of(9, 0));
}
@Test void instantSurvivesTheDatabaseRoundTrip() {
    Instant original = Instant.now().truncatedTo(ChronoUnit.MICROS);
    repository.save(new Event(original));
    assertThat(repository.findLatest().occurredAt()).isEqualTo(original);
}
</code></pre><p>The fourth one catches more real bugs than you’d expect — it’s the one that fails when someone quietly changes a column from timestamptz to timestamp.</p>
<h3 id="the-shortversion">The Short Version</h3>
<p>If you skim one section, make it this one.</p>
<p><strong>Schema</strong></p>
<ul>
<li>timestamptz by default. It&rsquo;s the same 8 bytes and it&rsquo;s unambiguous.</li>
<li>timestamp only for wall-clock times that must not shift — and store the zone ID beside it.</li>
<li>date for dates. timetz for nothing.</li>
<li>Pin timezone = &lsquo;UTC&rsquo; in postgresql.conf and in the JDBC URL.</li>
</ul>
<p><strong>Java</strong></p>
<ul>
<li>Instant for things that happened. LocalDate for dates. ZonedDateTime for scheduling. OffsetDateTime at the JDBC boundary.</li>
<li>LocalDateTime is not a point in time. Treat every use as a question to answer, not an answer.</li>
<li>Never accept zone abbreviations. Full IANA names only.</li>
<li>plusDays ≠ plus(Duration.ofDays(1)). Know which one your product means.</li>
</ul>
<p><strong>The seam</strong></p>
<ul>
<li>pgJDBC speaks LocalDate/LocalTime/LocalDateTime/OffsetDateTime. Convert Instant explicitly.</li>
<li>Avoid setTimestamp/getTimestamp — they use the JVM default zone.</li>
<li>Truncate to microseconds before persisting.</li>
<li>Don’t wrap indexed timestamp columns in AT TIME ZONE; compute boundary instants instead.</li>
</ul>
<p><strong>And the one that bites hardest</strong></p>
<p>Java resolves an ambiguous local time to the <em>earlier</em> offset. PostgreSQL picks the <em>later</em> one. Resolve ambiguity in exactly one place, deliberately, and pass instants across the boundary — never local times.</p>
<p>Store the instant, render the local time, and keep the zone rules in exactly one layer. Most datetime bugs are just that rule, broken somewhere.</p>
<p><em>All examples verified on OpenJDK 21.0.10 (tzdata 2025b) and PostgreSQL 16.13.</em></p>
<p><strong>Sources</strong></p>
<ul>
<li><a href="https://www.postgresql.org/docs/current/datatype-datetime.html">PostgreSQL: Date/Time Types</a></li>
<li><a href="https://jdbc.postgresql.org/documentation/query/">pgJDBC: Using Java 8 Date and Time classes</a></li>
<li><a href="https://docs.hibernate.org/orm/6.4/javadocs/org/hibernate/annotations/TimeZoneStorageType.html">Hibernate</a> <a href="https://docs.hibernate.org/orm/6.4/javadocs/org/hibernate/annotations/TimeZoneStorageType.html">TimeZoneStorageType Javadoc</a></li>
<li><a href="https://docs.hibernate.org/orm/6.2/migration-guide/">Hibernate 6 migration guide</a></li>
<li><a href="https://thorben-janssen.com/hibernate-6-offsetdatetime-and-zoneddatetime/">Thorben Janssen: TimezoneStorageType</a></li>
</ul>
]]></content:encoded></item></channel></rss>