On This Page
I ran the same program three times, on three machines pretending to be in three different places, and asked a legacy java.sql.Date what day it was. It said 2026-10-26 in Berlin and 2026-10-25 in UTC and in Los Angeles. Same object, same bytes, three answers:
JVM default zone : Europe/Berlin
sql.Date.toString() : 2026-10-26
JVM default zone : UTC
sql.Date.toString() : 2026-10-25
JVM default zone : America/Los_Angeles
sql.Date.toString() : 2026-10-25
Now the same parcel after it goes through the bridge you're about to write. These lines are identical on all three machines:
Scan : Scan[parcelId=PK-4711, scannedAt=2026-10-25T22:30:00.123Z, depotZone=Europe/Berlin]
Berlin reading : 2026-10-25T23:30:00.123
Tokyo reading : 2026-10-26T07:30:00.123
Ship day (Berlin) : 2026-10-26
That's what this article is for: how to convert Date to LocalDateTime in Java (and to Instant, Timestamp, Calendar, OffsetDateTime and epoch milliseconds, in both directions) without the answer depending on where the JVM happens to run. You'll build a small parcel tracker that takes scan records from an old warehouse system, converts every legacy time type into java.time, and writes some of them back. It's for Java developers who inherited code full of java.util.Date and need to modernise it without breaking the data.
LegacyScan Scan (new code)
Date / Timestamp / sql.Date ──┐
Calendar / epoch millis ──┼──> Instant ──> LocalDateTime / LocalDate / ZonedDateTime
│ ^ (always with a ZoneId you pass in)
Date / Timestamp / Calendar <─┘───────┘
Check your setup
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| Gradle (optional path) | 8.14.3 | gradle --version |
| IntelliJ IDEA (optional) | 2026.2.2 | Help → About |
| Eclipse IDE (optional) | 2026-06 | Help → About Eclipse IDE |
| OS I ran it on | Linux, system zone UTC | date +%Z |
The conversion methods in java.time and the legacy classes (Date.from, Timestamp.from, GregorianCalendar.from) have all existed since Java 8, so everything below works on any JDK from 8 up. The code I wrote uses a record and a pattern-matching instanceof, which need 16, and I compiled with --release 21.
I didn't open either IDE for this article. The IDE steps come from the vendors' documentation, and the versions come from their release announcements. I also couldn't reach Maven Central from the machine I built on, so there's no pom.xml I've run; the build I did run is plain javac, and then Gradle in offline mode. Every block of console output below came from the code shown, on the machine in the table.
You should already know what a time zone is in everyday terms, and it helps to have read the LocalDate, LocalDateTime and ZonedDateTime article first. This one picks up where its Instant-is-the-hub rule leaves off. The Scan type below is a record; if you want the reasoning behind making it immutable, there's an article on that too.
Spin up the parcel tracker project
I'm using the terminal as the primary path this time, then Gradle, then the IDEs.
From a terminal.
mkdir -p parcel-tracker/src/parcels parcel-tracker/out
cd parcel-tracker
With Gradle. Add these two files in the project root. I ran this with gradle --offline and it worked, because there are no dependencies to download.
settings.gradle.kts
rootProject.name = "parcel-tracker"
build.gradle.kts
plugins {
application
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
// Keep sources in src/parcels/*.java instead of Gradle's src/main/java layout.
sourceSets {
main {
java.setSrcDirs(listOf("src"))
}
}
application {
mainClass.set("parcels.Main")
}
tasks.register<JavaExec>("checks") {
classpath = sourceSets["main"].runtimeClasspath
mainClass.set("parcels.Checks")
}
Then gradle run runs the demo and gradle checks runs the checks. If you'd rather use the standard Gradle layout, move the package folder to src/main/java/parcels/ and delete the sourceSets block.
In IntelliJ IDEA. File → New → Project. Choose Java, build system IntelliJ (or Gradle if you added the files above), JDK 21, untick "Add sample code". Right-click src → New → Package → parcels. Add each class below under that package. To test the zone behaviour later, open Run → Edit Configurations, and put -Duser.timezone=Europe/Berlin in "VM options".
In Eclipse. File → New → Java Project, name it parcel-tracker, JRE 21. Right-click src → New → Package → parcels. For the zone experiments use Run → Run Configurations → your class → Arguments tab → "VM arguments".
With Maven. I haven't run this one. A standard pom.xml with maven.compiler.release set to 21 and the sources under src/main/java/parcels/ is all it needs; there's no dependency.
The tree once you're done:
parcel-tracker/
├── build.gradle.kts (Gradle path only)
├── settings.gradle.kts (Gradle path only)
├── out/ (compiled classes land here for the javac path)
└── src/
└── parcels/
├── Checks.java
├── LegacyScan.java
├── Main.java
├── Scan.java
└── TimeBridge.java
The compile and run commands for the plain javac path:
javac --release 21 -d out src/parcels/*.java
java -cp out parcels.Main
How to convert Date, Instant and LocalDateTime in Java, step by step
First, the vocabulary, because the names mislead. java.util.Date isn't a date. It's one number, milliseconds since 1970-01-01T00:00:00Z, wrapped in a class, with no calendar and no zone inside. java.time.Instant is the same idea done properly: seconds plus nanoseconds since the same moment. LocalDateTime is a calendar-and-clock reading with no zone, so on its own it doesn't point at any moment. The conversions that go wrong are the ones where a zone has to be invented to turn one of those into another. Every method in the bridge below takes the zone as a parameter for that reason.
Step 1: Describe the old record and the new one
Goal: have both shapes of a scan in code so that conversions have something to start from and land on.
LegacyScan is what the warehouse system gives us. Its fields are public on purpose; the old code looked like that and the point is the types.
src/parcels/LegacyScan.java
package parcels;
import java.sql.Timestamp;
import java.util.Calendar;
import java.util.Date;
/**
* What the old warehouse system hands us. Every field is a different way of
* saying "a point in time", and none of them agree on what a zone is.
*/
public class LegacyScan {
public String parcelId;
public Date scannedAt; // java.util.Date: epoch millis inside
public Timestamp loggedAt; // java.sql.Timestamp: a Date with nanoseconds
public java.sql.Date shipDate; // java.sql.Date: a calendar day, stored as a Date
public Calendar receivedAt; // GregorianCalendar with its own zone
public long scannedAtMillis; // epoch millis in a JSON feed
public LegacyScan(String parcelId, Date scannedAt, Timestamp loggedAt,
java.sql.Date shipDate, Calendar receivedAt, long scannedAtMillis) {
this.parcelId = parcelId;
this.scannedAt = scannedAt;
this.loggedAt = loggedAt;
this.shipDate = shipDate;
this.receivedAt = receivedAt;
this.scannedAtMillis = scannedAtMillis;
}
}
src/parcels/Scan.java
package parcels;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Objects;
/**
* The modern side of the tracker: one exact moment, plus the zone of the depot
* that scanned the parcel (used only to show the time the way the depot saw it).
*/
public record Scan(String parcelId, Instant scannedAt, ZoneId depotZone) {
public Scan {
Objects.requireNonNull(parcelId, "parcelId");
Objects.requireNonNull(scannedAt, "scannedAt");
Objects.requireNonNull(depotZone, "depotZone");
}
}
Scan stores an Instant and a ZoneId. The zone is the depot's, and it only controls how we show the time to a human. It never changes which moment the scan is.
Checkpoint: javac --release 21 -d out src/parcels/LegacyScan.java src/parcels/Scan.java prints nothing.
Step 2: Write the bridge
Goal: put every conversion in one class, so the zone decisions are visible in one file.
Two rules shape it. The Instant is the hub, so there's no direct Date-to-LocalDateTime method, only Date to Instant to LocalDateTime. And any method that produces a calendar reading takes a ZoneId argument, so nothing here reads the machine's default zone.
src/parcels/TimeBridge.java
package parcels;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
/**
* Every conversion the tracker needs, in one place. Rule: the Instant is the
* hub. Anything that needs a calendar reading takes the ZoneId as a parameter,
* so nothing here depends on the machine's default zone.
*/
public final class TimeBridge {
private TimeBridge() {
}
// ---- legacy -> Instant ------------------------------------------------
/** Works for java.util.Date and its subclasses, java.sql.Date and java.sql.Time included. */
public static Instant fromDate(Date date) {
return Instant.ofEpochMilli(date.getTime());
}
/** Timestamp keeps nanoseconds, so use its own toInstant(). */
public static Instant fromTimestamp(Timestamp timestamp) {
return timestamp.toInstant();
}
public static Instant fromCalendar(Calendar calendar) {
return calendar.toInstant();
}
public static Instant fromEpochMillis(long millis) {
return Instant.ofEpochMilli(millis);
}
// ---- Instant -> legacy ------------------------------------------------
public static Date toDate(Instant instant) {
return Date.from(instant);
}
public static Timestamp toTimestamp(Instant instant) {
return Timestamp.from(instant);
}
public static long toEpochMillis(Instant instant) {
return instant.toEpochMilli();
}
// ---- Instant <-> local readings (the zone is always an argument) -------
public static LocalDateTime toLocalDateTime(Instant instant, ZoneId zone) {
return LocalDateTime.ofInstant(instant, zone);
}
public static Instant toInstant(LocalDateTime local, ZoneId zone) {
return local.atZone(zone).toInstant();
}
public static LocalDate toLocalDate(Instant instant, ZoneId zone) {
return instant.atZone(zone).toLocalDate();
}
/** A java.sql.Date is a calendar day. Turn it back into one using the zone the day was written in. */
public static LocalDate fromSqlDate(java.sql.Date date, ZoneId zoneItWasWrittenIn) {
return Instant.ofEpochMilli(date.getTime()).atZone(zoneItWasWrittenIn).toLocalDate();
}
/** The other direction: midnight of that day in the zone you name, as a java.sql.Date. */
public static java.sql.Date toSqlDate(LocalDate day, ZoneId zone) {
return new java.sql.Date(day.atStartOfDay(zone).toInstant().toEpochMilli());
}
// ---- Calendar <-> ZonedDateTime ----------------------------------------
public static ZonedDateTime fromCalendarZoned(Calendar calendar) {
if (calendar instanceof GregorianCalendar gregorian) {
return gregorian.toZonedDateTime();
}
return ZonedDateTime.ofInstant(calendar.toInstant(), calendar.getTimeZone().toZoneId());
}
public static GregorianCalendar toCalendar(ZonedDateTime zoned) {
return GregorianCalendar.from(zoned);
}
// ---- OffsetDateTime ------------------------------------------------------
public static OffsetDateTime toOffsetDateTime(Instant instant, ZoneOffset offset) {
return instant.atOffset(offset);
}
// ---- the whole feed record -----------------------------------------------
public static Scan toScan(LegacyScan legacy, ZoneId depotZone) {
return new Scan(legacy.parcelId, fromDate(legacy.scannedAt), depotZone);
}
}
A few lines deserve a reason.
fromDate uses Instant.ofEpochMilli(date.getTime()) and not date.toInstant(). For a plain java.util.Date they're the same. For java.sql.Date and java.sql.Time, toInstant() throws UnsupportedOperationException (the exact failure is in the troubleshooting section), and a feed that mixes the types will hit it eventually. getTime() works on all of them.
fromTimestamp is the opposite: use Timestamp.toInstant(), because getTime() returns milliseconds and drops the nanoseconds. A timestamp column with microsecond precision loses its last digits otherwise.
toLocalDateTime(Instant, ZoneId) is the answer to "convert Date to LocalDateTime in Java". The ZoneId argument is the part most tutorials hide behind ZoneId.systemDefault().
fromSqlDate and toSqlDate treat a java.sql.Date as what it really is: a calendar day stored as midnight in some zone. You have to say which zone.
Checkpoint: javac --release 21 -d out src/parcels/*.java prints nothing. If you get reference to Date is ambiguous somewhere, you've imported both java.sql.* and java.util.* with wildcards; I did that in my scratch file and it cost me a minute. Import java.util.Date explicitly.
Step 3: Run a legacy scan through it
Goal: build a LegacyScan the way the old system would, convert it, and print every reading.
src/parcels/Main.java
package parcels;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
public class Main {
public static void main(String[] args) {
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZoneId tokyo = ZoneId.of("Asia/Tokyo");
// The warehouse scanned this parcel at 2026-10-25T22:30:00Z.
Instant moment = Instant.parse("2026-10-25T22:30:00.123456789Z");
Date date = TimeBridge.toDate(moment);
Timestamp stamp = TimeBridge.toTimestamp(moment);
java.sql.Date shipDay = TimeBridge.toSqlDate(LocalDate.of(2026, 10, 26), berlin);
Calendar received = GregorianCalendar.from(moment.atZone(tokyo));
LegacyScan legacy = new LegacyScan("PK-4711", date, stamp, shipDay, received,
moment.toEpochMilli());
System.out.println("JVM default zone : " + ZoneId.systemDefault());
System.out.println();
System.out.println("-- what the legacy objects print (they use the JVM default zone)");
System.out.println("Date.toString() : " + legacy.scannedAt);
System.out.println("Timestamp.toString() : " + legacy.loggedAt);
System.out.println("sql.Date.toString() : " + legacy.shipDate);
System.out.println();
System.out.println("-- the same objects, converted with an explicit zone");
Scan scan = TimeBridge.toScan(legacy, berlin);
System.out.println("Scan : " + scan);
System.out.println("Berlin reading : " + TimeBridge.toLocalDateTime(scan.scannedAt(), berlin));
System.out.println("Tokyo reading : " + TimeBridge.toLocalDateTime(scan.scannedAt(), tokyo));
System.out.println("Ship day (Berlin) : " + TimeBridge.fromSqlDate(legacy.shipDate, berlin));
System.out.println("Calendar as zoned : " + TimeBridge.fromCalendarZoned(legacy.receivedAt));
System.out.println("Timestamp as Instant : " + TimeBridge.fromTimestamp(legacy.loggedAt));
System.out.println("Date as Instant : " + TimeBridge.fromDate(legacy.scannedAt));
System.out.println("Epoch millis : " + TimeBridge.fromEpochMillis(legacy.scannedAtMillis));
System.out.println();
System.out.println("-- the two conversions that quietly use the default zone");
System.out.println("Timestamp.toLocalDateTime() : " + legacy.loggedAt.toLocalDateTime());
System.out.println("sql.Date.toLocalDate() : " + legacy.shipDate.toLocalDate());
ZonedDateTime check = TimeBridge.fromCalendarZoned(legacy.receivedAt);
System.out.println("TimeZone of the Calendar : " + legacy.receivedAt.getTimeZone().getID()
+ " / zoned says " + check.getZone());
}
}
The first two blocks of output print the legacy objects with their own toString(). That's the part I wanted you to see misbehave. The rest goes through TimeBridge with an explicit zone.
javac --release 21 -d out src/parcels/*.java
java -Duser.timezone=Europe/Berlin -cp out parcels.Main
If you're using Gradle, gradle -q run does the same, but pass the zone with applicationDefaultJvmArgs = listOf("-Duser.timezone=Europe/Berlin") inside the application { } block (I didn't run that variant), or leave it to use the machine's own.
Step 4: Run it under three different machine zones
Goal: prove that the converted lines don't move when the JVM's zone does. -Duser.timezone changes the default zone for one run, which is how I pretended to be in three places.
See it working
On Berlin:
JVM default zone : Europe/Berlin
-- what the legacy objects print (they use the JVM default zone)
Date.toString() : Sun Oct 25 23:30:00 CET 2026
Timestamp.toString() : 2026-10-25 23:30:00.123456789
sql.Date.toString() : 2026-10-26
-- the same objects, converted with an explicit zone
Scan : Scan[parcelId=PK-4711, scannedAt=2026-10-25T22:30:00.123Z, depotZone=Europe/Berlin]
Berlin reading : 2026-10-25T23:30:00.123
Tokyo reading : 2026-10-26T07:30:00.123
Ship day (Berlin) : 2026-10-26
Calendar as zoned : 2026-10-26T07:30:00.123+09:00[Asia/Tokyo]
Timestamp as Instant : 2026-10-25T22:30:00.123456789Z
Date as Instant : 2026-10-25T22:30:00.123Z
Epoch millis : 2026-10-25T22:30:00.123Z
-- the two conversions that quietly use the default zone
Timestamp.toLocalDateTime() : 2026-10-25T23:30:00.123456789
sql.Date.toLocalDate() : 2026-10-26
TimeZone of the Calendar : Asia/Tokyo / zoned says Asia/Tokyo
Now the same command with -Duser.timezone=UTC, and then with -Duser.timezone=America/Los_Angeles. The Scan, Berlin reading, Tokyo reading, Ship day (Berlin), Calendar as zoned, Timestamp as Instant, Date as Instant and Epoch millis lines came out identical in all three runs. Only the legacy toString() lines and the two "default zone" lines changed:
=== UTC
Date.toString() : Sun Oct 25 22:30:00 UTC 2026
Timestamp.toString() : 2026-10-25 22:30:00.123456789
sql.Date.toString() : 2026-10-25
Timestamp.toLocalDateTime() : 2026-10-25T22:30:00.123456789
sql.Date.toLocalDate() : 2026-10-25
=== America/Los_Angeles
Date.toString() : Sun Oct 25 15:30:00 PDT 2026
Timestamp.toString() : 2026-10-25 15:30:00.123456789
sql.Date.toString() : 2026-10-25
Timestamp.toLocalDateTime() : 2026-10-25T15:30:00.123456789
sql.Date.toLocalDate() : 2026-10-25
Here's how to read the Berlin run. The scan happened at 22:30 UTC. In Berlin that's 23:30 on the 25th; in Tokyo it's 07:30 on the 26th. Date as Instant shows .123Z and Timestamp as Instant shows .123456789Z: the Date was made from the same Instant and kept only the milliseconds. That's expected, and the checks below pin it.
The two lines under "quietly use the default zone" are the ones to be afraid of. Timestamp.toLocalDateTime() and java.sql.Date.toLocalDate() look innocent and don't take a zone. They read the JVM default. In Berlin they printed 23:30 and 2026-10-26; in Los Angeles 15:30 and 2026-10-25. If your batch job runs on a server in a different region from your laptop, those two methods disagree and nothing throws.
Lock it in with checks
JUnit would be the normal choice. Without Maven Central I couldn't resolve it, so the checks are a plain main that prints PASS or FAIL and exits non-zero on any failure, the same approach as in the earlier java.time article. The expected strings are the part to keep. When you can, move them into JUnit 5 tests (that article isn't published yet).
src/parcels/Checks.java
package parcels;
import java.sql.Timestamp;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;
import java.util.function.Supplier;
public class Checks {
static int passed;
static int failed;
static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
static final ZoneId TOKYO = ZoneId.of("Asia/Tokyo");
static final Instant MOMENT = Instant.parse("2026-10-25T22:30:00.123456789Z");
public static void main(String[] args) {
// Date <-> Instant
Date date = TimeBridge.toDate(MOMENT);
eq("Date keeps milliseconds only", TimeBridge.fromDate(date).toString(), "2026-10-25T22:30:00.123Z");
eq("Date round trip is stable", String.valueOf(TimeBridge.toDate(TimeBridge.fromDate(date)).equals(date)), "true");
eq("Instant -> Date -> Instant loses sub-millisecond digits",
String.valueOf(TimeBridge.fromDate(date).equals(MOMENT)), "false");
// java.sql types
java.sql.Date sqlDate = new java.sql.Date(date.getTime());
throwsType("java.sql.Date.toInstant() is unsupported", UnsupportedOperationException.class, sqlDate::toInstant);
throwsType("java.sql.Time.toInstant() is unsupported", UnsupportedOperationException.class,
() -> new java.sql.Time(date.getTime()).toInstant());
eq("fromDate works on java.sql.Date anyway", TimeBridge.fromDate(sqlDate).toString(), "2026-10-25T22:30:00.123Z");
Timestamp ts = TimeBridge.toTimestamp(MOMENT);
eq("Timestamp keeps nanoseconds", TimeBridge.fromTimestamp(ts).toString(), "2026-10-25T22:30:00.123456789Z");
// Explicit-zone conversions
eq("Berlin reading", TimeBridge.toLocalDateTime(MOMENT, BERLIN).toString(), "2026-10-25T23:30:00.123456789");
eq("Tokyo reading", TimeBridge.toLocalDateTime(MOMENT, TOKYO).toString(), "2026-10-26T07:30:00.123456789");
eq("LocalDateTime back to the same Instant (Berlin)",
TimeBridge.toInstant(TimeBridge.toLocalDateTime(MOMENT, BERLIN), BERLIN).toString(), MOMENT.toString());
eq("UTC offset form is fixed", TimeBridge.toOffsetDateTime(MOMENT, ZoneOffset.ofHours(2)).toString(),
"2026-10-26T00:30:00.123456789+02:00");
// Local times that DST breaks
eq("overlap local time maps to the earlier instant",
TimeBridge.toInstant(LocalDateTime.of(2026, 10, 25, 2, 30), BERLIN).toString(), "2026-10-25T00:30:00Z");
eq("gap local time is pushed forward an hour",
TimeBridge.toInstant(LocalDateTime.of(2026, 3, 29, 2, 30), BERLIN).toString(), "2026-03-29T01:30:00Z");
// The default-zone conversions
TimeZone saved = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
String utcReading = ts.toLocalDateTime().toString();
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo"));
String tokyoReading = ts.toLocalDateTime().toString();
eq("Timestamp.toLocalDateTime() follows the default zone (UTC)", utcReading, "2026-10-25T22:30:00.123456789");
eq("Timestamp.toLocalDateTime() follows the default zone (Tokyo)", tokyoReading, "2026-10-26T07:30:00.123456789");
} finally {
TimeZone.setDefault(saved);
}
// java.sql.Date as a calendar day
java.sql.Date berlinDay = TimeBridge.toSqlDate(LocalDate.of(2026, 10, 26), BERLIN);
eq("sql.Date midnight Berlin (CET, +01:00) is 23:00Z the day before", TimeBridge.fromDate(berlinDay).toString(), "2026-10-25T23:00:00Z");
eq("fromSqlDate recovers the Berlin day", TimeBridge.fromSqlDate(berlinDay, BERLIN).toString(), "2026-10-26");
eq("reading it in UTC gives the day before", TimeBridge.fromSqlDate(berlinDay, ZoneId.of("UTC")).toString(), "2026-10-25");
// Calendar
GregorianCalendar tokyoCal = TimeBridge.toCalendar(MOMENT.atZone(TOKYO));
ZonedDateTime back = TimeBridge.fromCalendarZoned(tokyoCal);
eq("Calendar keeps the zone", back.getZone().getId(), "Asia/Tokyo");
eq("Calendar keeps the instant to the millisecond", back.toInstant().toString(), "2026-10-25T22:30:00.123Z");
Calendar plain = Calendar.getInstance(TimeZone.getTimeZone("Europe/Berlin"));
plain.setTimeInMillis(date.getTime());
eq("Calendar.getInstance in Berlin converts", TimeBridge.fromCalendarZoned(plain).toString(),
"2026-10-25T23:30:00.123+01:00[Europe/Berlin]");
// epoch
eq("epoch millis round trip", String.valueOf(TimeBridge.toEpochMillis(TimeBridge.fromEpochMillis(1_793_000_000_123L))),
"1793000000123");
throwsType("Instant.MAX does not fit in epoch millis", ArithmeticException.class, () -> Instant.MAX.toEpochMilli());
throwsType("Instant.MAX does not fit in a Date", IllegalArgumentException.class, () -> Date.from(Instant.MAX));
// Legacy -> Scan
LegacyScan legacy = new LegacyScan("PK-1", date, ts, berlinDay, tokyoCal, date.getTime());
eq("toScan uses the Date's instant", TimeBridge.toScan(legacy, BERLIN).scannedAt().toString(), "2026-10-25T22:30:00.123Z");
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) {
System.exit(1);
}
}
static void eq(String name, String actual, String expected) {
if (actual.equals(expected)) {
passed++;
System.out.println("PASS " + name);
} else {
failed++;
System.out.println("FAIL " + name + "\n expected: " + expected + "\n actual: " + actual);
}
}
static void throwsType(String name, Class<? extends Exception> type, Supplier<?> action) {
try {
action.get();
failed++;
System.out.println("FAIL " + name + " (nothing thrown)");
} catch (Exception e) {
if (type.isInstance(e)) {
passed++;
System.out.println("PASS " + name);
} else {
failed++;
System.out.println("FAIL " + name + " (got " + e + ")");
}
}
}
}
javac --release 21 -d out src/parcels/*.java
java -cp out parcels.Checks
PASS Date keeps milliseconds only
PASS Date round trip is stable
PASS Instant -> Date -> Instant loses sub-millisecond digits
PASS java.sql.Date.toInstant() is unsupported
PASS java.sql.Time.toInstant() is unsupported
PASS fromDate works on java.sql.Date anyway
PASS Timestamp keeps nanoseconds
PASS Berlin reading
PASS Tokyo reading
PASS LocalDateTime back to the same Instant (Berlin)
PASS UTC offset form is fixed
PASS overlap local time maps to the earlier instant
PASS gap local time is pushed forward an hour
PASS Timestamp.toLocalDateTime() follows the default zone (UTC)
PASS Timestamp.toLocalDateTime() follows the default zone (Tokyo)
PASS sql.Date midnight Berlin (CET, +01:00) is 23:00Z the day before
PASS fromSqlDate recovers the Berlin day
PASS reading it in UTC gives the day before
PASS Calendar keeps the zone
PASS Calendar keeps the instant to the millisecond
PASS Calendar.getInstance in Berlin converts
PASS epoch millis round trip
PASS Instant.MAX does not fit in epoch millis
PASS Instant.MAX does not fit in a Date
PASS toScan uses the Date's instant
25 passed, 0 failed
My first run of this file had one failure, and it was my arithmetic. I'd written that midnight on 26 October in Berlin is 22:00Z the day before, which is true in summer time. Berlin's clocks went back on the 25th, so by the 26th the offset is +01:00 and midnight is 23:00Z. The check caught my own wrong expectation, which is a good use for it, and I fixed the expected value and the test name.
The Gradle path gave the same result: gradle --offline -q checks ended with 25 passed, 0 failed.
Why the Instant is the hub
A Date, a Timestamp, a Calendar and a long of epoch milliseconds are four spellings of the same fact: how many milliseconds (or, for Timestamp, nanoseconds) after the epoch. Instant is the type in java.time that says only that. Converting between any two of them through an Instant is lossless, apart from precision: Date stops at milliseconds, Instant and Timestamp go to nanoseconds.
A LocalDateTime or LocalDate says something else: what a wall clock or calendar showed. To get from the first family to the second, someone has to name the place, which is the ZoneId. To get back, the same applies, and then two local times will refuse to cooperate: one that doesn't exist (spring) and one that happens twice (autumn). LocalDateTime.atZone doesn't throw for either. The checks show it picks the earlier instant for the overlap and shifts a gap time forward an hour. For human input you'd want to reject those instead; the earlier article has a parser that does.
Calendar is the odd one. A GregorianCalendar carries its own TimeZone, so the bridge keeps it: toZonedDateTime() and GregorianCalendar.from(zoned) are a faithful round trip, zone included. Other Calendar subclasses don't have toZonedDateTime(), so the fallback builds one from toInstant() and getTimeZone().toZoneId().
If you want to know why the zone argument matters so much when ZonedDateTime does the shifting for you, the time zones tutorial (not published yet) is where I'll go through daylight saving rules properly, and the formatting one will cover turning these values into strings.
Traps I hit on purpose
I forced each of these after the checks went green, to get the exact text.
java.lang.UnsupportedOperationException (no message)
Cause: toInstant() on a java.sql.Date or java.sql.Time. Those classes extend java.util.Date but deliberately don't support the method, because a SQL date has no time of day and a SQL time has no date. Fix: Instant.ofEpochMilli(d.getTime()), or better, convert the SQL date with toLocalDate() / LocalDate and the zone you mean. This is the one that bites when a method takes java.util.Date and someone passes a java.sql.Date into it.
error: cannot find symbol symbol: method toLocalDateTime() location: variable d of type Date
Cause: Date has no such method, and that's the first thing people try. The same message appears for Instant.now().toLocalDateTime(). Fix: date.toInstant().atZone(zone).toLocalDateTime(), or TimeBridge.toLocalDateTime(TimeBridge.fromDate(date), zone). (Only Timestamp has toLocalDateTime(), and that one reads the default zone.)
error: incompatible types: LocalDateTime cannot be converted to Instant
Cause: Date.from(LocalDateTime.now()). Date.from takes an Instant only. Fix: give the LocalDateTime a zone first: Date.from(local.atZone(zone).toInstant()).
error: method toInstant in interface ChronoLocalDateTime<D> cannot be applied to given types; required: ZoneOffset; found: no arguments
Cause: timestamp.toLocalDateTime().toInstant(). That round trip silently goes through the default zone on the way in and then asks you for an offset on the way out. Fix: call timestamp.toInstant() and skip LocalDateTime altogether.
java.lang.ArithmeticException: long overflow and java.lang.IllegalArgumentException: java.lang.ArithmeticException: long overflow
Cause: Instant.MAX.toEpochMilli() and Date.from(Instant.MAX). An Instant covers about ±1 billion years; a long of milliseconds doesn't. If a "never expires" sentinel in your data is Instant.MAX, converting it to a Date blows up. Fix: pick a sentinel date such as 9999-12-31 and clamp before converting.
IllegalArgumentException: Timestamp format must be yyyy-mm-dd hh:mm:ss[.fffffffff]
Cause: Timestamp.valueOf("2026-10-25T22:30:00"). It wants a space where ISO text has a T. java.sql.Date.valueOf("2026-10-25 22:30") throws a bare IllegalArgumentException for the same reason. Fix: parse ISO text with LocalDateTime.parse or Instant.parse and convert from there.
Nanoseconds that vanish. Instant.parse("2026-10-25T22:30:00.123456789Z") goes through Date and comes back as .123Z. The check "Instant -> Date -> Instant loses sub-millisecond digits" asserts that on purpose. If you store microsecond timestamps, don't route them through Date.
Dates before October 1582. Date and GregorianCalendar switch from the Julian calendar to the Gregorian one in October 1582; java.time uses the Gregorian rules everywhere, backwards in time. For a GregorianCalendar set to 1 March 1500 the fields said 1500-3-1 and toZonedDateTime() printed 1500-03-11T12:00Z[UTC]. Same instant, a different label ten days apart. Only historians and some genealogy data hit this, but when you do, nothing crashes.
Date.toString() as a log format. It prints in the default zone with a three-letter abbreviation (CET, PDT) that's ambiguous and not parseable back reliably. Log instant.toString() instead.
new Date(126, 9, 25) compiles with a deprecation warning and means 25 October 2026: the year is offset by 1900 and the month starts at 0. It printed Sun Oct 25 00:00:00 UTC 2026 in UTC and ... PDT 2026 in Los Angeles, a different instant each time. Don't write new code with it, and when you find it in old code, convert it to LocalDate.of(2026, 10, 25) plus a zone.
Before this touches a database
- Prefer
java.timestraight through the driver. With any JDBC 4.2 driver you can callresultSet.getObject("col", Instant.class)orOffsetDateTime.class, andpreparedStatement.setObject(i, instant). That skipsTimestampentirely. I couldn't run a driver here, so check your driver's documentation for which types it supports. - If you must use
Timestamp, write and read it withTimestamp.from(instant)andtimestamp.toInstant(), never throughvalueOf(LocalDateTime)ortoLocalDateTime(), for the default-zone reason above. java.sql.Datecolumns hold calendar days. Map them toLocalDate, notInstant. If your driver's session zone and your JVM's zone differ, day columns shift by one at midnight, which is the2026-10-25versus2026-10-26difference in the opening.- Set
-Duser.timezone=UTCon servers if you can't fix every legacy call at once. That makes the quiet default-zone conversions agree across machines. It's a stopgap, not a cure. - Version note: nothing in the conversions changed between Java 8 and 21. The only change you may notice is the one I didn't test:
java.sql.Date.toLocalDate()andTimestamp.toLocalDateTime()still use the default zone in 21.
What you did
- Treated
Instantas the hub and put every conversion in one class. - Passed a
ZoneIdinto every method that makes a calendar reading. - Avoided
Timestamp.toLocalDateTime()andjava.sql.Date.toLocalDate()unless the default zone is the one you mean. - Used
getTime()forjava.sqldate types andtoInstant()forTimestamp. - Proved the whole thing with 25 checks, and ran the demo under three machine zones to see the output hold still.
Questions about converting Date and LocalDateTime
How do I convert java.util.Date to LocalDateTime in Java?
Go through Instant and pick a zone: date.toInstant().atZone(zone).toLocalDateTime(), or LocalDateTime.ofInstant(date.toInstant(), zone). Use Instant.ofEpochMilli(date.getTime()) instead of toInstant() if the value could be a java.sql.Date. The zone is the choice that decides the answer, so pass it in rather than reaching for ZoneId.systemDefault().
How do I convert LocalDateTime to java.util.Date?Date.from(local.atZone(zone).toInstant()). The LocalDateTime has to become an Instant first, which needs a zone. For a time that falls in a daylight saving gap or overlap, check zone.getRules().getValidOffsets(local) first if you can't accept a silent shift.
Is java.sql.Timestamp the same as java.util.Date?
It extends Date, adds nanoseconds, and is meant for database columns. Convert it with timestamp.toInstant() to keep the nanoseconds, and with Timestamp.from(instant) on the way back. Avoid its toLocalDateTime() unless the JVM default zone is what you want.
Why does my converted date change when I run the code on another server?
Something in the chain used the JVM's default zone: Timestamp.toLocalDateTime(), java.sql.Date.toLocalDate(), Date.toString(), LocalDateTime.now(), or ZoneId.systemDefault(). Pass a ZoneId or Clock explicitly instead.
Can I convert a Calendar to ZonedDateTime without losing the time zone?
Yes for GregorianCalendar: calendar.toZonedDateTime() keeps the calendar's zone, and GregorianCalendar.from(zoned) goes back. Any other Calendar needs ZonedDateTime.ofInstant(calendar.toInstant(), calendar.getTimeZone().toZoneId()).