Date & Time API

Interoperating with Legacy Dates

15 min Lesson 9 of 13

Interoperating with Legacy Dates

Real-world Java code rarely starts from a blank slate. You will encounter APIs, libraries, databases, and serialisation layers that speak the old java.util.Date and java.util.Calendar dialect. The java.time package was designed with explicit bridge methods so that migration can happen incrementally — you do not have to rewrite everything at once.

This lesson covers every direction of conversion, the gotchas hiding in time-zone semantics, and the practical pattern for deciding when to convert versus when to leave the old type in place.

A Quick Map of the Old World

Before you can bridge the two worlds you need to know what maps to what:

  • java.util.Date — a millisecond timestamp since the Unix epoch (1970-01-01T00:00:00Z). Despite the name it has no concept of a calendar date; it is really a wrapper around a long.
  • java.util.Calendar / GregorianCalendar — a mutable, time-zone-aware representation that combines a date, a time, and a TimeZone.
  • java.sql.Date, java.sql.Time, java.sql.Timestamp — JDBC types that extend java.util.Date; they carry extra precision but the same epoch-millis foundation.
All three legacy types store UTC milliseconds internally. Any "local" interpretation is performed at display time by layering a TimeZone on top. java.time makes this contract explicit rather than hiding it.

java.util.Date to java.time

Date gained a single bridge method: toInstant(). Because Date is nothing more than a millisecond counter, the natural target is Instant. From there you project into any zone-aware or local type you need.

import java.time.*; import java.util.Date; Date legacyDate = new Date(); // some Date from an old API // Step 1: land on Instant (lossless — same millis, now in java.time) Instant instant = legacyDate.toInstant(); // Step 2: project into a time zone to get a human-readable date/time ZonedDateTime zdt = instant.atZone(ZoneId.of("America/New_York")); // Step 3: strip the zone if you only care about local fields LocalDate date = zdt.toLocalDate(); LocalTime time = zdt.toLocalTime(); LocalDateTime ldt = zdt.toLocalDateTime(); System.out.println(instant); // 2024-03-15T14:30:00Z System.out.println(date); // 2024-03-15 System.out.println(time); // 10:30:00
Never pass ZoneId.systemDefault() without thinking. On a server the default zone is usually UTC or the OS zone, which changes between environments. Be explicit about the target zone; otherwise conversions silently produce different local dates on different machines.

java.time to java.util.Date

The reverse direction uses the static factory Date.from(Instant). If your source is a zone-aware type convert to Instant first.

// From Instant Instant instant = Instant.now(); Date legacyDate = Date.from(instant); // From ZonedDateTime ZonedDateTime zdt = ZonedDateTime.of(2024, 3, 15, 14, 30, 0, 0, ZoneId.of("UTC")); Date fromZdt = Date.from(zdt.toInstant()); // From LocalDateTime — YOU must decide the zone; there is no default LocalDateTime ldt = LocalDateTime.of(2024, 3, 15, 14, 30); ZoneId zone = ZoneId.of("Europe/Berlin"); Date fromLdt = Date.from(ldt.atZone(zone).toInstant());
Converting from LocalDate or LocalDateTime always requires a zone. These types intentionally have no time-zone information. If you omit the zone you are forced to choose one — make that choice visible in the code, not hidden in a helper method.

Calendar and GregorianCalendar

GregorianCalendar is the concrete class you almost always receive when working with Calendar. It has a direct conversion method.

import java.util.GregorianCalendar; import java.util.Calendar; import java.time.*; // Old API returns a Calendar Calendar cal = Calendar.getInstance(); // GregorianCalendar under the hood // Cast to GregorianCalendar and use the bridge method GregorianCalendar gcal = (GregorianCalendar) cal; ZonedDateTime zdt = gcal.toZonedDateTime(); System.out.println(zdt); // 2024-03-15T14:30:00+01:00[Europe/Berlin] // Reverse: create a GregorianCalendar from a ZonedDateTime GregorianCalendar back = GregorianCalendar.from(zdt);

The bridge preserves the original TimeZone exactly, so a Calendar in Asia/Tokyo becomes a ZonedDateTime with Asia/Tokyo — no silent zone shift.

What if the Calendar is not a GregorianCalendar? The JDK only ships one concrete Calendar subclass for non-Gregorian calendars: JapaneseImperialCalendar (used when the JVM locale is Japanese). For those cases extract the epoch millis manually: cal.getTimeInMillis(), then wrap in Instant.ofEpochMilli(...).

JDBC Types: java.sql.Date, Time, and Timestamp

JDBC types are the most common source of legacy dates in backend applications. Each has a direct toLocalXxx() bridge — notice these do not go through Instant because JDBC dates are explicitly zone-less at the protocol level.

import java.sql.*; import java.time.*; // --- java.sql.Date --- java.sql.Date sqlDate = java.sql.Date.valueOf("2024-03-15"); LocalDate localDate = sqlDate.toLocalDate(); // direct, no zone needed java.sql.Date backToSql = java.sql.Date.valueOf(localDate); // --- java.sql.Time --- java.sql.Time sqlTime = java.sql.Time.valueOf("14:30:00"); LocalTime localTime = sqlTime.toLocalTime(); java.sql.Time backToTime = java.sql.Time.valueOf(localTime); // --- java.sql.Timestamp --- java.sql.Timestamp ts = java.sql.Timestamp.valueOf("2024-03-15 14:30:00.123456"); LocalDateTime ldt = ts.toLocalDateTime(); // zone-less, sub-second precision preserved Instant instant = ts.toInstant(); // zone-aware path when you need UTC java.sql.Timestamp backToTs = java.sql.Timestamp.valueOf(ldt);
Prefer Timestamp.valueOf(LocalDateTime) over Timestamp.from(Instant) when writing to a database column typed as DATETIME (not TIMESTAMP WITH TIME ZONE). The valueOf path keeps the literal calendar fields; the from path applies the JVM default zone, which can silently shift values in MySQL and similar databases.

A Practical Migration Pattern

When you inherit a large codebase you cannot always rewrite every layer at once. A safe incremental strategy is:

  1. Convert at the boundary. Leave internal model classes and DTOs using java.time. Convert to/from legacy types only at the I/O boundary — the point where you call an old library or read from JDBC.
  2. Write a dedicated adapter method. Centralise the conversion logic. When you later upgrade the old library you change one place.
  3. Annotate with @SuppressWarnings("deprecated") sparingly. Keep legacy conversion code isolated so it is easy to delete once the old dependency is gone.
// Adapter class — one place to own all legacy conversions public final class DateBridge { private DateBridge() {} /** Convert a legacy Date from a third-party library into a LocalDateTime * using the application's canonical zone. */ public static LocalDateTime toLocalDateTime(Date legacy, ZoneId appZone) { return legacy.toInstant().atZone(appZone).toLocalDateTime(); } /** Convert a LocalDateTime back to Date for the same third-party library. */ public static Date fromLocalDateTime(LocalDateTime ldt, ZoneId appZone) { return Date.from(ldt.atZone(appZone).toInstant()); } }

Summary

Every legacy type has a clean bridge into java.time:

  • Date.toInstant() / Date.from(Instant) — the universal gateway.
  • GregorianCalendar.toZonedDateTime() / GregorianCalendar.from(ZonedDateTime) — preserves the original time zone.
  • java.sql.Date.toLocalDate(), Time.toLocalTime(), Timestamp.toLocalDateTime() — zone-less JDBC paths.

The golden rule is always be explicit about time zones during conversion. The errors that plagued the old API were almost all caused by implicit zone assumptions — java.time forces you to state those assumptions in code.