> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Convert Date, Instant, LocalDateTime in Java 21
- URL: https://www.ggorantala.dev/how-to-convert-date-instant-localdatetime-java/
- Published: 2026-08-19T03:19:00.000Z
- Updated: 2026-10-31T04:28:03.000Z
- Description: Convert java.util.Date, Timestamp, Calendar and epoch millis to Instant and LocalDateTime in Java 21, with zone-safe code and checks on a parcel tracker.
- Author: Gopi Gorantala
- Tags: how-to-tutorials

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:

```log
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:

```log
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.

```log
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](https://ggorantala.dev/how-to-work-localdate-localdatetime-zoneddatetime-java/?ref=ggorantala.dev) first. This one picks up where its `Instant`\-is-the-hub rule leaves off. The `Scan` type below is a [record](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev); if you want the reasoning behind making it immutable, there's [an article on that too](https://ggorantala.dev/how-to-create-immutable-class-java/?ref=ggorantala.dev).

## 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.**

```bash
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`

```kotlin
rootProject.name = "parcel-tracker"

```

`build.gradle.kts`

```kotlin
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:

```log
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:

```bash
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`

```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`

```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`

```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`

```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.

```bash
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:

```log
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:

```log
=== 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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) (that article isn't published yet).

`src/parcels/Checks.java`

```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 + ")");
            }
        }
    }
}

```

```bash
javac --release 21 -d out src/parcels/*.java
java -cp out parcels.Checks

```

```log
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](https://ggorantala.dev/how-to-work-localdate-localdatetime-zoneddatetime-java/?ref=ggorantala.dev) 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](https://ggorantala.dev/how-to-handle-time-zones-java/?ref=ggorantala.dev) (not published yet) is where I'll go through daylight saving rules properly, and [the formatting one](https://ggorantala.dev/how-to-format-parse-dates-datetimeformatter-java/?ref=ggorantala.dev) 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.time` straight through the driver. With any JDBC 4.2 driver you can call `resultSet.getObject("col", Instant.class)` or `OffsetDateTime.class`, and `preparedStatement.setObject(i, instant)`. That skips `Timestamp` entirely. 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 with `Timestamp.from(instant)` and `timestamp.toInstant()`, never through `valueOf(LocalDateTime)` or `toLocalDateTime()`, for the default-zone reason above.
- `java.sql.Date` columns hold calendar days. Map them to `LocalDate`, not `Instant`. If your driver's session zone and your JVM's zone differ, day columns shift by one at midnight, which is the `2026-10-25` versus `2026-10-26` difference in the opening.
- Set `-Duser.timezone=UTC` on 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()` and `Timestamp.toLocalDateTime()` still use the default zone in 21.

## What you did

- Treated `Instant` as the hub and put every conversion in one class.
- Passed a `ZoneId` into every method that makes a calendar reading.
- Avoided `Timestamp.toLocalDateTime()` and `java.sql.Date.toLocalDate()` unless the default zone is the one you mean.
- Used `getTime()` for `java.sql` date types and `toInstant()` for `Timestamp`.
- 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())`.