Convert Date to LocalDate in Java (and LocalDate to Date)

Convert Date to LocalDate in Java with LocalDate.ofInstant() and back with Date.from(), pick the right time zone and avoid the one-day-off bug.

One java.util.Date read in Asia/Kolkata gives March 14 and read in UTC gives March 13

To convert a java.util.Date to a LocalDate, we call LocalDate.ofInstant(date.toInstant(), zone), and to convert a LocalDate back to a Date, we call Date.from(localDate.atStartOfDay(zone).toInstant()). Both directions need a ZoneId, because a Date is a moment on the timeline and a LocalDate is a day on the calendar.

Both conversions belong at the boundary with code we cannot change, such as an old library, a JAXB class or a DAO that still returns Date. We convert once when the value comes in, work with LocalDate inside our code, and convert back only when the old API asks for a Date.

The following example reads a due date that an old library system returns as a Date and gives it back after adding a week.

Date fromLegacy = Date.from(Instant.parse("2026-03-13T20:00:00Z"));
ZoneId zone = ZoneId.of("Asia/Kolkata");
LocalDate dueDate = LocalDate.ofInstant(fromLegacy.toInstant(), zone);   // 2026-03-14
LocalDate extended = dueDate.plusWeeks(1);                               // 2026-03-21
Date toLegacy = Date.from(extended.atStartOfDay(zone).toInstant());
Instant sent = toLegacy.toInstant();                                     // 2026-03-20T18:30:00Z

Notice that 20:00 UTC on March 13 is already March 14 in India, so the zone decides the calendar day. After the zone choice, we convert in both directions, handle the java.sql.Date case, build a small adapter class and answer the questions developers ask after their first conversion.

1. Why the Conversion Needs a Time Zone

A java.util.Date stores one number, the milliseconds since 1970-01-01T00:00:00Z. Its name is misleading, because the value has no year, month or day until we pick a time zone. A LocalDate is the opposite, a year, a month and a day with no time and no zone.

One java.util.Date read in Asia/Kolkata gives March 14 and read in UTC gives March 13
The same Date gives a different LocalDate in each zone, which causes the classic one-day-off bug

The zone in the conversion must be the zone that created the Date, or the result is one day off near midnight. If an old API builds a Date for midnight on March 14 in India, the value is 2026-03-13T18:30Z, and reading it in UTC gives March 13.

Date midnightIndia = Date.from(Instant.parse("2026-03-13T18:30:00Z"));
LocalDate inIndia = LocalDate.ofInstant(midnightIndia.toInstant(), ZoneId.of("Asia/Kolkata"));   // 2026-03-14
LocalDate inUtc = LocalDate.ofInstant(midnightIndia.toInstant(), ZoneOffset.UTC);                // 2026-03-13

Many examples pass ZoneId.systemDefault(). That zone is correct only when the legacy code also used the JVM default zone, which is common in old code because Date.toString() and Calendar.getInstance() use it. On a server, the default zone is often UTC, so we pass the business zone as a named ZoneId and do not depend on the JVM time zone.

2. Convert Date to LocalDate

Java gives us two equal ways from a Date to a LocalDate. Both go through an Instant, because Date.toInstant(), added in Java 8, is the bridge between the old and the new classes.

2.1. With LocalDate.ofInstant() (Java 9 and Later)

The factory method LocalDate.ofInstant(Instant, ZoneId) takes the moment and the zone and returns the calendar date in that zone. It is the shortest form and the one we use on Java 9 and later.

Date legacy = new Date(1773426600000L);
LocalDate date = LocalDate.ofInstant(legacy.toInstant(), ZoneId.of("Europe/Paris"));   // 2026-03-13

2.2. With toInstant().atZone() on Java 8

Java 8 has no LocalDate.ofInstant(), so we attach the zone to the instant with atZone() and take the date part of the resulting ZonedDateTime. The old form with Instant.ofEpochMilli(date.getTime()) gives the same result in more words.

Date legacy = new Date(1773426600000L);
ZoneId paris = ZoneId.of("Europe/Paris");
LocalDate viaZoned = legacy.toInstant().atZone(paris).toLocalDate();               // 2026-03-13
LocalDate viaMillis = Instant.ofEpochMilli(legacy.getTime()).atZone(paris).toLocalDate();   // 2026-03-13

2.3. When the Date Is a java.sql.Date

A JDBC driver returns a java.sql.Date, which is a subclass of java.util.Date, so a legacy method declared to return Date may hand us one. Its toInstant() method throws UnsupportedOperationException, because the class represents a date without a time.

Date fromDao = java.sql.Date.valueOf(LocalDate.of(2026, 3, 14));
Instant broken = fromDao.toInstant();          // UnsupportedOperationException

The safe conversion checks the type first and calls toLocalDate() on a java.sql.Date, which needs no zone. The adapter in section 4 runs that check. More on the JDBC types is in LocalDate to java.sql.Date.

Date fromDao = java.sql.Date.valueOf(LocalDate.of(2026, 3, 14));
LocalDate safe = (fromDao instanceof java.sql.Date sql) ? sql.toLocalDate() : LocalDate.ofInstant(fromDao.toInstant(), ZoneId.of("Asia/Kolkata"));   // 2026-03-14

3. Convert LocalDate to Date

A Date needs a moment, and a LocalDate has no time, so we choose one. The usual choice is the start of the day in the business zone, which atStartOfDay(zone) returns as a ZonedDateTime. From there, toInstant() and Date.from() give the Date.

