Date & Time API
In this lesson we'll cover Java's date/time machinery — the java.time package —
end to end: from LocalDate to Instant, from how time zones actually work to how to
interoperate with the legacy Date/Calendar API. Date and time looks simple at first
glance but quickly gets complicated with details like time zones and Daylight Saving
Time (DST) — so we'll close this lesson with a clear answer to "which class should I
use, and when?"
What Is the Date & Time API?
The java.time package is a family of classes — all immutable — that represent a
date, a time, a combination of the two, or a point on the timeline. The four most
fundamental ones: LocalDate (date only), LocalTime (time only), LocalDateTime
(both combined), and Instant (a single, unambiguous point on the universal timeline,
with no time zone involved):
LocalDate today = LocalDate.now(); // 2026-08-10
LocalTime now = LocalTime.now(); // 14:32:07
LocalDateTime dateTime = LocalDateTime.now(); // 2026-08-10T14:32:07
Instant instant = Instant.now(); // 2026-08-10T11:32:07Z (UTC)
The "Local" prefix can be confusing — what's "local" here isn't the time zone a user happens to be in, it's that the class carries no time zone information at all. We'll see why that distinction matters in "ZonedDateTime and the Time Zone Concept."
Why Does It Exist?
A real-world example: imagine a user in Istanbul schedules a meeting for 15:00, and a
participant in New York needs to see it in their own local time (07:00 EDT). Doing
this correctly requires three separate pieces of information: an absolute point in time
(the "real" moment everyone agrees on), a time zone's rules (what local time that
moment corresponds to in a given region), and a way to present both of those to the
user in a readable form. The reason java.time splits these into separate classes is
precisely to solve these three needs independently, without conflating them — we'll
walk through this end to end in the first mini project.
History
Java's original date/time API (java.util.Date, later Calendar) had been around
since 1996, but it carried serious design problems: Date was mutable, and its month
values started at 0 (January = 0); the Calendar API was overly complex; and
SimpleDateFormat wasn't thread-safe — we'll come back to all of these in "Migrating
from the Legacy API to java.time." These problems were common enough complaints that
Stephen Colebourne wrote the popular Joda-Time library; Joda-Time was successful
enough that it directly inspired Java's own official standard. Oracle brought
Colebourne on board to drive JSR-310, and the result shipped in Java 8 (2014) as the
java.time package — immutable, thread-safe, and built from scratch with a clear
separation of responsibilities.
LocalDate
LocalDate holds only a calendar date (year, month, day) — no time or time zone
information:
import java.time.LocalDate;
class LocalDateExample {
public static void main(String[] args) {
LocalDate today = LocalDate.now(); // varies depending on when you run this
System.out.println("Today: " + today);
LocalDate fixedDate = LocalDate.of(2026, 3, 15);
System.out.println("Fixed date: " + fixedDate);
LocalDate nextWeek = fixedDate.plusDays(7); // returns a NEW LocalDate, doesn't mutate fixedDate
System.out.println("A week later: " + nextWeek);
System.out.println("Original unchanged: " + fixedDate);
System.out.println("Day of week: " + fixedDate.getDayOfWeek());
System.out.println("Is 2026 a leap year? " + fixedDate.isLeapYear());
}
}
You can create a date directly with LocalDate.of(2026, 3, 15), and derive new dates
with methods like plusDays(...) — every plus/minus call, as with every
java.time class, never mutates the original, it returns a new LocalDate.
Methods like getDayOfWeek() and isLeapYear() handle calendar arithmetic (leap-year
rules, weekdays) correctly for you — trying to compute these by hand is a well-known
source of bugs.
LocalTime
LocalTime is LocalDate's time-of-day counterpart — it holds only a time within a
day (hour, minute, second, nanosecond), with no date or time zone information:
import java.time.LocalTime;
class LocalTimeExample {
public static void main(String[] args) {
LocalTime now = LocalTime.now(); // varies depending on when you run this
System.out.println("Now: " + now);
LocalTime openingTime = LocalTime.of(9, 0); // seconds and nanos default to 0
System.out.println("Opening time: " + openingTime);
LocalTime closingTime = openingTime.plusHours(8);
System.out.println("Closing time: " + closingTime);
System.out.println("Closing hour: " + closingTime.getHour());
System.out.println("Is opening time before noon? " + openingTime.isBefore(LocalTime.NOON));
}
}
A call like LocalTime.of(14, 30) implicitly assumes zero seconds and nanoseconds.
LocalTime's most typical use is expressing a recurring time that's independent of any
particular date — a business rule like "opens every day at 09:00" is modeled more
correctly with LocalTime than LocalDateTime, since it isn't tied to a specific
calendar day.
LocalDateTime
LocalDateTime combines LocalDate and LocalTime — in practice it's the most
commonly used java.time class, since most applications think about "when" as both a
date and a time together:
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
class LocalDateTimeExample {
public static void main(String[] args) {
LocalDate date = LocalDate.of(2026, 3, 15);
LocalTime time = LocalTime.of(14, 30);
LocalDateTime combinedA = date.atTime(time);
LocalDateTime combinedB = LocalDateTime.of(date, time);
System.out.println("Same result either way? " + combinedA.equals(combinedB));
System.out.println("Combined: " + combinedA);
System.out.println("Back to date: " + combinedA.toLocalDate());
System.out.println("Back to time: " + combinedA.toLocalTime());
}
}
LocalDate.atTime(LocalTime) and LocalDateTime.of(date, time) produce the same
result two different ways — which one you use depends on the pieces you already have.
toLocalDate() and toLocalTime() do the reverse: pulling the pieces back out of a
combined LocalDateTime.
Remember that LocalDateTime still carries no time zone information — a
LocalDateTime that says "15:00" has no idea whether that's Istanbul or New York.
Once you need to tie it to a time zone, move on to "ZonedDateTime and the Time Zone
Concept."
Instant
Instant represents a single point on the UTC timeline (as seconds and nanoseconds
since the January 1970 epoch) — it carries no notion of a "calendar day" or "time
zone," just a universal, unambiguous moment:
import java.time.Instant;
class InstantExample {
public static void main(String[] args) {
Instant now = Instant.now(); // varies depending on when you run this
System.out.println("Now: " + now);
Instant epoch = Instant.EPOCH;
System.out.println("Epoch: " + epoch);
Instant fixed = Instant.ofEpochSecond(1_700_000_000L);
System.out.println("Fixed instant: " + fixed);
Instant later = fixed.plusSeconds(3600); // one hour later
System.out.println("An hour later: " + later);
}
}
Instant is ideal for machine-to-machine communication: a log entry, a database
timestamp, or an event's "when did this actually happen" has exactly one correct
answer with Instant, completely independent of which time zone it's read in. That's
why, as we'll also see in "Real-World Examples: java.time in Spring Boot," storing a
timestamp in a database usually means reaching for Instant (or a fixed-offset
OffsetDateTime) — the user's time zone is applied only when displaying that universal
moment to them.
ZonedDateTime and the Time Zone Concept
ZonedDateTime combines a LocalDateTime with a ZoneId (say, "Europe/Istanbul") —
so it carries both "what the local clock read" and "which region's rules that should be
interpreted under":
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
class ZonedDateTimeExample {
public static void main(String[] args) {
LocalDateTime local = LocalDateTime.of(2026, 7, 15, 15, 0);
ZonedDateTime istanbul = local.atZone(ZoneId.of("Europe/Istanbul"));
System.out.println("Istanbul: " + istanbul);
// withZoneSameInstant -- the INSTANT stays the same, the local time changes
ZonedDateTime newYorkSameInstant = istanbul.withZoneSameInstant(ZoneId.of("America/New_York"));
System.out.println("Same instant in New York: " + newYorkSameInstant);
// withZoneSameLocal -- the LOCAL time stays "15:00", the instant changes
ZonedDateTime newYorkSameLocal = istanbul.withZoneSameLocal(ZoneId.of("America/New_York"));
System.out.println("Same local time relabeled to New York (different instant): " + newYorkSameLocal);
System.out.println("Istanbul and 'same instant' New York represent the same instant? "
+ istanbul.toInstant().equals(newYorkSameInstant.toInstant()));
System.out.println("Istanbul and 'same local' New York represent the same instant? "
+ istanbul.toInstant().equals(newYorkSameLocal.toInstant()));
}
}
ZoneId.of("Europe/Istanbul") isn't a fixed number — it represents all of a
region's historical and future rules (including DST transitions), so the same
ZonedDateTime can correspond to a different UTC offset depending on the date.
withZoneSameInstant(...) shows the same universal moment in another region's local
time (the clock time changes, the instant stays the same); withZoneSameLocal(...)
does the opposite (the clock time stays the same, the instant changes) — mixing these
two up is a common source of bugs.
Calling equals() on a ZonedDateTime compares both the instant and the time
zone — two ZonedDateTimes representing the exact same universal moment but
expressed in different regions are not equal per equals(). If you only want to
check whether they represent the same moment, use isEqual(...) instead.
OffsetDateTime
OffsetDateTime combines a LocalDateTime with a fixed ZoneOffset (say, +03:00)
instead of a ZoneId. The difference is critical: a ZoneOffset is just a fixed
number, carrying no DST transition rules — whereas a ZoneId, like "Europe/Istanbul",
represents a region's rules, which can change over time:
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
class OffsetDateTimeExample {
public static void main(String[] args) {
LocalDateTime local = LocalDateTime.of(2026, 3, 15, 14, 30);
OffsetDateTime withOffset = local.atOffset(ZoneOffset.of("+03:00"));
System.out.println("With fixed offset: " + withOffset);
// A ZoneOffset is just a fixed number -- it carries no DST transition rules,
// unlike a ZoneId region such as "Europe/Istanbul".
System.out.println("Offset: " + withOffset.getOffset());
}
}
That distinction decides which one to reach for: if you want to show a user "Istanbul
time," ZonedDateTime + ZoneId is the right tool (it applies DST rules
automatically); if you want to store a fixed-offset timestamp in an API contract or
a database column (the ISO-8601 OffsetDateTime shape), OffsetDateTime is more
predictable — a region's rules changing in the future (say, a country dropping DST)
never affects records already stored with a fixed offset.
Duration and Period
There are two ways to express the gap between two points in time, and which one you
pick depends on what you're actually measuring: Duration represents a
time-based amount — hours, minutes, seconds — (between Instant, LocalTime, or
LocalDateTime values); Period represents a calendar-based amount — years,
months, days — (only between LocalDate values):
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
class DurationAndPeriodExample {
public static void main(String[] args) {
Instant start = Instant.parse("2026-01-01T09:00:00Z");
Instant end = Instant.parse("2026-01-01T17:30:00Z");
Duration workDay = Duration.between(start, end);
System.out.println("Duration: " + workDay);
System.out.println("Hours: " + workDay.toHours());
System.out.println("Minutes: " + workDay.toMinutes());
LocalDate projectStart = LocalDate.of(2024, 1, 10);
LocalDate projectEnd = LocalDate.of(2026, 4, 20);
Period projectLength = Period.between(projectStart, projectEnd);
System.out.println("Period: " + projectLength.getYears() + " years, "
+ projectLength.getMonths() + " months, " + projectLength.getDays() + " days");
}
}
Duration.between(start, end) gives the gap between two Instants with
second/nanosecond precision — a concept like "3 months" doesn't fit Duration at all,
since months don't have a fixed length. Period.between(start, end) does the
opposite: it produces a calendar-shaped gap like "2 years, 3 months, 10 days," but
offers no hour/minute precision. Trying to use one in place of the other (say,
converting a Period into seconds) produces meaningless results — ChronoUnit, which
we'll see next, builds a more flexible bridge between these two worlds.
Calculating Time Differences with ChronoUnit
ChronoUnit is an enum that implements the TemporalUnit interface (recall the
pattern from the Enum lesson's "Interface Implementation" section) — it offers
constants like DAYS, HOURS, MONTHS, and lets you compute a gap as a single raw
number, without constructing a Duration/Period object:
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
class ChronoUnitExample {
public static void main(String[] args) {
LocalDate start = LocalDate.of(2024, 1, 10);
LocalDate end = LocalDate.of(2026, 4, 20);
long totalDays = ChronoUnit.DAYS.between(start, end);
long totalMonths = ChronoUnit.MONTHS.between(start, end);
long totalYears = ChronoUnit.YEARS.between(start, end);
System.out.println("Total days: " + totalDays);
System.out.println("Total months: " + totalMonths);
System.out.println("Total years: " + totalYears);
}
}
Unlike Period.between(...).getDays(), ChronoUnit.DAYS.between(start, end) returns
the total day gap (say, 400 days) as a single long — Period instead breaks that
same gap into pieces, like "1 year, 1 month, 5 days." Pick based on what question you
actually want answered: "how many days apart are they?" is a ChronoUnit question,
"how much time apart, in human terms?" is a Period/Duration question. Another
advantage of ChronoUnit is its generality: the same between(...) call works across
many different types — LocalDate, LocalDateTime, Instant, and more.
DateTimeFormatter: Formatting and Parsing
DateTimeFormatter is the standard way to turn a date/time object into text
(format(...)) or text into a date/time object (parse(...)) — you can use either
ready-made ISO-8601 formats or define a custom pattern with ofPattern(...):
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
class FormattingAndParsingExample {
public static void main(String[] args) {
LocalDate date = LocalDate.of(2026, 3, 15);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
String formatted = date.format(formatter);
System.out.println("Formatted: " + formatted);
LocalDate parsedBack = LocalDate.parse(formatted, formatter);
System.out.println("Parsed back: " + parsedBack);
System.out.println("Round-trip matches original? " + parsedBack.equals(date));
try {
LocalDate.parse("2026-13-45"); // invalid month and day
} catch (DateTimeParseException e) {
System.out.println("Caught expected parse failure: " + e.getMessage());
}
}
}
DateTimeFormatter.ofPattern("dd/MM/yyyy") prints a LocalDate in a human-friendly
shape like "15/03/2026"; the same formatter converts text back into a LocalDate in
the other direction via LocalDate.parse(text, formatter). If the text doesn't match
the expected pattern (say, an invalid date like "2026-13-45"), a
DateTimeParseException is thrown — this is an exception you must handle with a
try/catch whenever you're processing user input.
Date Calculations
Every java.time class offers a set of plus/minus methods — plusDays(),
minusMonths(), plusYears(), and so on — all of which preserve immutability (recall
"LocalDate") and return a new object, never mutating the original:
import java.time.LocalDate;
class DateCalculationsExample {
public static void main(String[] args) {
LocalDate jan31 = LocalDate.of(2026, 1, 31);
LocalDate result = jan31.plusMonths(1); // January 31 + 1 month
System.out.println("Jan 31 + 1 month: " + result); // clamped -- 2026 is not a leap year
LocalDate chained = jan31.plusYears(1).minusDays(5);
System.out.println("Chained calculation: " + chained);
System.out.println("Original still unchanged: " + jan31);
}
}
A call like plusMonths(1) handles month-overflow intelligently on its own — say,
January 31 plus one month doesn't land on the nonexistent "February 31," it clamps to
that month's last valid day (the 28th or 29th). Chained calls
(date.plusYears(1).minusDays(5)) are perfectly safe, since each step produces a new
object — no intermediate step ever affects the one before it.
TemporalAdjusters
Some date calculations are too rule-based to express with a simple plus/minus —
things like "the next Monday" or "the last day of this month." The
TemporalAdjusters class offers ready-made rules (adjusters) you can pass to with(...):
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
class TemporalAdjustersExample {
public static void main(String[] args) {
LocalDate date = LocalDate.of(2026, 3, 15);
System.out.println("Starting date: " + date + " (" + date.getDayOfWeek() + ")");
LocalDate nextMonday = date.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
System.out.println("Next Monday: " + nextMonday);
LocalDate lastDayOfMonth = date.with(TemporalAdjusters.lastDayOfMonth());
System.out.println("Last day of the month: " + lastDayOfMonth);
LocalDate firstDayOfYear = date.with(TemporalAdjusters.firstDayOfYear());
System.out.println("First day of the year: " + firstDayOfYear);
}
}
date.with(TemporalAdjusters.next(DayOfWeek.MONDAY)) computes "the first Monday after
today" in a single line — doing this by hand would mean thinking through several edge
cases like the number of days in the week and month boundaries. Ready-made adjusters
like lastDayOfMonth() and firstDayOfYear() follow the same philosophy: they reduce
common calendar rules to a single, well-tested method call instead of error-prone
manual arithmetic.
Comparing Dates
Classes like LocalDate, LocalDateTime, and Instant offer isBefore(),
isAfter(), and compareTo() for comparing two values — equals() also works, but as
we saw in "ZonedDateTime and the Time Zone Concept," some types can fold extra
information (like a time zone) into that comparison too:
import java.time.LocalDate;
class ComparingDatesExample {
public static void main(String[] args) {
LocalDate first = LocalDate.of(2026, 3, 15);
LocalDate second = LocalDate.of(2026, 6, 1);
System.out.println("first isBefore second? " + first.isBefore(second));
System.out.println("first isAfter second? " + first.isAfter(second));
System.out.println("compareTo: " + first.compareTo(second)); // negative -- first comes before second
LocalDate sameAsFirst = LocalDate.of(2026, 3, 15);
System.out.println("equals (value comparison): " + first.equals(sameAsFirst));
}
}
isBefore()/isAfter() are far more readable than writing compareTo() < 0 — the
code reads almost like English for "is this date before that one?" For LocalDate and
LocalDateTime, equals() already compares only the value (field by field, much like
the auto-generated equals() we saw in the Record lesson) — the exception you actually
need to watch for is ZonedDateTime, covered in the previous section.
Migrating from the Legacy API to java.time
You may still run into java.util.Date, Calendar, and SimpleDateFormat in older
codebases — all three have the serious problems we touched on in "History," but for
situations you can't fully avoid (say, a third-party library that still hands you a
Date), there are conversion bridges:
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Date;
class LegacyInteropExample {
public static void main(String[] args) {
Date legacyDate = new Date(1_700_000_000_000L); // old API, milliseconds since epoch
Instant instant = legacyDate.toInstant(); // lossless -- Date is just an epoch timestamp underneath
System.out.println("Converted to Instant: " + instant);
ZonedDateTime inIstanbul = instant.atZone(ZoneId.of("Europe/Istanbul"));
System.out.println("Viewed in Istanbul: " + inIstanbul);
Date backToLegacy = Date.from(instant); // bridging back, for an old API that still needs a Date
System.out.println("Round-trip matches original? " + backToLegacy.equals(legacyDate));
}
}
Date.toInstant() converts an old Date into a modern Instant — since Date
itself is internally nothing more than an epoch timestamp, this conversion is
lossless. Instant.atZone(zoneId) builds the bridge in the other direction. The most
critical warning is about SimpleDateFormat: this class is not thread-safe —
multiple threads sharing the same SimpleDateFormat instance (recall "Race
Conditions" from the Threads lesson) can produce corrupted results; DateTimeFormatter
is immutable and can be safely shared across threads.
New code should never start with Date, Calendar, or SimpleDateFormat — these
should only ever be used to bridge at the boundary with an older API (a third-party
library, an old database driver). We'll emphasize this rule again in "Common
Mistakes."
Time Zones and Daylight Saving Time
Time zones are this lesson's most overlooked, yet most bug-prone, topic. UTC
(Coordinated Universal Time) is the fixed reference point every time zone is measured
against; GMT shares practically the same offset as UTC but is historically based on
a different definition. Region identifiers like "Europe/Istanbul" and
"America/New_York" don't represent a fixed offset at all — they carry rules that can
change over time, most notably Daylight Saving Time (DST):
import java.time.ZoneId;
import java.time.ZonedDateTime;
class TimeZoneAndDstExample {
public static void main(String[] args) {
// Istanbul stopped observing DST in 2016 -- fixed at UTC+3 year round.
ZonedDateTime istanbulWinter = ZonedDateTime.of(2026, 1, 15, 12, 0, 0, 0, ZoneId.of("Europe/Istanbul"));
ZonedDateTime istanbulSummer = ZonedDateTime.of(2026, 7, 15, 12, 0, 0, 0, ZoneId.of("Europe/Istanbul"));
System.out.println("Istanbul winter offset: " + istanbulWinter.getOffset());
System.out.println("Istanbul summer offset: " + istanbulSummer.getOffset() + " (same -- no DST anymore)");
// New York still observes DST -- the offset differs between winter and summer.
ZonedDateTime newYorkWinter = ZonedDateTime.of(2026, 1, 15, 12, 0, 0, 0, ZoneId.of("America/New_York"));
ZonedDateTime newYorkSummer = ZonedDateTime.of(2026, 7, 15, 12, 0, 0, 0, ZoneId.of("America/New_York"));
System.out.println("New York winter offset: " + newYorkWinter.getOffset());
System.out.println("New York summer offset: " + newYorkSummer.getOffset() + " (different!)");
}
}
DST transitions produce two odd situations: on the day clocks spring forward, some
local times never happen at all (say, 02:30 is skipped straight to 03:30); on the
day clocks fall back, some local times happen twice. ZonedDateTime resolves these
ambiguities for you with an automatic rule — but not knowing that rule exists leads to
surprising bugs like "why does this date show a 25-hour day?" A concrete example from
Turkey: the "Europe/Istanbul" region stopped observing Daylight Saving Time in 2016
and has stayed fixed at UTC+3 year-round ever since — solid proof that ZoneId rules
really can change over time.
Real-World Examples: java.time in Spring Boot
java.time shows up in almost every layer of a modern Spring Boot application. On the
JSON side, Spring Boot's default Jackson configuration automatically registers the
jackson-datatype-jsr310 module — so an Instant or LocalDate field serializes to
ISO-8601 text (like "2026-08-10T11:32:07Z") with no extra annotations needed. On the
database side, Hibernate has natively supported java.time types since 5.2 — an
Instant field on a JPA @Entity maps directly onto PostgreSQL's time-zone-aware
timestamptz column:
import java.time.Instant;
import java.time.LocalDate;
class Event {
private final String name;
private final Instant createdAt; // absolute timestamp -- what Jackson/Hibernate map automatically
private final LocalDate eventDate; // calendar date -- no time zone needed for "which day"
Event(String name, Instant createdAt, LocalDate eventDate) {
this.name = name;
this.createdAt = createdAt;
this.eventDate = eventDate;
}
@Override
public String toString() {
return name + " (created " + createdAt + ", scheduled for " + eventDate + ")";
}
}
class EventExample {
public static void main(String[] args) {
Event event = new Event(
"Java Meetup",
Instant.parse("2026-03-01T10:15:30Z"),
LocalDate.of(2026, 4, 12)
);
System.out.println(event);
// In a real Spring Boot app, this exact class (with @Entity/@JsonFormat added)
// would serialize createdAt as ISO-8601 JSON and map to a PostgreSQL timestamptz
// column -- no special handling needed for the java.time types themselves.
}
}
The Event class holds its Instant createdAt and LocalDate eventDate fields with
no framework annotations at all — and that's the whole point: framework integration
doesn't live in the class itself, it lives in the fact that the framework already
understands these types. The exact same Event class could become a JPA @Entity or
a Jackson DTO, and the field types (Instant, LocalDate) would never need to
change — because both Hibernate and Jackson understand them out of the box.
Best Practices
- Store timestamps in a database as
Instant(or a fixed-offsetOffsetDateTime), and convert toZonedDateTimeonly when displaying to a user — as we noted in "Instant," this eliminates time zone ambiguity at the earliest possible point. - Always use
ZonedDateTime(or at least a knownZoneId) when showing a date/time to a user — a bareLocalDateTimenever answers the question "in which time zone?" (see the warning in "LocalDateTime"). - When choosing between
Duration/Period/ChronoUnit, ask what you're actually measuring: a time-based span (Duration), a calendar-based range (Period), or a single raw number (ChronoUnit) (see "Duration and Period" and "Calculating Time Differences with ChronoUnit"). - Never start new code with
Date,Calendar, orSimpleDateFormat— use them only to bridge at the boundary with an old API (see "Migrating from the Legacy API to java.time"). - Always handle
DateTimeParseExceptionwhen parsing a date from user input — an invalid date string is an inevitability, not an edge case (see "DateTimeFormatter: Formatting and Parsing").
Common Mistakes
1. Treating LocalDateTime as if it knew a time zone. LocalDateTime never
carries time zone information — if you need to show a user an unambiguous moment, use
ZonedDateTime or Instant instead (see the warning in "LocalDateTime").
2. Comparing two ZonedDateTimes with equals() and assuming they represent the
same moment. equals() also compares the time zone — to compare only the instant,
use isEqual(...) (see the warning in "ZonedDateTime and the Time Zone Concept").
3. Sharing a single SimpleDateFormat instance across multiple threads. This class
isn't thread-safe and produces corrupted results; DateTimeFormatter safely replaces
it (see "Migrating from the Legacy API to java.time").
4. Assuming a time zone's rules (especially DST) never change. As the "Europe/Istanbul" example shows, a region's DST rules can change completely over the years (see "Time Zones and Daylight Saving Time").
5. Trying to use Period for an hour/minute-precision calculation. Period is
calendar-based only (years/months/days); for time-based gaps use Duration, and for a
single raw number use ChronoUnit (see "Duration and Period").
Summary, Cheat Sheet, and Glossary
java.time has been Java's immutable, thread-safe date/time API, built with a clear
separation of responsibilities, since Java 8. Key takeaways:
LocalDate/LocalTime/LocalDateTimecarry no time zone information at all — the word "Local" emphasizes exactly thatInstantis a single, unambiguous point on the UTC timeline — ideal for machine-to-machine communication and database timestampsZonedDateTimecarries aZoneId(a region's rules, including DST);OffsetDateTimecarries only a fixedZoneOffsetDurationis time-based,Periodis calendar-based, andChronoUnitexpresses the gap between two points as a single raw numberDateTimeFormatterhandles both formatting and parsing, and is thread-safe- Every
plus/minuscall returns a new object, never mutating the original TemporalAdjustersoffers ready-made adjusters for rule-based calculations like "the next Monday"Date/Calendar/SimpleDateFormatshould only be used to bridge with legacy APIs —SimpleDateFormatis not thread-safe- Store
Instant/OffsetDateTimein your database, displayZonedDateTimeto users
Quick reference:
// Core classes
LocalDate date = LocalDate.now(); // date only
LocalTime time = LocalTime.now(); // time only
LocalDateTime dateTime = LocalDateTime.now(); // date + time, no time zone
Instant instant = Instant.now(); // a single point on the UTC timeline
// Time zones
ZonedDateTime zoned = ZonedDateTime.now(ZoneId.of("Europe/Istanbul"));
OffsetDateTime offset = OffsetDateTime.now(ZoneOffset.of("+03:00"));
// Measuring gaps
Duration duration = Duration.between(instant1, instant2); // hours/minutes/seconds
Period period = Period.between(date1, date2); // years/months/days
long days = ChronoUnit.DAYS.between(date1, date2); // a raw number
// Formatting / parsing
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy");
String text = date.format(fmt);
LocalDate parsed = LocalDate.parse(text, fmt);
// Calculations and TemporalAdjusters
LocalDate nextMonday = date.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate endOfMonth = date.with(TemporalAdjusters.lastDayOfMonth());
// Legacy bridge
Instant fromLegacy = legacyDate.toInstant();
Date toLegacy = Date.from(instant);
Glossary
LocalDate/LocalTime/LocalDateTime — Immutable classes carrying no time zone
information, representing a date, a time, and the combination of the two,
respectively.
Instant — An immutable class representing a single, unambiguous point on the UTC
timeline.
ZonedDateTime — Combines a local date/time with a ZoneId that carries a
region's full set of rules, including DST.
OffsetDateTime — Combines a local date/time with a fixed ZoneOffset that
carries no DST rules.
Duration — A time-based amount expressing the gap between two points in
hours/minutes/seconds.
Period — A calendar-based amount expressing the gap between two dates in
years/months/days.
ChronoUnit — An enum implementing TemporalUnit, used to express the gap
between two points as a single raw number.
DateTimeFormatter — A thread-safe class used to convert date/time objects to
text (formatting) and text back into date/time objects (parsing).
TemporalAdjusters — A helper class offering ready-made adjusters for rule-based
date calculations like "the next Monday" or "the last day of the month."
DST (Daylight Saving Time) — The practice, in some regions, of moving clocks
forward/backward during certain parts of the year; the primary reason ZoneId rules
can change over time.
Appendix: Mini Project — A Multi-Time-Zone Meeting Scheduler
In this mini project we build the scenario described in "Why Does It Exist?": a
scheduler that stores a meeting as a single absolute moment (Instant) and can display
it in any time zone you ask for:
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
class MeetingScheduler {
private final String title;
private final Instant scheduledAt; // single source of truth -- an unambiguous instant
MeetingScheduler(String title, Instant scheduledAt) {
this.title = title;
this.scheduledAt = scheduledAt;
}
ZonedDateTime viewIn(ZoneId zone) {
return scheduledAt.atZone(zone);
}
String getTitle() {
return title;
}
}
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
class MeetingSchedulerDemo {
public static void main(String[] args) {
MeetingScheduler meeting = new MeetingScheduler(
"Sprint Planning",
Instant.parse("2026-07-15T12:00:00Z")
);
ZonedDateTime inIstanbul = meeting.viewIn(ZoneId.of("Europe/Istanbul"));
ZonedDateTime inNewYork = meeting.viewIn(ZoneId.of("America/New_York"));
ZonedDateTime inTokyo = meeting.viewIn(ZoneId.of("Asia/Tokyo"));
System.out.println(meeting.getTitle() + " local times:");
System.out.println(" Istanbul: " + inIstanbul);
System.out.println(" New York: " + inNewYork);
System.out.println(" Tokyo: " + inTokyo);
// All three represent the exact same instant, just expressed differently.
boolean sameInstant = inIstanbul.toInstant().equals(inNewYork.toInstant())
&& inNewYork.toInstant().equals(inTokyo.toInstant());
System.out.println("Same instant everywhere? " + sameInstant);
}
}
MeetingScheduler holds the meeting's moment as nothing but an Instant — as we
emphasized in "Instant," this single source of truth carries no time zone ambiguity at
all. The viewIn(ZoneId) method converts that Instant into the requested region's
ZonedDateTime; MeetingSchedulerDemo shows what local time the same meeting
corresponds to simultaneously in Istanbul, New York, and Tokyo — all three print a
different clock time, yet all three point at the exact same Instant.
It's no accident that all three cities in MeetingSchedulerDemo have different UTC
offsets — seeing that Istanbul no longer observes DST (recall "Time Zones and
Daylight Saving Time"), while New York still does, makes concrete exactly why a
ZoneId's rules are specific to a single region.
Appendix: Mini Project — Event Duration Tracking
Our last mini project combines the ideas from "Migrating from the Legacy API to
java.time" and "Duration and Period": a tracker that converts Date-based records
coming from an old system into Instant and computes their duration:
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
class EventDurationTracker {
private final String name;
private final Instant start;
private final Instant end;
EventDurationTracker(String name, Instant start, Instant end) {
this.name = name;
this.start = start;
this.end = end;
}
static EventDurationTracker fromLegacyDates(String name, Date legacyStart, Date legacyEnd) {
// Bridging old java.util.Date records into the modern API -- lossless,
// because a Date is already just an epoch timestamp underneath.
return new EventDurationTracker(name, legacyStart.toInstant(), legacyEnd.toInstant());
}
Duration duration() {
return Duration.between(start, end);
}
String getName() {
return name;
}
}
import java.time.Duration;
import java.util.Date;
class EventDurationTrackerDemo {
public static void main(String[] args) {
// A modern record, created directly with Instant.
EventDurationTracker modern = new EventDurationTracker(
"Modern Conference",
java.time.Instant.parse("2026-05-01T09:00:00Z"),
java.time.Instant.parse("2026-05-01T17:00:00Z")
);
// A "legacy" record, as if it came from an old system using java.util.Date.
Date legacyStart = new Date(1_700_000_000_000L);
Date legacyEnd = new Date(1_700_010_800_000L);
EventDurationTracker legacy = EventDurationTracker.fromLegacyDates("Legacy Workshop", legacyStart, legacyEnd);
for (EventDurationTracker tracker : new EventDurationTracker[] { modern, legacy }) {
Duration d = tracker.duration();
System.out.println(tracker.getName() + ": " + d.toHours() + "h " + (d.toMinutes() % 60) + "m");
}
}
}
EventDurationTracker.fromLegacyDates(...) converts two java.util.Dates coming from
a legacy API into modern Instants via toInstant() — a real application of the
bridge we saw in "Migrating from the Legacy API to java.time." The duration() method
then uses Duration.between(...) to compute the event's total length in hours and
minutes; EventDurationTrackerDemo shows that records coming from both the old and the
new API can be processed by the same EventDurationTracker without any friction.
The reason fromLegacyDates(...) uses Date.toInstant() is that Date itself
already carries nothing but an epoch timestamp — so the conversion is always safe
and lossless. But converting from Calendar may also require accounting for its time
zone information — a more advanced topic we didn't cover in "Migrating from the
Legacy API to java.time."