Date and Time API in Java (java.time)
Executive Summary
The Date and Time API in java.time splits time into human and machine views. For example, LocalDate, LocalTime, and LocalDateTime describe wall-clock values with no zone, right for dates of record like birthdays and due dates. ZonedDateTime attaches a ZoneId, correct for user-facing scheduled events, especially across daylight saving transitions. By contrast, Instant is a point on the UTC timeline, the correct type for system timestamps, logs, and persistence. Similarly, Duration measures machine time, hours and seconds, while Period measures calendar time, years, months, days. Every type is immutable: plusDays and friends return new values, so sharing is safe. Also, the ISO-8601 format is the default parse and print, and DateTimeFormatter states custom patterns explicitly. Never use java.util.Date or Calendar in new code; convert at legacy and persistence boundaries instead.
In practice, store Instants for machine events, LocalDates for calendar facts, and ZonedDateTime for meetings. Then choose Duration or Period by asking whether the answer changes when the calendar intervenes.
Why the Date and Time API Exists: The Legacy It Replaced
You will meet java.util.Date in every legacy codebase, so you need to know why it earned its retirement. One table says most of it:
| Legacy problem | Date / Calendar behavior | java.time answer |
|---|---|---|
| Mutability | setTime mutates a shared Date | All types immutable: operations return new values |
| Zones | Almost no zone awareness | ZonedDateTime, ZoneId, OffsetDateTime as first-class types |
| Absurd offsets | Years offset from 1900, months zero-based | Calendar-true values, Month enum, no hidden arithmetic |
| Formatting | SimpleDateFormat is not thread-safe | DateTimeFormatter is immutable and shareable |
| Ambiguity | One type means date, time, and instant | A type per meaning: LocalDate, LocalTime, Instant |
// WRONG: legacy code, three bugs hiding in six lines
Date d = new Date(126, 9, 31); // 2026-10-31? Year 1900+126, month "9" is OCTOBER
Calendar cal = Calendar.getInstance();
cal.setTime(d);
cal.add(Calendar.MONTH, 1); // mutates cal in place: shared state
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
// and fmt is not thread-safe: reuse it across threads and watch the corruption
// RIGHT: the same meaning, stated
LocalDate d = LocalDate.of(2026, Month.OCTOBER, 31);
LocalDate nextMonth = d.plusMonths(1); // new value: safe to share
DateTimeFormatter fmt = DateTimeFormatter.ISO_LOCAL_DATE; // immutable
In short, the legacy code hides arithmetic in constructors, mutates in place, and shares a formatter that corrupts under threads. In contrast, the java.time version reads like the calendar it describes.
The Territory: Every Type and Its Job
The java.time package summary organizes the API along two axes, human wall-clock time versus machine timeline time, plus amounts. As a result, this map is the article’s core mental model:
HUMAN TIME (wall clock) MACHINE TIME (timeline)
LocalDate 2026-10-31 Instant 2026-10-31T10:15:00Z
LocalTime 10:15:30 (always UTC, nanoseconds)
LocalDateTime 2026-10-31T10:15:30
| |
+ attach a zone: + read in any zone:
ZonedDateTime 2026-10-31T10:15:30+02:00[Europe/Berlin]
OffsetDateTime 2026-10-31T10:15:30+02:00
AMOUNTS:
Duration machine amount: "PT1H30M" (1 hour 30 minutes)
Period calendar amount: "P3M" (3 months)
| Type | Holds | Use for |
|---|---|---|
| LocalDate | Year, month, day | Birthdates, due dates, holidays |
| LocalTime | Hour to nanosecond | Opening hours, alarms |
| LocalDateTime | Both, no zone | Wall-clock stamps where the zone is external and fixed |
| ZonedDateTime | Date, time, and ZoneId | Meetings, scheduled jobs shown to users |
| Instant | UTC point on the timeline | System timestamps, logs, database records |
| Duration | Time-based amount | Timeouts, elapsed time, TTLs |
| Period | Date-based amount | Ages, “3 months”, subscription windows |
Month and DayOfWeek, which you met in the EnumMap example of the enums article, are the enum constants behind these types. For example, LocalDate.of takes Month.OCTOBER, not a magic 9, which retires an entire category of legacy off-by-one.
Creating, Reading, and Calculating
The factories are consistent across every type: now() for the current value, of() for a specific one, parse() for ISO text. The LocalDate documentation shows the pattern that all the others follow:
LocalDate today = LocalDate.now();
LocalDate launch = LocalDate.of(2026, Month.OCTOBER, 31); // Month enum, never 9
LocalDate fromText = LocalDate.parse("2026-10-31"); // ISO by default
LocalTime opening = LocalTime.of(9, 0);
Instant now = Instant.now(); // UTC, always
// immutability: every calculation returns a new value
LocalDate due = launch.plusWeeks(2); // a new LocalDate
LocalDate holiday = launch.withDayOfMonth(1); // adjust within the month
boolean isBefore = today.isBefore(due); // comparisons: isBefore, isAfter, equals
The arithmetic rule that follows from immutability is also the same one the records article taught for value objects. Here, launch never changes, so every variable holding it stays valid. In the legacy Calendar code, cal.add mutated in place and every alias mutated with it. However, the java.time shape makes that class of bug impossible rather than merely discouraged.
For measuring between values, meanwhile, two tools cover the two scales. ChronoUnit computes the distance in one unit; Duration and Period hold amounts you can add:
long daysLate = ChronoUnit.DAYS.between(dueDate, today); // single-unit distance
Duration elapsed = Duration.between(start, end); // machine amount
System.out.println(elapsed.toMillis() + " ms"); // 125 ms
Period age = Period.between(birthday, LocalDate.now()); // calendar amount
System.out.println(age.getYears() + " years"); // 36 years
Zones and Instants: Where the Money Hides
A ZonedDateTime is a wall-clock time plus a ZoneId. That is what makes it the correct type for scheduled events people will see. The zone is not decoration. Instead, it encodes the daylight saving rules that decide whether 2:30 AM exists once, twice, or zero times on a given morning.
ZonedDateTime meeting = ZonedDateTime.of(2026, 11, 2, 9, 0, 0, 0,
ZoneId.of("Europe/Berlin"));
Instant asUtc = meeting.toInstant(); // the same moment, machine view
ZonedDateTime inTokyo = asUtc.atZone(ZoneId.of("Asia/Tokyo")); // 17:00 same day
The conversion chain, wall time to Instant to any zone, is the whole model. In other words, an Instant is the truth on the timeline, and zones are views of it. As a result, that yields the two rules that prevent most production date bugs. Store Instants for machine events, created_at, updated_at, log lines, because UTC composes and orders safely. Store ZonedDateTime, or an Instant plus the intended zone, for future events people will attend. That is because the zone’s rules, not your arithmetic, decide when the moment arrives. In contrast, a LocalDateTime for a future global event is a bug with a type name. It says 9 AM without saying where, and half the world disagrees.
Formatting and Parsing: State the Pattern
By default, the format is ISO-8601, which sorts as text, parses without configuration, and matches what logs and APIs exchange. Custom patterns come from DateTimeFormatter, an immutable object you can share freely, unlike the legacy SimpleDateFormat. For example, here is the wrong code first, the boundary shape every ingestion pipeline needs:
// WRONG: assuming the input format and hoping
LocalDate d = LocalDate.parse("10/31/2026"); // DateTimeParseException:
// ISO expects yyyy-MM-dd
// RIGHT: state the pattern, catch the typed failure, wrap into your domain
try {
LocalDate d = LocalDate.parse("10/31/2026",
DateTimeFormatter.ofPattern("MM/dd/yyyy"));
} catch (DateTimeParseException e) {
throw new ImportException("cannot parse date: 10/31/2026", e);
}
// formatting out, for humans
var human = DateTimeFormatter.ofPattern("dd MMM yyyy");
System.out.println(dueDate.format(human)); // 14 Nov 2026
The delta: parse failures are typed and predictable. DateTimeParseException is caught at the boundary and wrapped, exactly the pattern the exceptions article‘s parse example established. The DateTimeFormatter documentation lists the pattern letters, but you need about six of them (yyyy, MM, dd, HH, mm, ss) for most systems, and ISO for everything else.
The Complete Example: Loan Due Dates
import java.time.LocalDate;
import java.time.Month;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
public class LoanDates {
public static void main(String[] args) {
var borrowed = LocalDate.of(2026, Month.OCTOBER, 18);
var due = borrowed.plusWeeks(2); // 2026-11-01
var today = LocalDate.of(2026, Month.OCTOBER, 25); // fixed for the demo
var human = DateTimeFormatter.ofPattern("dd MMM yyyy");
System.out.println("borrowed: " + borrowed.format(human));
System.out.println("due: " + due.format(human));
if (today.isAfter(due)) {
long late = ChronoUnit.DAYS.between(due, today);
System.out.println("overdue by " + late + " days");
} else {
long left = ChronoUnit.DAYS.between(today, due);
System.out.println(left + " days left");
}
}
}
borrowed: 18 Oct 2026
due: 01 Nov 2026
7 days left
First, notice what never appears: no Calendar, no mutation, no zone guesswork, and no magic 9 for October. As a result, the code reads like the librarian’s rule it implements: borrowed date, plus two weeks, compared against today.
How Real Systems Do This
java.time is the default time model across the entire modern Java ecosystem. Also, the boundaries are already visible in this course’s future. Part 8’s JDBC chapters map Instant and Timestamp at the persistence edge. Similarly, the HTTP and JSON article shows ISO-8601 as the wire format, the same string LocalDate.parse reads with no configuration. Likewise, the CSV practice build in this part parses a date column with exactly the boundary pattern above.
The daylight saving story deserves its production slot. After all, it is the date bug everyone learns about twice: once reading about it, once in an incident channel. In my experience, the memorable one was a scheduler that ran a 2:30 AM job on a LocalDateTime. On the fall-back night, 2:30 AM occurs twice, so the job fired twice and the downstream report doubled. The fix was one type change: ZonedDateTime with the job’s real zone, which contains the transition rules. We also added a guard on the instant. The lesson generalizes into this article’s two storage rules. Machine moments are Instants, human wall-clock events are zone-attached, and LocalDateTime is for neither of those jobs.
Duration also anchors Part 5, where pool timeouts, cache TTLs, and await calls all take Duration objects. This keeps time units explicit instead of implied in a bare long. TimeUnit made unit confusion possible, whereas Duration makes it a compile-time choice.
Decision Framework
- Is the value a calendar fact: a birthday, a due date, a holiday? LocalDate, no zone, no time.
- Is it a machine moment: a timestamp, a log entry, a created_at column? Instant, UTC, always.
- Is it an event a person will attend, with a wall-clock time in a known place? ZonedDateTime, so the zone’s rules govern the moment.
- Is it a wall-clock stamp where the zone is external and fixed, like a shop-local opening time? LocalDateTime or LocalTime, with the zone carried elsewhere.
- Is it an amount of time? Duration if machine scale (timeout, elapsed), Period if calendar scale (months of subscription). If the answer changes when the calendar intervenes, it is a Period.
- Are you converting at a legacy or database boundary? Convert at the edge, use Date.from and Timestamp.from, and keep java.time inside your code.
When NOT to Use This
- Do not use java.util.Date or Calendar in new code. They remain only for legacy interop and database driver conversions. Meanwhile, every line of new logic written against them reimports the bugs java.time retired.
- Do not use LocalDateTime for future global events or scheduled jobs. Without a zone, it is a time that means different moments in different places. Worse, exactly once a year it means two moments in one place.
- Do not store epoch millis as business fields without a conversion discipline. A bare long is the new magic number: unit-less, zone-less, and unreadable in reviews. Instead, wrap it in Instant at the boundary.
Common Mistakes
- Assuming plusDays(1) is always 24 hours across DST transitions. In fact, ZonedDateTime handles the calendar truth, and Duration arithmetic assumes machine hours. Therefore, choose by the meaning, not the convenience.
- Sharing a SimpleDateFormat in legacy code: it is not thread-safe, and the corruption appears only under load. DateTimeFormatter is immutable and shares freely.
- Parsing user input without stating the pattern: ISO defaults surprise every developer once. DateTimeParseException is the kind surprise; locale-dependent formats are the unkind one.
- Storing timestamps as LocalDateTime because the server’s zone happens to be right today. Then the first server migration or container with UTC defaults turns every historical row into a different moment.
- Comparing times across types by converting through strings: format then parse is the legacy round trip; toInstant and atZone are the conversions.
- Calling LocalDate.now() on a server and assuming it is the user’s today. After all, a server in UTC flips its date hours before parts of the world do. The user’s zone decides their today.
Key Takeaways
- The Date and Time API replaces Date and Calendar with immutable value types and explicit zones, so new code never touches the legacy classes except at boundaries.
- LocalDate for calendar facts, Instant for machine moments, ZonedDateTime for zone-attached human events, Duration for machine amounts, Period for calendar amounts.
- Every operation returns a new value: plusDays, withDayOfMonth, and comparisons never mutate, so sharing date objects is always safe.
- Month and DayOfWeek are enums, so the zero-based-month bug is a museum piece, and DateTimeFormatter is immutable and thread-safe.
- ISO-8601 is the default parse and print format, sorts as text, and is the same wire format REST APIs exchange.
- Store Instants for system timestamps and zone-attached values for scheduled events, because a LocalDateTime for a future global event is a bug with a type name.
- Parse with an explicit pattern at boundaries and wrap DateTimeParseException into your domain exception, exactly like any other boundary failure.
FAQ
What is java.time in Java?
The modern Date and Time API introduced in Java 8: immutable types like LocalDate, Instant, and ZonedDateTime, explicit time zones, and thread-safe formatting. It replaced the mutable, zone-blind java.util.Date and Calendar.
What is the difference between Instant and LocalDateTime?
An Instant is a point on the UTC timeline, right for timestamps and machine events. A LocalDateTime is a wall-clock reading with no zone, right only where the zone is external and fixed; it names a time, not a moment.
Should I use LocalDate or LocalDateTime?
Use LocalDate when the time of day is irrelevant to the domain: due dates, birthdays, holidays. Use LocalDateTime only when the wall-clock time matters and the zone is genuinely external, like a shop-local schedule. For machine moments, use Instant instead of both.
What is the difference between Duration and Period?
Duration measures machine time: hours, minutes, seconds, right for timeouts and elapsed time. Period measures calendar time: years, months, days, right for ages and subscription windows, where a month is a calendar month and not a fixed number of hours.
How do I format a LocalDate in Java?
format with a DateTimeFormatter: date.format(DateTimeFormatter.ofPattern(“dd MMM yyyy”)). Formatters are immutable and reusable, the ISO format needs no formatter at all, and the same pattern letters parse in reverse via LocalDate.parse.
Conclusion
java.time gives dates the same treatment records gave data. It offers immutable values, explicit meaning per type, and bugs converted from runtime surprises into compile-time choices. The type names are the documentation. Meanwhile, the two storage rules, Instants for machines and zone-attached times for humans, prevent the incidents calendars otherwise guarantee.
Next, the following article tours the JDK’s utility shelf: Objects, Math, Random, and UUID. These small static helpers appear in every codebase and deserve to be known by name rather than reinvented by accident.
Pick the type that says where the value lives: on the calendar, on the timeline, or on a wall in one particular place.
Last updated on 17 September 2026.