LocalDate renewal = LocalDate.of(2026, 3, 14);
ZoneId zone = ZoneId.of("Asia/Kolkata");
Date legacyDate = Date.from(renewal.atStartOfDay(zone).toInstant());
long millis = legacyDate.getTime();                     // 1773426600000
Instant moment = legacyDate.toInstant();                // 2026-03-13T18:30:00Z

The instant shows 18:30 on the previous day in UTC, which is correct, because midnight in India is 5.5 hours before midnight in UTC. The toString() output of legacyDate uses the JVM default zone, so a laptop and a server print different text for the same value.

We prefer atStartOfDay(zone) over atStartOfDay().atZone(zone). A few zones skip midnight on the day daylight saving time starts, for example Chile on September 6, 2026, and the zone-aware method returns the first valid time of that day.

LocalDate dstDay = LocalDate.of(2026, 9, 6);
ZonedDateTime start = dstDay.atStartOfDay(ZoneId.of("America/Santiago"));   // 2026-09-06T01:00-03:00[America/Santiago]

When the old API wants a date at noon or another fixed time, we use atTime() before adding the zone, as LocalDate to LocalDateTime shows.

4. An Adapter Class at the Legacy Boundary

Say a library app still uses an old MemberDao whose methods take and return java.util.Date, and the team cannot change it yet. The new loan code works with LocalDate, so we put both conversions in one small class with the business zone as a constant.

final class LegacyDates {
    static final ZoneId LIBRARY_ZONE = ZoneId.of("Asia/Kolkata");

    private LegacyDates() {
    }

    static LocalDate toLocalDate(Date date) {
        if (date == null) {
            return null;                                  // legacy APIs use null for "no date"
        }
        if (date instanceof java.sql.Date sqlDate) {
            return sqlDate.toLocalDate();                 // toInstant() would throw here
        }
        return LocalDate.ofInstant(date.toInstant(), LIBRARY_ZONE);
    }

    static Date toDate(LocalDate localDate) {
        return localDate == null ? null : Date.from(localDate.atStartOfDay(LIBRARY_ZONE).toInstant());
    }
}

The loan service calls the adapter once for each value that crosses the boundary. Everything between the two calls is plain java.time code, so a later move away from MemberDao touches only LegacyDates.

Date memberSince = Date.from(Instant.parse("2025-12-31T19:00:00Z"));
LocalDate since = LegacyDates.toLocalDate(memberSince);      // 2026-01-01
LocalDate firstRenewal = since.plusYears(1);                 // 2027-01-01
Date forDao = LegacyDates.toDate(firstRenewal);
LocalDate roundTrip = LegacyDates.toLocalDate(forDao);       // 2027-01-01
LocalDate missing = LegacyDates.toLocalDate(null);           // null

The round trip gives back the same date, because both directions use the same zone. If one side used UTC and the other used India time, every member who joined in the evening would see the wrong renewal day.

5. Date, LocalDate and the Other Bridges

A LocalDate is the right target only when the value is a whole day, such as a due date or a birthday. When the legacy value is a timestamp, such as the time a book was returned, we keep the time and convert to another type.

Legacy value meansConvert toCall
A calendar dayLocalDateLocalDate.ofInstant(date.toInstant(), zone)
A wall-clock date and timeLocalDateTimeLocalDateTime.ofInstant(date.toInstant(), zone)
A moment, such as an event timeInstantdate.toInstant()
A moment shown in a placeZonedDateTimedate.toInstant().atZone(zone)

The LocalDateTime and Date conversion covers the second row, including daylight saving gaps. For an Instant, no zone is involved at all, which makes it the simplest replacement for a Date field that records when something happened.

6. Date to LocalDate FAQs

Search results for this conversion keep showing the same follow-up questions.

6.1. Can we convert a Date to a LocalDate without a time zone?

No. A Date is a moment, and the calendar day of a moment depends on the zone. Code that seems to work without a zone passes ZoneId.systemDefault() somewhere, often hidden inside a helper.

6.2. Why is my LocalDate one day off after the conversion?

Because the conversion used a different zone than the code that created the Date. A Date for midnight in India is 18:30 UTC on the previous day, so reading it in UTC gives the previous date, as section 1 shows. We use the same ZoneId in both directions.

6.3. How do we convert a Calendar to a LocalDate?

A GregorianCalendar has its own zone, so we use it. The call gc.toZonedDateTime().toLocalDate() keeps the calendar’s zone, and for any Calendar we write LocalDate.ofInstant(cal.toInstant(), cal.getTimeZone().toZoneId()).

6.4. Is java.sql.Date.valueOf() a good way to get a java.util.Date from a LocalDate?

Only when the target is JDBC. The result is a java.sql.Date at midnight in the JVM default zone, and passing it to code that calls toInstant() throws UnsupportedOperationException. For a plain java.util.Date, we use Date.from(localDate.atStartOfDay(zone).toInstant()).

6.5. Should new code still use java.util.Date?

No. Each legacy class has a java.time replacement, so we keep Date only in the signatures of APIs we cannot change and convert at that boundary.

7. Conclusion

A Date becomes a LocalDate through an Instant and a ZoneId, with LocalDate.ofInstant() on Java 9 and later. The way back picks the start of the day in a zone with atStartOfDay(zone) and wraps the instant with Date.from().

The zone is the part that goes wrong. We use the zone that created the legacy value, check for a java.sql.Date before calling toInstant(), and keep both conversions in one adapter at the edge of our code. The Java date and time guide lists the other java.time tutorials.

8. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.