Every experienced Java developer has a date-related war story. Mine came from a batch financial-reconciliation job that silently skipped three months of records because Calendar.MONTH uses zero-based indexing — January is 0, not 1 — and the off-by-one was invisible in unit tests that only ran against the current month. The bug surfaced in a quarterly audit, not in CI. That afternoon I started migrating our entire codebase to java.time, and I have not looked back since.
The java.time package (JSR-310, shipped in Java 8) is not merely a cleaner calendar API — it is a complete redesign of how Java represents time. Every type is immutable and thread-safe, month numbers start at 1, year offsets are gone, and the API forces you to be explicit about whether a value carries a timezone. This guide covers every migration scenario you will encounter when moving a real codebase from java.util.Date, java.util.Calendar, java.sql.Date, and SimpleDateFormat to their modern equivalents. All code has been tested on Java 21.0.3 (Eclipse Temurin) and is fully backward-compatible to Java 8.
The guide is structured so you can jump to any section independently. If you only need to migrate SQL types for a Hibernate project, jump to Section 6. If you are fixing Jackson serialisation in a REST API, jump to Section 10. The pitfalls section at the end contains the five mistakes I see most often in code reviews — read it before you consider the migration done.
Why java.util.Date and Calendar Are Broken
Before migrating, it helps to understand exactly what is wrong with the legacy API. The problems are not cosmetic — they cause real production bugs.
// Problem 1: java.util.Date does NOT represent "just a date"
// Despite the name, it stores milliseconds since the Unix epoch.
// The name is a lie — it is actually a timestamp.
Date birthday = new Date(); // actually: "right now" as a timestamp
// Problem 2: Year offset of 1900, zero-based months
// The constructor is so confusing it was deprecated in Java 1.1
Date legacyBirthday = new Date(124, 0, 15); // means: year 2024, January, 15th
// ^^^ ^ this is genuinely what this does
// Problem 3: Calendar.MONTH is 0-indexed (January = 0, December = 11)
Calendar cal = Calendar.getInstance();
cal.set(2024, 1, 15); // sets FEBRUARY 15, not January — the silent killer
cal.set(2024, Calendar.FEBRUARY, 15); // correct, but requires the constant
// Problem 4: Mutable — anyone with a reference can change your date
Date orderDate = new Date();
processOrder(orderDate); // the method could call orderDate.setTime(...) inside!
// Problem 5: SimpleDateFormat is NOT thread-safe
// Sharing this as a static field in a multi-threaded application
// produces corrupted dates silently
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd"); // DANGER
The result is an API where correct usage requires intimate knowledge of its historical quirks, and where several of the most dangerous mistakes produce no exception — just wrong data. java.time eliminates all of these by design.
The java.time Type System at a Glance
Before choosing a replacement type, understand what each one represents. The single biggest mistake in migrations is reaching for LocalDateTime everywhere — it is the wrong default for the majority of real-world use cases.
| Legacy Type | Problem | Replacement Type | When to Use the Replacement |
|---|---|---|---|
java.util.Date (as timestamp) | Mutable, no timezone | Instant | Machine-generated timestamps: audit logs, created_at, event sourcing |
java.util.Date (date only) | Carries hidden time | LocalDate | Birthdays, holidays, accounting dates — no time component needed |
java.util.Date (date + local time) | Mutable, confusing | LocalDateTime | Scheduled events in a single fixed timezone (use sparingly) |
java.util.Date (global event) | No zone info | ZonedDateTime | User-facing events that cross timezone boundaries |
java.util.Calendar | Mutable, verbose | ZonedDateTime | Any calendar operation that involves a timezone |
java.util.GregorianCalendar | Mutable, verbose | ZonedDateTime | Direct conversion available via toZonedDateTime() |
java.sql.Date | Subclass of Date, broken | LocalDate | DATE columns in relational databases |
java.sql.Timestamp | Nanosecond hack, broken | Instant or LocalDateTime | TIMESTAMP columns; prefer Instant for UTC |
java.sql.Time | Subclass of Date, broken | LocalTime | TIME columns in relational databases |
SimpleDateFormat | Not thread-safe | DateTimeFormatter | All date formatting and parsing — immutable and thread-safe |
Decision rule: If the value needs to be unambiguous across timezones (APIs, databases, event logs), use Instant. If it represents a calendar date visible to users (a birthday, a deadline), use LocalDate. If it represents a moment in a specific region (a flight departure, a meeting invite), use ZonedDateTime. Avoid LocalDateTime unless you are certain the timezone is always fixed and implicit.
The Bridge: toInstant() and Date.from()
Java 8 added bridge methods to java.util.Date and java.util.Calendar specifically to make migration possible without rewriting everything at once. These two methods are the foundation of every conversion pattern in this guide.
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
// Bridge 1: java.util.Date → Instant (the universal first step)
Date legacyDate = new Date();
Instant instant = legacyDate.toInstant(); // added in Java 8
// Bridge 2: Instant → java.util.Date (for legacy interop layers)
Instant now = Instant.now();
Date backToLegacy = Date.from(now);
// Bridge 3: Calendar → Instant (Calendar also got toInstant() in Java 8)
Calendar calendar = Calendar.getInstance();
Instant calInstant = calendar.toInstant();
// Bridge 4: GregorianCalendar has a direct, lossless conversion
GregorianCalendar gregorianCal = (GregorianCalendar) Calendar.getInstance();
ZonedDateTime zdt = gregorianCal.toZonedDateTime(); // preserves zone AND DST rules
// Bridge 5: ZonedDateTime → GregorianCalendar (back-conversion)
ZonedDateTime zonedDateTime = ZonedDateTime.now();
GregorianCalendar backToGregorian = GregorianCalendar.from(zonedDateTime);
The pattern is always: old type → Instant (via toInstant()) → new type (via atZone() or other conversion). Once you have an Instant, you can reach any other java.time type.
Migrating java.util.Date
A java.util.Date always holds a millisecond epoch internally, regardless of what name you give it in your code. The four conversions below cover every real-world use case. Choose based on what the value semantically represents in your domain — not on the field name.
Date → Instant (most common)
Instant is the closest semantic equivalent to java.util.Date: a precise point on the UTC timeline. Use this conversion for any value that represents “when something happened” — created timestamps, event logs, audit fields. The conversion is a single method call with no decisions to make.
import java.time.Instant;
import java.util.Date;
// The simplest and most common conversion
Date createdAt = getRecordCreatedAt(); // from legacy API
Instant createdAtInstant = createdAt.toInstant();
// Common use case: comparing two dates without caring about timezone
Date start = new Date(1_000_000L);
Date end = new Date(2_000_000L);
Instant startInstant = start.toInstant();
Instant endInstant = end.toInstant();
boolean isAfter = endInstant.isAfter(startInstant); // true
Date → LocalDate (date-only, no time)
LocalDate represents a calendar date with no time component and no timezone — a year, month, and day. This conversion requires a ZoneId because the same epoch millisecond can fall on different calendar dates depending on the observer’s timezone. Always be explicit: defaulting to ZoneId.systemDefault() on a server is a portability risk.
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
// You must supply a ZoneId because a Date is a point in time,
// and the same Instant maps to different calendar dates in different timezones.
Date legacyDate = getLegacyDate();
// OPTION A: Use the system default timezone (fine for single-region apps)
LocalDate localDate = legacyDate.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate();
// OPTION B: Always be explicit about the timezone (recommended for server-side code)
LocalDate localDateUtc = legacyDate.toInstant()
.atZone(ZoneId.of("UTC"))
.toLocalDate();
// OPTION C: Use the user's timezone from their profile/session
String userTimeZone = "America/New_York";
LocalDate localDateUserZone = legacyDate.toInstant()
.atZone(ZoneId.of(userTimeZone))
.toLocalDate();
Date → LocalDateTime
LocalDateTime combines a date and a clock time but carries no timezone. Use it only for values that will always be interpreted in the same, implicit timezone — for example, a recurring local alarm or a scheduled batch time in a single-region system. For any value that crosses timezone boundaries, use ZonedDateTime instead.
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
// Same pattern as LocalDate but retains the time component.
// Remember: LocalDateTime has NO timezone info — it is wall-clock time only.
Date legacyDate = getLegacyDate();
LocalDateTime ldt = legacyDate.toInstant()
.atZone(ZoneId.of("Europe/London"))
.toLocalDateTime();
System.out.println(ldt); // e.g. 2024-01-15T14:30:00
Date → ZonedDateTime (timezone-aware)
ZonedDateTime is the richest representation: date, time, and a full IANA timezone identifier such as America/New_York. Unlike a raw UTC offset, a named timezone carries DST rules — so plusDays(1) is DST-aware, not a raw 86400-second addition. Use this when you need to display or schedule events in a specific user’s timezone.
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Date;
// ZonedDateTime preserves full zone rules including DST transitions.
Date legacyDate = getEventStartDate();
ZonedDateTime zdt = legacyDate.toInstant()
.atZone(ZoneId.of("America/New_York"));
// Output: 2024-01-15T09:30:00-05:00[America/New_York]
System.out.println(zdt);
Back-conversion: java.time → Date (for legacy interop)
During incremental migrations you will often need to pass a modern java.time value to a legacy method that still accepts Date. The key subtlety: LocalDate has no time component, so you must choose a timezone and a time-of-day before calling Date.from(). Midnight at UTC is the safe, reproducible default for server-side code.
import java.time.*;
import java.util.Date;
// Instant → Date (the cleanest back-conversion)
Instant instant = Instant.now();
Date fromInstant = Date.from(instant);
// LocalDate → Date (must go through Instant — pick a timezone!)
LocalDate localDate = LocalDate.of(2024, 1, 15);
Date fromLocalDate = Date.from(
localDate.atStartOfDay(ZoneId.of("UTC")).toInstant()
);
// LocalDateTime → Date
LocalDateTime ldt = LocalDateTime.of(2024, 1, 15, 9, 30);
Date fromLdt = Date.from(
ldt.atZone(ZoneId.systemDefault()).toInstant()
);
// ZonedDateTime → Date
ZonedDateTime zdt = ZonedDateTime.now();
Date fromZdt = Date.from(zdt.toInstant());
Migrating Calendar and GregorianCalendar
Calendar is even harder to migrate than Date because its mutable API actively encourages shared-state bugs — a Calendar passed into a method can be silently modified inside it. The two Java 8 bridge additions — Calendar.toInstant() and GregorianCalendar.toZonedDateTime() — make the conversion mechanical. Always use cal.getTimeZone().toZoneId() when converting a Calendar to preserve the timezone the original code intended.
import java.time.*;
import java.util.*;
// --- Calendar → ZonedDateTime ---
// Calendar carries its timezone in getTimeZone().
// Use that zone when converting to preserve the intended time.
Calendar cal = Calendar.getInstance();
cal.set(2024, Calendar.MARCH, 15, 10, 30, 0); // March 15, 10:30 AM
ZonedDateTime fromCalendar = cal.toInstant()
.atZone(cal.getTimeZone().toZoneId());
// Output: 2024-03-15T10:30:00+01:00[Europe/Berlin] (depends on your system zone)
// --- GregorianCalendar → ZonedDateTime (direct, lossless) ---
// GregorianCalendar.toZonedDateTime() is the only direct bridge
// that preserves the zone ID exactly without going through Instant.
GregorianCalendar greg = new GregorianCalendar(
TimeZone.getTimeZone("America/Chicago")
);
greg.set(2024, Calendar.JUNE, 10, 8, 0, 0);
ZonedDateTime fromGregorian = greg.toZonedDateTime();
// Output: 2024-06-10T08:00:00-05:00[America/Chicago]
// --- ZonedDateTime → GregorianCalendar (back-conversion) ---
ZonedDateTime eventTime = ZonedDateTime.of(
LocalDateTime.of(2024, 6, 10, 8, 0),
ZoneId.of("America/Chicago")
);
GregorianCalendar backToGregorian = GregorianCalendar.from(eventTime);
One pattern that comes up often during migrations: code that calls cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), etc. to extract individual fields. The java.time equivalents are date.getYear(), date.getMonthValue() (1-based), date.getDayOfMonth(). Note that the old Calendar.MONTH is 0-based and the new getMonthValue() is 1-based — this is one of the most common sources of bugs in partial migrations.
Migrating java.sql Date, Timestamp, and Time
The java.sql.* types are subclasses of java.util.Date with all the same problems, plus additional ones introduced by the JDBC specification. Java 8 added direct conversion methods to each of them.
java.sql.Date → LocalDate
java.sql.Date is supposed to represent a date-only value but is internally a millisecond epoch with the time stripped on the wire. This inconsistency meant that calling getMonth() returned 0-based months just like java.util.Date. Java 8 added toLocalDate() directly on java.sql.Date, making this the cleanest of all the java.sql.* conversions — no ZoneId needed.
import java.sql.ResultSet;
import java.time.LocalDate;
// BEFORE: Manual, error-prone extraction
java.sql.Date sqlDate = resultSet.getDate("order_date");
// sqlDate.getMonth() is 0-based: January = 0 — classic bug source
// AFTER: Direct conversion via toLocalDate() (added in Java 8)
LocalDate orderDate = resultSet.getDate("order_date").toLocalDate();
// orderDate.getMonthValue() is 1-based: January = 1
// Back-conversion for INSERT/UPDATE statements
LocalDate dueDate = LocalDate.of(2024, 6, 30);
java.sql.Date sqlDueDate = java.sql.Date.valueOf(dueDate); // direct factory method
java.sql.Timestamp → Instant (recommended)
java.sql.Timestamp is the messiest legacy type — it extends java.util.Date but adds a nanoseconds field on top, creating two representations of sub-second precision in one object. Prefer Instant as the migration target because it captures the full semantics of a TIMESTAMP column: a UTC point in time with nanosecond precision. Use LocalDateTime only when you know the database column stores local wall-clock time with no timezone context.
import java.sql.ResultSet;
import java.time.Instant;
import java.time.LocalDateTime;
// AFTER Option A: Timestamp → Instant (recommended for audit/UTC timestamps)
Instant createdAt = resultSet.getTimestamp("created_at").toInstant();
// AFTER Option B: Timestamp → LocalDateTime (when you know the column stores local time)
LocalDateTime updatedAt = resultSet.getTimestamp("updated_at").toLocalDateTime();
// Back-conversion: Instant → Timestamp (for prepared statements)
Instant eventTime = Instant.now();
java.sql.Timestamp sqlTs = java.sql.Timestamp.from(eventTime);
// Back-conversion: LocalDateTime → Timestamp
LocalDateTime localDt = LocalDateTime.of(2024, 6, 15, 12, 0);
java.sql.Timestamp sqlTs2 = java.sql.Timestamp.valueOf(localDt);
java.sql.Time → LocalTime
java.sql.Time represents a time-of-day value but is implemented internally as a java.util.Date with the date portion zeroed out — an abstraction leak that made comparisons unreliable. LocalTime is the clean replacement: it has no date component, no timezone, and a clear, readable API for extracting hours, minutes, and seconds.
import java.sql.ResultSet;
import java.time.LocalTime;
// Direct conversion
LocalTime openingTime = resultSet.getTime("opening_time").toLocalTime();
// Back-conversion
LocalTime closingTime = LocalTime.of(22, 0);
java.sql.Time sqlTime = java.sql.Time.valueOf(closingTime);
Using JDBC 4.2 Directly (the clean modern path)
With JDBC 4.2 (bundled with Java 8) and modern JDBC drivers (PostgreSQL 42.x, MySQL Connector/J 8.x, H2 2.x), you can bypass the java.sql.* types entirely and bind java.time types directly to PreparedStatement.
// JDBC 4.2+: bind java.time types directly — no java.sql.* wrapper needed
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO orders (order_date, created_at) VALUES (?, ?)"
);
LocalDate orderDate = LocalDate.of(2024, 6, 30);
Instant createdAt = Instant.now();
ps.setObject(1, orderDate); // JDBC 4.2 handles LocalDate → DATE
ps.setObject(2, createdAt); // JDBC 4.2 handles Instant → TIMESTAMP
// Reading back with JDBC 4.2
ResultSet rs = statement.executeQuery("SELECT order_date, created_at FROM orders");
LocalDate retrievedDate = rs.getObject("order_date", LocalDate.class);
Instant retrievedTimestamp = rs.getObject("created_at", Instant.class);
Replacing SimpleDateFormat with DateTimeFormatter
SimpleDateFormat has two problems: it is mutable (so it cannot be safely shared across threads) and its format characters overlap with java.time in non-obvious ways. DateTimeFormatter is immutable and thread-safe, ships with named constants for all standard ISO-8601 formats, and throws DateTimeParseException on bad input instead of silently returning a wrong result.
The Thread-Safety Problem
The most dangerous misuse of SimpleDateFormat is declaring it as a static final constant in a Spring service. Spring service beans are singletons shared across all request threads, so a shared SimpleDateFormat becomes a shared mutable resource. Under concurrent load it produces intermittent NumberFormatException or silently wrong dates — bugs that are notoriously hard to reproduce in a single-threaded test suite.
// BAD: SimpleDateFormat is stateful and NOT thread-safe.
// In a Spring @Service bean shared across request threads,
// this will produce corrupted date strings or throw ParseException
// under concurrent load — and only intermittently, making it hard to debug.
@Service
public class OrderService {
// WRONG: shared mutable state in a singleton bean
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");
public String formatOrderDate(Date date) {
return SDF.format(date); // data race under concurrent calls
}
}
// GOOD: DateTimeFormatter is immutable and thread-safe.
// Declare it as a static constant — safe to share across all threads.
@Service
public class OrderService {
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
public String formatOrderDate(LocalDate date) {
return date.format(DATE_FORMATTER); // thread-safe, no locks needed
}
}
Format Pattern Comparison
Most SimpleDateFormat patterns transfer directly to DateTimeFormatter. The key difference is that DateTimeFormatter provides named constants (e.g. ISO_LOCAL_DATE, ISO_OFFSET_DATE_TIME) for the most common ISO-8601 formats — prefer these over custom patterns to get locale-safe, standards-compliant output without maintaining pattern strings.
| Pattern | SimpleDateFormat | DateTimeFormatter | Example Output |
|---|---|---|---|
| ISO date | yyyy-MM-dd | ISO_LOCAL_DATE or yyyy-MM-dd | 2024-01-15 |
| ISO datetime | yyyy-MM-dd'T'HH:mm:ss | ISO_LOCAL_DATE_TIME | 2024-01-15T09:30:00 |
| ISO with offset | yyyy-MM-dd'T'HH:mm:ssZ | ISO_OFFSET_DATE_TIME | 2024-01-15T09:30:00+05:30 |
| Human-readable | dd MMM yyyy | dd MMM yyyy | 15 Jan 2024 |
| Time only | HH:mm:ss | ISO_LOCAL_TIME | 09:30:00 |
| US date | MM/dd/yyyy | MM/dd/yyyy | 01/15/2024 |
| Full timestamp | yyyy-MM-dd HH:mm:ss.SSS | yyyy-MM-dd HH:mm:ss.SSS | 2024-01-15 09:30:00.000 |
Parsing and Formatting
DateTimeFormatter works by calling format() on the temporal object (e.g. date.format(formatter)) rather than on the formatter itself. Parsing uses a static factory on the target type (e.g. LocalDate.parse(str, formatter)). Always pass an explicit Locale when a pattern contains text tokens like month names — on a server where the JVM locale may differ from the expected locale, omitting it produces silently wrong output.
import java.time.*;
import java.time.format.*;
import java.util.Locale;
// --- Formatting ---
LocalDate date = LocalDate.of(2024, 1, 15);
LocalDateTime dateTime = LocalDateTime.of(2024, 1, 15, 9, 30, 0);
ZonedDateTime zdt = ZonedDateTime.of(dateTime, ZoneId.of("America/New_York"));
// Using built-in formatters (prefer these — they are ISO-standard and locale-safe)
String isoDate = date.format(DateTimeFormatter.ISO_LOCAL_DATE);
String isoDateTime = dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
String isoOffset = zdt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
// Using custom patterns
DateTimeFormatter customFormatter = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH);
String humanReadable = date.format(customFormatter); // 15 Jan 2024
// --- Parsing ---
String dateStr = "2024-01-15";
LocalDate parsed = LocalDate.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE);
String customStr = "15 Jan 2024";
LocalDate parsedCustom = LocalDate.parse(customStr, DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH));
// Parsing a date with timezone offset
String offsetStr = "2024-01-15T09:30:00+05:30";
ZonedDateTime parsedZdt = ZonedDateTime.parse(offsetStr, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
Optional Pattern Parts and Lenient Parsing
When integrating with external systems — legacy databases, CSV exports, partner APIs — you often encounter date strings where seconds or milliseconds are present on some records and absent on others. DateTimeFormatterBuilder lets you declare those parts as optional using optionalStart() / optionalEnd(), eliminating the need for pre-processing or try-catch chains around multiple parsers.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
// Optional seconds and milliseconds — useful when parsing data from external systems
DateTimeFormatter flexibleFormatter = new DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd'T'HH:mm")
.optionalStart()
.appendPattern(":ss")
.optionalStart()
.appendPattern(".SSS")
.optionalEnd()
.optionalEnd()
.parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
.parseDefaulting(ChronoField.MILLI_OF_SECOND, 0)
.toFormatter();
LocalDateTime dt1 = LocalDateTime.parse("2024-01-15T09:30", flexibleFormatter);
LocalDateTime dt2 = LocalDateTime.parse("2024-01-15T09:30:45", flexibleFormatter);
LocalDateTime dt3 = LocalDateTime.parse("2024-01-15T09:30:45.123",flexibleFormatter);
Date Arithmetic: Duration, Period, and TemporalAdjusters
Date arithmetic is where the legacy API causes the most subtle bugs — the millisecond-math approach breaks silently at DST boundaries because a “day” is not always 86400 seconds. java.time separates the two kinds of elapsed time cleanly: Duration for machine time (invariant, second-precise), and Period for calendar time (month-length-aware, DST-aware).
Adding and Subtracting Time
Every java.time arithmetic method returns a new object — the original is never mutated. This eliminates the class of bugs where a Calendar is accidentally modified after being passed to a helper method. ZonedDateTime arithmetic is additionally DST-aware: adding one day around a DST transition produces the correct wall-clock result, not a raw 86400-second shift.
import java.time.*;
// BAD: Calendar arithmetic — mutates the object (accidental shared state bug source)
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, 7); // permanently modifies the calendar
// GOOD: java.time — returns new instances (immutable, safe to share)
LocalDate today = LocalDate.now();
LocalDate nextWeek = today.plusDays(7);
LocalDate nextMonth = today.plusMonths(1);
LocalDate nextYear = today.plusYears(1);
LocalDate lastQuarter = today.minusMonths(3);
Instant now = Instant.now();
Instant inOneHour = now.plus(1, ChronoUnit.HOURS);
Instant inTenMins = now.plus(10, ChronoUnit.MINUTES);
Instant yesterday = now.minus(1, ChronoUnit.DAYS);
// ZonedDateTime — DST-aware arithmetic
ZonedDateTime meeting = ZonedDateTime.now(ZoneId.of("Europe/London"));
ZonedDateTime tomorrow = meeting.plusDays(1); // DST-aware
ZonedDateTime nextSlot = meeting.plusHours(2); // exact 2-hour shift
Calculating Differences: Duration vs Period
Use Duration when you need a machine-time difference (hours, minutes, seconds, nanoseconds) between two Instant or LocalDateTime values. Use Period when you need a calendar-time difference (years, months, days) between two LocalDate values. Mixing them — e.g. using Duration to count “months” — produces wrong results because months have variable lengths.
import java.time.*;
// BAD: Millisecond arithmetic — fragile and fails at DST boundaries
Date start = new Date();
Date end = new Date();
long diffMs = end.getTime() - start.getTime();
long diffDays = diffMs / (1000 * 60 * 60 * 24); // WRONG during DST transitions
// GOOD: Duration — for time-based differences (hours, minutes, seconds, nanos)
Instant startInstant = Instant.now();
Instant endInstant = startInstant.plus(5, ChronoUnit.HOURS);
Duration duration = Duration.between(startInstant, endInstant);
long totalHours = duration.toHours(); // 5
long totalMinutes = duration.toMinutes(); // 300
long totalSeconds = duration.toSeconds(); // 18000
// GOOD: Period — for calendar-based differences (years, months, days)
LocalDate startDate = LocalDate.of(2024, 1, 15);
LocalDate endDate = LocalDate.of(2025, 4, 20);
Period period = Period.between(startDate, endDate);
System.out.println(period.getYears()); // 1
System.out.println(period.getMonths()); // 3
System.out.println(period.getDays()); // 5
// ChronoUnit.between — when you want a single unit
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate); // 461
long monthsBetween = ChronoUnit.MONTHS.between(startDate, endDate); // 15
Start/End of Day, Month, Year
TemporalAdjusters is a factory for common calendar adjustments that would otherwise require manual arithmetic. It handles edge cases automatically — for example, “last day of month” correctly returns 28 for February in a non-leap year and 29 in a leap year. These adjusters are particularly useful when building database query ranges where you need the first and last instant of a reporting period.
import java.time.*;
import java.time.temporal.TemporalAdjusters;
LocalDate today = LocalDate.now();
ZoneId zone = ZoneId.of("UTC");
// Start and end of today as Instant (for database queries)
Instant startOfDay = today.atStartOfDay(zone).toInstant();
Instant endOfDay = today.plusDays(1).atStartOfDay(zone).toInstant().minusNanos(1);
// First and last day of the current month
LocalDate firstDayOfMonth = today.with(TemporalAdjusters.firstDayOfMonth());
LocalDate lastDayOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
// First and last day of the year
LocalDate firstDayOfYear = today.with(TemporalAdjusters.firstDayOfYear());
LocalDate lastDayOfYear = today.with(TemporalAdjusters.lastDayOfYear());
// Next Monday
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
// Last Friday of the month
LocalDate lastFridayOfMonth = today.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
Date Comparisons
All java.time types implement Comparable and expose isBefore(), isAfter(), and isEqual() — more readable than the before() / after() methods on java.util.Date, and correct across DST boundaries because they compare actual temporal values rather than millisecond counts. They also sort naturally with Collections.sort() and streams without a custom comparator.
import java.time.*;
import java.util.*;
// BAD: java.util.Date comparisons
Date d1 = new Date();
Date d2 = new Date();
boolean before = d1.before(d2);
boolean after = d1.after(d2);
boolean equal = d1.compareTo(d2) == 0;
// GOOD: LocalDate
LocalDate ld1 = LocalDate.now();
LocalDate ld2 = ld1.plusDays(1);
boolean ldBefore = ld1.isBefore(ld2); // true
boolean ldAfter = ld1.isAfter(ld2); // false
boolean ldEqual = ld1.isEqual(ld2); // false
// GOOD: Instant (compares absolute points in time)
Instant i1 = Instant.now();
Instant i2 = i1.plusSeconds(60);
boolean iBefore = i1.isBefore(i2); // true
// Range check: is a date within a half-open interval [start, end)?
LocalDate checkDate = LocalDate.of(2024, 6, 15);
LocalDate rangeStart = LocalDate.of(2024, 6, 1);
LocalDate rangeEnd = LocalDate.of(2024, 6, 30);
boolean inRange = !checkDate.isBefore(rangeStart) && checkDate.isBefore(rangeEnd);
// Sorting — java.time types are Comparable
List<LocalDate> dates = List.of(
LocalDate.of(2024, 3, 10),
LocalDate.of(2024, 1, 5),
LocalDate.of(2024, 6, 20)
);
List<LocalDate> sorted = dates.stream()
.sorted()
.toList(); // [2024-01-05, 2024-03-10, 2024-06-20]
Jackson Serialisation of java.time Types
When exposing java.time types in REST APIs, you need Jackson to serialise them as ISO-8601 strings rather than as numeric arrays or epoch numbers. The setup differs between Jackson 2 and Jackson 3.
Jackson 2.x Setup
In Jackson 2, java.time support ships in a separate artifact (jackson-datatype-jsr310) that must be added as a dependency and registered explicitly with the ObjectMapper. Without it, a LocalDate field serialises as a JSON array [2024,1,15] rather than the string "2024-01-15" — a common surprise when first migrating REST endpoints.
<!-- Maven: add the JSR-310 module for Jackson 2 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.17.2</version>
</dependency>
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
// Jackson 2: explicit module registration required
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// In Spring Boot, application.properties is enough:
// spring.jackson.serialization.write-dates-as-timestamps=false
Jackson 3.x Setup (no extra module needed)
Jackson 3 ships java.time support as part of jackson-databind itself — no separate dependency and no explicit registerModule() call. The only configuration you still need is the property that switches from numeric timestamp output to ISO-8601 strings.
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
// Jackson 3: JavaTimeModule is auto-registered — no explicit call needed
JsonMapper mapper = JsonMapper.builder()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
Default Serialisation Behaviour
With WRITE_DATES_AS_TIMESTAMPS disabled, Jackson serialises every java.time type as an ISO-8601 string. The table below shows the exact output format for each type — useful for documenting API contracts and writing frontend date-parsing code.
java.time Type | Serialised As (ISO-8601 string) | Example JSON value |
|---|---|---|
LocalDate | Date string | "2024-01-15" |
LocalTime | Time string | "09:30:00" |
LocalDateTime | Date-time string | "2024-01-15T09:30:00" |
ZonedDateTime | Offset date-time | "2024-01-15T09:30:00-05:00[America/New_York]" |
Instant | UTC instant | "2024-01-15T14:30:00Z" |
Duration | ISO-8601 duration | "PT5H30M" |
Custom Serialiser for a Specific Format
When an API contract requires a non-ISO date format — for example, a UK-style dd/MM/yyyy for a legacy integration — annotate individual fields with @JsonSerialize / @JsonDeserialize rather than changing the global mapper configuration. This keeps the custom format scoped to the one field that needs it.
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.annotation.*;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
// Custom serialiser: format LocalDate as dd/MM/yyyy instead of ISO-8601
public class UkDateSerializer extends JsonSerializer<LocalDate> {
private static final DateTimeFormatter UK_FORMAT =
DateTimeFormatter.ofPattern("dd/MM/yyyy");
@Override
public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
gen.writeString(value.format(UK_FORMAT));
}
}
// Custom deserialiser
public class UkDateDeserializer extends JsonDeserializer<LocalDate> {
private static final DateTimeFormatter UK_FORMAT =
DateTimeFormatter.ofPattern("dd/MM/yyyy");
@Override
public LocalDate deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
return LocalDate.parse(p.getText(), UK_FORMAT);
}
}
// Apply on the field
public class OrderDto {
@JsonSerialize(using = UkDateSerializer.class)
@JsonDeserialize(using = UkDateDeserializer.class)
private LocalDate orderDate;
}
Hibernate and JPA Mapping
Hibernate 5.2+ and JPA 2.2+ natively support java.time types. Hibernate 7 (which requires Java 17+) goes further with improved JDBC 4.2 bindings and a more consistent TIMESTAMP_UTC approach. See the companion post Mastering Hibernate 7 Date & Time Mapping for the full deep-dive. This section covers the migration-specific patterns.
Removing @Temporal
@Temporal was required because java.util.Date carries no semantic information about whether it should map to a DATE, TIME, or TIMESTAMP column — you had to tell Hibernate explicitly. With java.time types the mapping is unambiguous from the type itself, so @Temporal is both unnecessary and actively discouraged from JPA 2.2 onwards. Leaving it on a java.time field in Hibernate 7 can cause unexpected behaviour.
import jakarta.persistence.*;
import java.time.*;
import java.util.Date;
// BEFORE: Legacy entity with @Temporal
@Entity
public class LegacyOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Temporal(TemporalType.DATE)
@Column(name = "order_date")
private Date orderDate; // Date-only, but carries hidden time
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_at")
private Date createdAt; // Full timestamp
@Temporal(TemporalType.TIME)
@Column(name = "pickup_time")
private Date pickupTime; // Time-only extraction hack
}
// AFTER: Modern entity — @Temporal removed, types are semantically correct
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_date")
private LocalDate orderDate; // DATE column — no @Temporal needed
@Column(name = "created_at")
private Instant createdAt; // TIMESTAMP column — UTC point in time
@Column(name = "pickup_time")
private LocalTime pickupTime; // TIME column — no date, no zone
}
Hibernate Type Mapping Reference
The table below shows exactly which SQL column type Hibernate chooses for each java.time type, which JPA version introduced the support, and when to favour one type over another. The most common source of confusion is choosing between Instant and OffsetDateTime for timestamp columns — the short answer is Instant for machine-generated values, OffsetDateTime when you need to preserve the user’s UTC offset.
| Java Type | Hibernate SQL Type | JPA Spec Support | Notes |
|---|---|---|---|
LocalDate | DATE | JPA 2.2+ | Use for calendar-date fields |
LocalTime | TIME | JPA 2.2+ | Use for time-of-day fields |
LocalDateTime | TIMESTAMP | JPA 2.2+ | No timezone — use carefully |
Instant | TIMESTAMP / TIMESTAMP_UTC | JPA 2.2+ | Best for audit/created_at fields |
OffsetDateTime | TIMESTAMP WITH TIME ZONE | JPA 2.2+ | Preserves UTC offset |
ZonedDateTime | TIMESTAMP + extra column | JPA 2.2+ (via @TimeZoneStorage) | Use @TimeZoneStorage to preserve zone name |
Duration | BIGINT (nanoseconds) | Hibernate only | Stored as a number |
Year | INTEGER | Hibernate only |
Forcing UTC in application.properties
Without these properties, Hibernate uses the JVM default timezone to bind temporal parameters to JDBC statements. On a developer laptop this may silently be correct; on a UTC Docker container or a cloud VM with a different region setting, the same code will shift timestamps. Setting both properties makes the binding behaviour explicit and consistent regardless of where the application runs.
# Force Hibernate to bind all temporal values as UTC
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
# Use TIMESTAMP_UTC JDBC type for Instant fields (Hibernate 6+)
spring.jpa.properties.hibernate.type.preferred_instant_jdbc_type=TIMESTAMP_UTC
Spring MVC and REST API Binding
Spring MVC does not bind java.time types from HTTP parameters out of the box — it needs to know the expected format. The binding strategy changed between Spring Boot 2 and Spring Boot 3: Boot 2 requires a per-parameter @DateTimeFormat annotation, while Boot 3 lets you configure a global default in application.properties and skip the annotation for standard ISO-8601 inputs.
@RequestParam and @PathVariable
In Spring Boot 2.x, add @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to every @RequestParam LocalDate — without it, Spring cannot convert the query string and throws a MethodArgumentTypeMismatchException. In Spring Boot 3.x, set the three spring.mvc.format.* properties in application.properties and the annotation becomes optional for ISO-8601 input.
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.List;
@RestController
@RequestMapping("/orders")
public class OrderController {
// Spring Boot 2.x: @DateTimeFormat is required
@GetMapping("/by-date")
public List<Order> getOrdersByDate(
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate orderDate) {
// Accepts: ?orderDate=2024-01-15
return orderService.findByDate(orderDate);
}
// Spring Boot 3.x: configure application.properties instead
// spring.mvc.format.date=iso → @DateTimeFormat optional for ISO-8601 inputs
@GetMapping("/range")
public List<Order> getOrdersInRange(
@RequestParam LocalDate from,
@RequestParam LocalDate to) {
return orderService.findByDateRange(from, to);
}
// ZonedDateTime in a path variable
@GetMapping("/events/{eventTime}")
public Event getEvent(
@PathVariable
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) ZonedDateTime eventTime) {
return eventService.find(eventTime);
}
}
# application.properties: configure default formats for Spring Boot 3
spring.mvc.format.date=iso
spring.mvc.format.date-time=iso
spring.mvc.format.time=iso
spring.jackson.serialization.write-dates-as-timestamps=false
Spring Data JPA Repository Queries
Spring Data JPA binds java.time parameters directly to JPQL queries — it delegates to Hibernate, which handles the JDBC binding. Derived method names, @Query JPQL, and native queries all accept LocalDate and Instant parameters directly. For “last N hours” queries, pass a computed Instant cutoff rather than using database functions to keep the logic portable across dialects.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
public interface OrderRepository extends JpaRepository<Order, Long> {
// Derived query — Spring Data binds LocalDate directly
List<Order> findByOrderDate(LocalDate orderDate);
// Date range
List<Order> findByOrderDateBetween(LocalDate from, LocalDate to);
// Instant-based timestamp range
List<Order> findByCreatedAtBetween(Instant start, Instant end);
// JPQL with named parameters
@Query("SELECT o FROM Order o WHERE o.orderDate >= :startDate AND o.orderDate < :endDate")
List<Order> findByDateRange(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
// Orders created in the last 24 hours
@Query("SELECT o FROM Order o WHERE o.createdAt > :cutoff")
List<Order> findRecentOrders(@Param("cutoff") Instant cutoff);
}
SQL Column Type Reference
When designing or reviewing a schema migration alongside a java.time upgrade, use this table to verify that the Java type and the SQL column type are semantically aligned. Mismatches — for example, storing an Instant in a DATETIME column on MySQL — lead to silent timezone shifts when the database and application server are in different regions.
| SQL Column Type | Recommended Java Type | Why |
|---|---|---|
DATE | LocalDate | Exact semantic match — no time, no timezone |
TIME | LocalTime | Exact semantic match — no date, no timezone |
TIMESTAMP | Instant | UTC-normalised point in time — most portable |
TIMESTAMP WITH TIME ZONE | OffsetDateTime | Preserves UTC offset; standard SQL type |
DATETIME (MySQL) | LocalDateTime | MySQL DATETIME has no timezone; matches LocalDateTime semantics |
BIGINT (epoch ms) | Instant + converter | Use Instant.ofEpochMilli() to read, .toEpochMilli() to write |
VARCHAR (ISO string) | LocalDate / Instant + converter | Parse with DateTimeFormatter in an @Convert class |
Five Migration Pitfalls to Avoid
These are the five mistakes I see most often in code reviews of partially-migrated codebases. They all share the same characteristic: the code compiles and passes unit tests, but fails in production under specific timezone, concurrency, or precision conditions.
Pitfall 1: The LocalDateTime Timezone Trap
LocalDateTime is the most misused type in the entire java.time API. It looks like the obvious replacement for Date because it has both a date and a time component — but it carries no timezone information at all. The same LocalDateTime value means a completely different instant depending on the server’s timezone, making it silently wrong for any value that crosses a timezone boundary.
// WRONG: LocalDateTime carries no timezone information.
// If your application servers are in UTC but your users are in New York,
// a meeting scheduled at 14:00 LocalDateTime will be interpreted differently
// depending on where the value is read.
LocalDateTime meetingTime = LocalDateTime.of(2024, 6, 15, 14, 0);
// Is this 14:00 UTC? 14:00 New York? Nobody knows.
// RIGHT: Use ZonedDateTime for user-visible events
ZonedDateTime meetingTime = ZonedDateTime.of(
LocalDateTime.of(2024, 6, 15, 14, 0),
ZoneId.of("America/New_York")
);
// Unambiguous: 2024-06-15T14:00:00-04:00[America/New_York]
// RIGHT: Use Instant for machine-generated timestamps
Instant eventCreatedAt = Instant.now(); // always UTC
Pitfall 2: Month Indexing Is Different Between Old and New APIs
When a migration is done incrementally — some code still reading from Calendar, other code already using java.time — a month-comparison bug is almost inevitable. Calendar.MONTH returns 0 for January; LocalDate.getMonthValue() returns 1. Code that reads a month from one API and compares it against the other will be off by one for every month of the year.
// TRAP: Calendar.MONTH is 0-indexed (January = 0, December = 11)
Calendar cal = Calendar.getInstance();
int oldMonth = cal.get(Calendar.MONTH); // 0 in January, 11 in December
// java.time is 1-indexed (January = 1, December = 12)
LocalDate date = LocalDate.now();
int newMonth = date.getMonthValue(); // 1 in January, 12 in December
// This is the most common partial-migration bug:
// Code that reads from Calendar and compares against java.time values
// will be off by one for all months.
// SAFE: use the Month enum — immune to indexing bugs
Month month = date.getMonth();
boolean isFirstQuarter = (month == Month.JANUARY
|| month == Month.FEBRUARY
|| month == Month.MARCH);
Pitfall 3: Date.from(LocalDate) Does Not Compile
This is a compile-time error, but it surprises developers who assume that LocalDate can be passed to Date.from() the same way Instant can. LocalDate does not implement TemporalAccessor in a way that provides epoch-seconds, because it has no time component. You must explicitly choose a time-of-day and timezone before converting to an Instant, and from there to a Date.
// WRONG: LocalDate is not a temporal that supports toInstant() directly.
// This does not compile:
// Date d = Date.from(LocalDate.now()); // compile error
// RIGHT: Convert via atStartOfDay() — always specify the timezone
LocalDate localDate = LocalDate.of(2024, 6, 30);
// Use UTC as the reference zone (safe for server-side code)
Date fromLocalDateUtc = Date.from(
localDate.atStartOfDay(ZoneId.of("UTC")).toInstant()
);
// Or use the system default (matches user's local midnight)
Date fromLocalDateSys = Date.from(
localDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
);
Pitfall 4: ZoneId.systemDefault() Is Not Portable
ZoneId.systemDefault() reads the JVM’s current default timezone, which varies by deployment environment. A developer’s laptop might return Europe/London; a Docker container defaults to UTC; a cloud VM in a US region might return America/New_York. Code that relies on the system default will behave differently on every environment it runs on — passing on localhost and failing silently in production.
// RISKY: ZoneId.systemDefault() returns the JVM's default timezone.
// This is different on a developer laptop (e.g., Europe/London),
// a Docker container (UTC by default), and a cloud VM in another region.
// Code that passes on localhost can fail in staging or production.
LocalDate localDate = new Date().toInstant()
.atZone(ZoneId.systemDefault()) // RISKY: varies by environment
.toLocalDate();
// SAFE: Always be explicit in server-side date conversion code.
LocalDate localDateUtc = new Date().toInstant()
.atZone(ZoneId.of("UTC"))
.toLocalDate();
Pitfall 5: Nanosecond Precision Is Lost in Round-Trips Through Date
java.util.Date stores time with millisecond precision (10⁻³ seconds). Instant stores time with nanosecond precision (10⁻⁹ seconds). Any round-trip that passes through Date will silently truncate the sub-millisecond portion of an Instant. For most applications this does not matter, but for distributed tracing, event sourcing, and benchmarking — where nanosecond-precise ordering is meaningful — keep values as Instant throughout and never pass them through the Date layer.
// java.util.Date has millisecond precision.
// java.time.Instant has nanosecond precision.
// Converting Instant → Date → Instant truncates up to 999,999 nanoseconds.
Instant original = Instant.now(); // e.g. 2024-01-15T09:30:00.123456789Z
Date asDate = Date.from(original); // loses 456789 nanoseconds
Instant roundTrip = asDate.toInstant(); // 2024-01-15T09:30:00.123000000Z
// original.equals(roundTrip) == FALSE
// If nanosecond precision matters (benchmarks, event sourcing, distributed tracing),
// do NOT round-trip through Date. Keep values as Instant throughout.
Finding Legacy Date Code in Your Codebase
Before starting a migration, run these commands to get a full picture of what needs to change. The output gives you a prioritised task list.
# Find all uses of java.util.Date (excluding import lines)
grep -rn "java.util.Date" src/ --include="*.java" | grep -v "^.*import"
# Find all SimpleDateFormat usages
grep -rn "SimpleDateFormat" src/ --include="*.java"
# Find all Calendar usages
grep -rn "java.util.Calendar" src/ --include="*.java" | grep -v "^.*import"
# Find all @Temporal annotations (candidates for removal)
grep -rn "@Temporal" src/ --include="*.java"
# Find all java.sql.* date type usages
grep -rn "java.sql.(Date|Timestamp|Time)" src/ --include="*.java" | grep -v "^.*import"
# Count occurrences per file — prioritise by highest count
grep -rn "SimpleDateFormat|java.util.Date|java.util.Calendar" src/ --include="*.java"
| cut -d: -f1 | sort | uniq -c | sort -rn | head -20
IntelliJ IDEA has a built-in inspection called Use java.time date-time classes instead of legacy (under Inspections → Java → Code Style Issues). Enable it across the project to get inline warnings at every call site. The IDE’s quick-fix can auto-convert some simple patterns like new Date() → Instant.now().
Frequently Asked Questions
Is java.util.Date deprecated?
Not officially — the JDK has never added a formal @Deprecated annotation to java.util.Date. However, the Javadoc for many of its constructors and methods explicitly states “as of JDK 1.1, replaced by…” and the Java team has confirmed there are no plans to add features to it. In practice, treat it as deprecated: use it only where a third-party library forces you to.
Can I use java.time on Android?
Yes — with a caveat. java.time requires Android API 26 (Android 8.0, Oreo) or higher for native support. If you need to support older Android versions, use the ThreeTenABP backport library, which provides the same API on older Android versions via desugaring.
Does Spring Boot auto-configure Jackson for java.time?
Spring Boot auto-configures Jackson 2 with JavaTimeModule registered, but it still serialises as timestamps by default. Set spring.jackson.serialization.write-dates-as-timestamps=false in application.properties to get ISO-8601 string output. Spring Boot with Jackson 3 has JavaTimeModule built-in, and the same property applies.
Should I use Instant or OffsetDateTime for database timestamps?
Use Instant for timestamps that are purely machine-generated (audit logs, created_at, event queues). Use OffsetDateTime when you need to preserve the UTC offset for display to end users in their original timezone. Both map to TIMESTAMP WITH TIME ZONE in databases that support it; for databases that do not (MySQL DATETIME), both are stored as UTC values.
What is the java.time equivalent of new Date()?
It depends on what you need. In the vast majority of server-side cases the answer is Instant.now() — a UTC timestamp. Use LocalDate.now() when you only need the current calendar date, and ZonedDateTime.now() when you need the current time in a specific timezone.
Instant nowInstant = Instant.now(); // current UTC instant (most common)
LocalDate today = LocalDate.now(); // current date in system zone
LocalDateTime nowLocal = LocalDateTime.now(); // current date+time in system zone
ZonedDateTime nowZoned = ZonedDateTime.now(); // current date+time with zone
ZonedDateTime nowUtc = ZonedDateTime.now(ZoneId.of("UTC")); // current date+time in UTC
How do I sort a list of dates?
All java.time types implement Comparable, so Collections.sort() and stream .sorted() work without a custom comparator. For reverse order, pass Comparator.reverseOrder().
// All java.time types implement Comparable — sorting just works
List<LocalDate> dates = new ArrayList<>(List.of(
LocalDate.of(2024, 6, 10),
LocalDate.of(2024, 1, 5),
LocalDate.of(2024, 3, 20)
));
Collections.sort(dates); // ascending: [2024-01-05, 2024-03-20, 2024-06-10]
dates.sort(Comparator.reverseOrder()); // descending
How do I get the current timestamp for a database INSERT?
For the majority of audit and tracking use cases, Instant.now() is the right answer — it gives you a UTC timestamp that Hibernate writes correctly to any TIMESTAMP column when hibernate.jdbc.time_zone=UTC is set. For lifecycle callbacks, @PrePersist and @PreUpdate are the idiomatic JPA approach.
// For Instant fields (Hibernate / JDBC 4.2):
entity.setCreatedAt(Instant.now());
// For OffsetDateTime fields:
entity.setCreatedAt(OffsetDateTime.now(ZoneOffset.UTC));
// Using @PrePersist / @PreUpdate lifecycle callbacks:
@PrePersist
protected void onCreate() {
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = Instant.now();
}
How do I parse a date from user input safely?
User input is untrusted, so always parse inside a try-catch for DateTimeParseException and return Optional.empty() on failure. Never let the exception propagate to the caller — a bad date string in a form field should produce a validation message, not an HTTP 500.
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Optional;
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
public Optional<LocalDate> parseUserDate(String input) {
try {
return Optional.of(LocalDate.parse(input.trim(), formatter));
} catch (DateTimeParseException e) {
log.warn("Invalid date input received: {}", input);
return Optional.empty();
}
}
How do I store a timezone-aware timestamp in a database that lacks TIMESTAMP WITH TIME ZONE?
The cleanest option is to normalise to UTC on write and store as a plain TIMESTAMP column. For use cases where you need to display the event in the user’s original timezone, store the timezone ID as a separate VARCHAR column and reconstruct the ZonedDateTime on read.
// Option A: Store as UTC Instant — lose the user's local timezone (fine for most cases)
@Column(name = "event_time")
private Instant eventTime; // always UTC
// Option B: Store UTC timestamp + user timezone as a separate string column
@Column(name = "event_time_utc")
private Instant eventTimeUtc;
@Column(name = "user_timezone", length = 50)
private String userTimezone; // e.g. "America/New_York"
// Reconstruct for display:
public ZonedDateTime getEventTimeInUserZone() {
return eventTimeUtc.atZone(ZoneId.of(userTimezone));
}
AI Prompts for java.time Migration
Migrate a Legacy Entity Class
Here is a Java entity class that uses java.util.Date and @Temporal annotations: [paste the class here]. Migrate it to use java.time types. Replace each java.util.Date field with the most semantically correct java.time type — Instant for timestamps, LocalDate for date-only fields, LocalTime for time-only fields, ZonedDateTime for timezone-aware user-facing events. Remove all @Temporal annotations. Add a @PrePersist and @PreUpdate method that sets audit timestamps using Instant.now(). Show the complete before and after class.
What it does: Produces a complete before-and-after migration of a JPA entity, replacing every legacy temporal field with the correct java.time type, removing @Temporal, and adding lifecycle callback methods for automatic timestamp management.
When to use it: When migrating your persistence layer — run this for each entity class. The output is ready to paste; just verify the chosen types match your DB schema column types.
Replace SimpleDateFormat with DateTimeFormatter
Here is a Java class that uses SimpleDateFormat: [paste the class here]. Replace every SimpleDateFormat with an equivalent DateTimeFormatter. Make formatters static final constants since they are immutable and thread-safe. Replace every call to sdf.format(date) with the appropriate java.time format call. Replace every call to sdf.parse(str) with LocalDate.parse() or LocalDateTime.parse(). If the method parses user input, wrap in a try-catch for DateTimeParseException and return Optional.
What it does: Converts all SimpleDateFormat usage to thread-safe DateTimeFormatter, updates call sites to use the correct java.time parse/format methods, and adds safe error handling for user input parsing.
When to use it: When your code review or static analysis flags thread-safety risks from shared SimpleDateFormat instances, or when migrating service-layer code to java.time before touching the persistence layer.
Generate a Date Conversion Utility Class
Generate a Java utility class called DateConverter with static methods for every common bidirectional conversion between java.util.Date and java.time types: Date to/from Instant, LocalDate, LocalDateTime, and ZonedDateTime. Also include java.sql.Date to/from LocalDate and java.sql.Timestamp to/from Instant. Add Javadoc to every method explaining the zone handling behaviour. Include JUnit 5 round-trip tests for each method pair.
What it does: Generates a production-ready utility class with all conversion bridges, complete Javadoc, and a test class verifying every round-trip — useful as an adapter layer during an incremental migration where both old and new code coexist.
When to use it: At the start of a large migration when you cannot convert everything at once. The utility class serves as the bridge layer between legacy code that still returns Date and new code that expects Instant or LocalDate.
Audit a Codebase for Legacy Date Usage
Here is a list of Java files in our codebase: [paste file paths or code snippets]. For each file, identify every usage of java.util.Date, java.util.Calendar, java.sql.Date, java.sql.Timestamp, SimpleDateFormat, and @Temporal annotations. For each occurrence, classify the migration complexity as LOW (direct type swap), MEDIUM (requires zone handling decision), or HIGH (interop with external system or legacy API). Output a prioritised migration backlog sorted by risk and complexity.
What it does: Produces a structured migration backlog from a codebase audit, classifying each legacy date usage by complexity and generating a risk-ordered list suitable for sprint planning.
When to use it: Before starting a migration sprint to scope the effort, identify hidden complexity, and avoid surprises when touching files that interact with external APIs or databases that still require legacy types.
Convert Calendar Arithmetic to java.time
Here is a Java method that uses Calendar arithmetic to add/subtract time, compare dates, or extract date components: [paste the method here]. Rewrite it using java.time equivalents. Use LocalDate for date-only arithmetic, Instant for timestamp arithmetic, and Duration/Period for differences. Use TemporalAdjusters for first-day-of-month or next-weekday patterns. Replace all Calendar mutation with the immutable chained-method style of java.time.
What it does: Rewrites Calendar-based date arithmetic to equivalent java.time operations, replacing mutable Calendar mutation with immutable value-return methods, and choosing the correct type (Duration vs Period) for each calculation.
When to use it: When migrating business logic that does date arithmetic — particularly code that adds months or years, calculates billing periods, or finds the start/end of reporting windows.
See Also
📘 Mastering Hibernate 7 Date & Time Mapping — the complete guide to mapping java.time types to SQL columns in Hibernate 7, including @TimeZoneStorage, dialect nuances, and JDBC 4.2 bindings
📘 Jackson 2 to Jackson 3 Migration Guide — covers JavaTimeModule auto-registration in Jackson 3, JsonMapper.builder(), and serialisation of java.time types with zero configuration
📘 Spring Data JPA: Complete Guide — repository method signatures, @Query with Instant and LocalDate parameters, and JPQL temporal predicates
Conclusion
Migrating from java.util.Date and Calendar to java.time is one of the highest-ROI refactors you can make in a Java codebase. The conversion patterns are mechanical once you understand the type mapping table: Date → Instant via toInstant(), with a zone added when you need a calendar date. Calendar → ZonedDateTime via toInstant().atZone(cal.getTimeZone().toZoneId()). java.sql.Date → LocalDate via toLocalDate(). SimpleDateFormat → DateTimeFormatter as a static constant.
The three decisions that matter most: choose Instant over LocalDateTime for anything that crosses a timezone boundary; always be explicit about the ZoneId in conversion code rather than relying on ZoneId.systemDefault(); and set hibernate.jdbc.time_zone=UTC the moment you start writing temporal fields to a database. Those three rules prevent the most common production bugs in migrated codebases.
The migration does not need to happen all at once. The bridge methods (toInstant(), Date.from(), toLocalDate()) make it safe to migrate file by file, layer by layer, with old and new code coexisting throughout. Start with the entities, then the formatters, then the business logic, and the migration will proceed without breakage.
Further Reading
🔗 Java 21 java.time Package Javadoc — Oracle
🔗 JSR-310 Date and Time API — JCP
🔗 ThreeTen Extra — additional date-time types beyond the JDK