Java Date Time API: A Practical Guide
java date time api: Learn how to use the java.time API for dates, times, time zones, formatting, and arithmetic in real Java projects.
The java date time api, introduced in Java 8, replaced the flawed java.util.Date and java.util.Calendar classes. It provides a comprehensive model for dates, times, instants, durations, and time zones, designed with clarity and thread safety in mind. Unlike the old mutable classes, every type in java.time is immutable, which makes them safe to share across threads without synchronization. This guide focuses on the core classes you will use daily, how they interact, and where common mistakes happen.
Core Classes in java.time
The java.time package is organized around a few fundamental types. LocalDate represents a date without time or time zone, such as 2025-04-14. LocalTime represents a time without date or time zone, such as 14:30:00. LocalDateTime combines both, but still has no time zone. ZonedDateTime adds a time zone, while OffsetDateTime adds a fixed offset from UTC. Instant represents a point on the timeline in UTC, suitable for machine timestamps.
| Type | Contains Date | Contains Time | Contains Zone/Offset | Typical Use |
|---|---|---|---|---|
LocalDate | Yes | No | No | Birthdays, holidays |
LocalTime | No | Yes | No | Opening hours |
LocalDateTime | Yes | Yes | No | Local event scheduling |
ZonedDateTime | Yes | Yes | Zone ID | Global meeting times |
OffsetDateTime | Yes | Yes | Fixed offset | API timestamps |
Instant | Yes | Yes | UTC only | Logging, auditing |
Choose LocalDate when you only need a calendar date, and ZonedDateTime when the instant must be unambiguous across time zones. Using LocalDateTime for a global event is a common mistake because it lacks zone information.
Creating and Manipulating LocalDate, LocalTime, and LocalDateTime
You create instances using factory methods rather than constructors. The now() method captures the current value, while of() lets you specify exact fields.
LocalDate today = LocalDate.now(); LocalDate specificDate = LocalDate.of(2025, 4, 14); LocalTime now = LocalTime.now(); LocalTime meetingTime = LocalTime.of(14, 30); LocalDateTime currentDateTime = LocalDateTime.now(); LocalDateTime specificDateTime = LocalDateTime.of(2025, 4, 14, 14, 30);
These types are immutable. Methods like plusDays, minusMonths, or withYear return a new instance instead of modifying the original.
LocalDate nextWeek = today.plusDays(7); LocalDate lastMonth = today.minusMonths(1); LocalDate sameDayNextYear = today.withYear(2026);
Because the objects are immutable, you must assign the result to a variable. Ignoring the return value is a frequent bug.
// Wrong: today is not changed today.plusDays(7); // Correct: assign the result LocalDate nextWeek = today.plusDays(7);
For date arithmetic that crosses month boundaries, plusMonths and plusYears handle variable-length months correctly. LocalDate.of(2025, 1, 31).plusMonths(1) yields 2025-02-28 because February has no 31st.
Working with Time Zones: ZonedDateTime and OffsetDateTime
A time zone is more than a fixed offset. It includes rules for daylight saving time (DST). ZoneId represents a region such as Europe/Paris, while ZoneOffset represents a fixed offset like +02:00. ZonedDateTime uses a ZoneId and adjusts for DST automatically. OffsetDateTime uses a ZoneOffset and is often used in APIs that need a fixed offset.
ZoneId zone = ZoneId.of("Europe/Paris"); ZonedDateTime parisTime = ZonedDateTime.of(2025, 4, 14, 14, 30, 0, 0, zone); ZoneOffset offset = ZoneOffset.ofHours(2); OffsetDateTime fixedOffset = OffsetDateTime.of(2025, 4, 14, 14, 30, 0, 0, offset);
Converting between zones is straightforward with withZoneSameInstant. This preserves the underlying instant and adjusts the local clock.
ZonedDateTime newYorkTime = parisTime.withZoneSameInstant(ZoneId.of("America/New_York"));
DST transitions can cause gaps and overlaps. For example, when clocks spring forward, a local time like 02:30 may not exist. ZonedDateTime resolves these cases according to the ZoneRules, but you should be aware that LocalDateTime alone cannot represent an instant without a zone.
Instant and Machine Time
Instant is the class to use for timestamps, logging, and persistence. It represents a point on the timeline in UTC with nanosecond precision. You can get the current instant with Instant.now().
Instant now = Instant.now(); Instant epoch = Instant.EPOCH; // 1970-01-01T00:00:00Z
Converting between Instant and ZonedDateTime is common when you need to display a timestamp in a user's local time.
ZonedDateTime zdt = instant.atZone(ZoneId.of("Asia/Tokyo")); Instant backToInstant = zdt.toInstant();
Because Instant is always UTC, it avoids the ambiguity of local times. Store Instant in databases and send it over the wire when you need a precise, time-zone-independent value.
Formatting and Parsing with DateTimeFormatter
DateTimeFormatter handles conversion between date-time objects and strings. The default ISO_LOCAL_DATE format is 2025-04-14, but you often need custom patterns.
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"); LocalDateTime dt = LocalDateTime.of(2025, 4, 14, 14, 30); String formatted = dt.format(formatter); // "14/04/2025 14:30"
Parsing works in reverse. The same formatter can parse a string back into a LocalDateTime.
LocalDateTime parsed = LocalDateTime.parse("14/04/2025 14:30", formatter);
Pattern letters are case-sensitive. yyyy is the year, MM is the month, dd is the day, HH is the hour in 24-hour format, and mm is minutes. A common mistake is using mm for hours or DD for day-of-year instead of dd. When parsing, the formatter must match the input exactly, including leading zeros.
For ZonedDateTime, include zone information in the pattern, such as "yyyy-MM-dd HH:mm z" for a short zone name or "yyyy-MM-dd HH:mm XXX" for an offset.
DateTimeFormatter zonedFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z"); ZonedDateTime zdt = ZonedDateTime.parse("2025-04-14 14:30 CET", zonedFormatter);
Duration and Period for Date-Time Arithmetic
Duration measures time in seconds and nanoseconds, suitable for LocalTime, Instant, and ZonedDateTime. Period measures time in years, months, and days, suitable for LocalDate.
Duration duration = Duration.between(startTime, endTime); long seconds = duration.getSeconds(); Period period = Period.between(startDate, endDate); int months = period.getMonths();
Adding a Duration to a ZonedDateTime works on the timeline, but adding a Period works on the calendar. This distinction matters across DST boundaries. Adding Duration.ofDays(1) to a time just before a DST shift results in a different local time, while adding Period.ofDays(1) keeps the same local time.
ZonedDateTime beforeDST = ZonedDateTime.of(2025, 3, 29, 12, 0, 0, 0, ZoneId.of("Europe/Paris")); ZonedDateTime afterDuration = beforeDST.plus(Duration.ofDays(1)); // local time becomes 13:00 ZonedDateTime afterPeriod = beforeDST.plus(Period.ofDays(1)); // local time stays 12:00
Use Duration for machine-level measurements and Period for human-readable calendar arithmetic.
Common Pitfalls and Compatibility Concerns
The java.time API is not directly compatible with java.util.Date and Calendar. You must convert explicitly. For legacy code, use Date.toInstant() and Date.from(Instant).
Date legacyDate = new Date(); Instant instant = legacyDate.toInstant(); Date newLegacy = Date.from(instant);
For JDBC, modern drivers accept java.time types directly. PreparedStatement.setObject and ResultSet.getObject work with LocalDate, LocalDateTime, and OffsetDateTime. Older drivers may require java.sql.Timestamp, which you can convert via Timestamp.valueOf(LocalDateTime).
Thread safety is a major improvement. All java.time types are immutable and thread-safe, so you can share a DateTimeFormatter across threads without concern. The old SimpleDateFormat was not thread-safe and caused subtle concurrency bugs.
One operational concern is time zone data. The ZoneId rules are loaded from the system's time zone database. If your application runs in a container with an outdated database, DST transitions may be wrong. Keep the JVM's time zone data up to date, especially for long-running services.
Another pitfall is mixing LocalDateTime with a zone implicitly. A LocalDateTime is not a point on the timeline. To convert it to an instant, you must supply a zone, which requires knowing the user's context. Storing LocalDateTime without zone information for a global event will lead to incorrect scheduling when the event is viewed from another region.
Finally, be careful with DateTimeFormatter patterns that are locale-sensitive. The default locale may produce unexpected month or day names. Use DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH) when you need consistent output across environments.
Choosing the right type from the start avoids many conversion headaches. Prefer Instant for machine timestamps, LocalDate for calendar dates, and ZonedDateTime for user-facing times that must respect DST. The java date time api gives you the tools to model each of these precisely, and understanding the distinctions is the key to writing correct, maintainable date and time logic.