Java Date and Time API Introduction
An introduction to the modern Java date and time API in java.time, replacing the legacy Date and Calendar classes.
Java Date and Time API Introduction
Java 8 added java.time, a new package for representing dates, times, durations, time zones, and the arithmetic between them. It replaced two previous APIs — java.util.Date and java.util.Calendar — that had a deserved reputation as the worst-designed corner of the JDK. The new API was driven by Stephen Colebourne's earlier Joda-Time library; if you've used Joda, java.time will feel familiar.
The two important things about the redesign:
- Every type is immutable. A
LocalDateonce created never changes. Methods likeplusDays(7)return a newLocalDate. That makes the API thread-safe by construction and removes a whole category of bugs. - Each type means one thing.
LocalDateis a date with no time.Instantis a moment on the timeline.Durationis a length of time. The legacyDatewas somehow all of these at once, depending on which constructor you used; the new API splits them apart so the type tells you what kind of value you have.
This chapter is the map. The next ten chapters drill into each class.
The core types
"A date" LocalDate 2025-11-04
"A time of day" LocalTime 14:30:00
"Both, no zone" LocalDateTime 2025-11-04T14:30:00
"Both, with zone" ZonedDateTime 2025-11-04T14:30:00-05:00 [America/New_York]
"A moment" Instant 2025-11-04T19:30:00Z (UTC, seconds-since-epoch)
"A length of time" Duration PT1H30M (1 hour 30 minutes)
"A length of date" Period P1Y2M3D (1 year 2 months 3 days)The horizontal split — Local* vs Zoned/Instant — is the most important one. Local types carry no time zone. A LocalDate of 2025-11-04 is "the fourth of November"; it does not say whether that's the fourth in Tokyo or in Honolulu. It's the right type for a birthday, a contract date, or a UI date picker.
Zoned types carry their zone. ZonedDateTime is "this calendar instant in this place," which is what you want for "meeting scheduled for 9 AM in New York." Instant is a moment on the global timeline — UTC seconds since the epoch — which is what you want for logging, message timestamps, anything that needs to be globally ordered without needing local labels.
The horizontal split between Duration and Period matters too. Duration is a length of time you can compare in seconds — PT24H is exactly 24 × 3600 seconds. Period is a length expressed in calendar terms — P1M (one month) is 30 days in some months and 31 in others. For timing measurements, you want Duration. For "add one month to a billing date," you want Period.
The fluent shape
Every type is built and modified through a consistent fluent API:
LocalDate today = LocalDate.now();
LocalDate stardate = LocalDate.of(2025, 11, 4);
LocalDate parsed = LocalDate.parse("2025-11-04");
LocalDate nextWeek = today.plusDays(7); // immutable: returns a NEW LocalDate
LocalDate lastYear = today.minusYears(1);
LocalDate firstOfMonth = today.withDayOfMonth(1); // with* returns a copy with one field changed
boolean before = today.isBefore(stardate);
int year = today.getYear();Three shapes you'll see everywhere:
now()— current value from the system clock.of(...)— explicit components.parse(...)— from a string (ISO-8601 by default).
And for transformations:
plusX(n)/minusX(n)— arithmetic.withX(value)— replace a single field.isBefore(other)/isAfter(other)— comparison.
This shape repeats across LocalDate, LocalTime, LocalDateTime, ZonedDateTime, and Instant. Once you know the pattern, every class is reading you the same dialect with a slightly different vocabulary.
Time zones are hard, and the API admits it
The single biggest reason java.util.Date was painful is that it tried to make time zones invisible. The result was the famous bug class of "store a Date, retrieve it on a server in a different time zone, get the wrong calendar date." java.time solves this by making the zone explicit in the type.
If you accept a date from a user and don't know what zone they're in, store it as a LocalDate. If they tell you it's "9 AM their time" and you know their zone, store it as a ZonedDateTime with the zone. If you log a server event, store it as an Instant. Don't store a LocalDateTime and hope the time zone comes through; the missing zone is the whole bug.
Instant now = Instant.now(); // unambiguous: a moment in UTC
ZonedDateTime localized = now.atZone(ZoneId.of("Europe/Berlin")); // a label for that moment in BerlinThe zone hierarchy:
ZoneOffsetis a fixed±HH:MMoffset from UTC:+05:30,-08:00. No DST handling.ZoneIdis a named zone:Europe/Berlin,America/New_York. Carries the IANA database's record of what offset that zone has on any given day, including DST transitions and historical changes.
Always prefer ZoneId over ZoneOffset when you have a choice. "America/New_York" is correct across DST; "−05:00" is correct only outside DST.
The legacy types haven't gone away
java.util.Date, java.util.Calendar, and java.text.SimpleDateFormat still exist. New code shouldn't use them — but plenty of old code does, and you'll need to interoperate. The conversion methods are direct:
// java.util.Date <-> java.time.Instant
Instant inst = legacyDate.toInstant();
Date back = Date.from(inst);
// java.util.Calendar -> java.time.ZonedDateTime
ZonedDateTime zdt = ZonedDateTime.ofInstant(
cal.toInstant(), cal.getTimeZone().toZoneId());The pattern is one-way: legacy → java.time is straightforward; for anything new, stay in java.time and convert only at the API boundary where the old code lives. The Legacy Date and Calendar chapters at the end of this part cover the bridge in detail.
A worked example: the family of types in one program
The program below uses every type the map above introduced — LocalDate, LocalTime, LocalDateTime, ZonedDateTime, Instant, Duration, Period — and shows how they convert into one another. It's the "tour" version; each individual type gets its own chapter from here on.
What to take from the run:
- The first block built up the family by composition:
LocalDate+LocalTime=LocalDateTime;LocalDateTime+ZoneId=ZonedDateTime;ZonedDateTime→Instant. That's the lattice of conversions, and you'll do these every time you cross an API boundary. The arrows go both ways for most pairs —Instant.atZone(zone)andZonedDateTime.toLocalDateTime()close the loops. - One
Instantprinted three different "looking" times when viewed from New York, Berlin, and Tokyo. That's the point ofInstant: it's the moment, independent of where you're standing. TheZonedDateTimeadds the "where I'm standing" label. Confusing the two is the legacyDatemistake. Durationprinted asPT1H30MandPeriodprinted asP3M. The ISO-8601 duration format isPnYnMnDTnHnMnS— anything before theTis calendar units (Period), anything after is time units (Duration). The string is verbatim whattoString()returns, and verbatim whatparse(...)accepts.today.plusDays(7)produced a differentLocalDate. Printingtodayagain right after showed the original was unchanged — that's the immutability guarantee. Everyplus/minus/withreturns a new object; the receiver is never modified. No defensive copying, no thread-safety concerns, ever.ChronoUnit.DAYS.between(today, launch)was the "distance" operation. It returns along, not aPeriod, because the answer in days has no calendar ambiguity (unlike months, which vary in length). Every chapter in this part usesChronoUnitsomewhere — it's the catalog of time units the API talks about.
What's next
The next chapter, Java LocalDate, starts the depth tour. LocalDate is the simplest of the five "point in time" types and the right place to learn the fluent shape that all the others share.
Practice
You need to store the moment a server received an HTTP request, so that the log can be sorted globally across servers in different time zones. Which `java.time` type fits?