Part 10 of 16

Date and Time API (JSR-310): LocalDate, ZonedDateTime, Duration, Period

Why java.util.Date Had to Go

java.util.Date was introduced in Java 1.0 and has been a source of bugs ever since:

  • MutableDate can be modified after creation, causing subtle bugs when passed between methods
  • Not thread-safe — mutable state means shared dates need external synchronisation
  • Poor API design — year is offset from 1900, months are 0-indexed, no time zone support on Date itself
  • Deprecated methods — most of the useful methods on Date were deprecated in Java 1.1 in favour of Calendar, which is also mutable and complex
// Java 7: classic Date bugs
Date d = new Date(2024 - 1900, 0, 15); // year 2024, January, day 15 — confusing!
d.setMonth(11); // mutates in place — dangerous if shared

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// SimpleDateFormat is NOT thread-safe — can't be shared across threads

JSR-310 (led by Stephen Colebourne, author of Joda-Time) introduced java.time in Java 8: immutable, thread-safe, ISO-8601-first, and far more intuitive.


The java.time Type Hierarchy

java.time
├── LocalDate         — date without time or zone (2024-01-15)
├── LocalTime         — time without date or zone (14:30:00)
├── LocalDateTime     — date + time, no zone (2024-01-15T14:30:00)
├── ZonedDateTime     — date + time + zone (2024-01-15T14:30:00+00:00[Europe/London])
├── OffsetDateTime    — date + time + UTC offset (2024-01-15T14:30:00+05:30)
├── OffsetTime        — time + UTC offset (14:30:00+05:30)
├── Instant           — machine timestamp (epoch seconds + nanoseconds)
├── Duration          — time-based amount (3 hours 30 minutes)
├── Period            — date-based amount (2 years 3 months)
├── ZoneId            — time zone identifier (Europe/London, America/New_York)
├── ZoneOffset        — fixed UTC offset (+05:30)
└── DateTimeFormatter — parse and format dates

LocalDate

Represents a date without a time or time zone. Use for birthdays, deadlines, working days.

// Creating
LocalDate today    = LocalDate.now();
LocalDate specific = LocalDate.of(2024, 1, 15);
LocalDate parsed   = LocalDate.parse("2024-01-15");        // ISO-8601 default
LocalDate custom   = LocalDate.parse("15/01/2024",
    DateTimeFormatter.ofPattern("dd/MM/yyyy"));

// Accessing fields
int year    = today.getYear();          // 2026
int month   = today.getMonthValue();    // 5 (1-indexed)
Month m     = today.getMonth();         // Month.MAY
int day     = today.getDayOfMonth();    // 4
DayOfWeek dow = today.getDayOfWeek();  // DayOfWeek.MONDAY

// Manipulating (returns new instance — immutable)
LocalDate tomorrow   = today.plusDays(1);
LocalDate lastMonth  = today.minusMonths(1);
LocalDate nextYear   = today.plusYears(1);
LocalDate firstOfMonth = today.withDayOfMonth(1);

// Comparing
boolean isBefore = LocalDate.of(2023, 1, 1).isBefore(today);
boolean isAfter  = today.isAfter(LocalDate.of(2020, 1, 1));
boolean isLeap   = today.isLeapYear();

// Calculating gap
long daysBetween = ChronoUnit.DAYS.between(LocalDate.of(2024, 1, 1), today);

LocalTime

Represents a time without a date or time zone. Use for opening hours, schedules.

LocalTime now     = LocalTime.now();
LocalTime meeting = LocalTime.of(14, 30);           // 14:30:00
LocalTime precise = LocalTime.of(14, 30, 15, 500);  // 14:30:15.0000005
LocalTime parsed  = LocalTime.parse("14:30:00");

// Manipulating
LocalTime later = meeting.plusHours(2).plusMinutes(15); // 16:45

// Comparing
boolean isBefore = meeting.isBefore(LocalTime.of(17, 0));

// Check if within working hours
LocalTime start = LocalTime.of(9, 0);
LocalTime end   = LocalTime.of(17, 0);
boolean inWorkHours = !now.isBefore(start) && now.isBefore(end);

LocalDateTime

Combines a date and time. Use for event timestamps where the time zone is determined by context (e.g., the user’s local time).

LocalDateTime now      = LocalDateTime.now();
LocalDateTime specific = LocalDateTime.of(2024, 6, 15, 14, 30);
LocalDateTime parsed   = LocalDateTime.parse("2024-06-15T14:30:00");

// From LocalDate + LocalTime
LocalDateTime dt = LocalDate.of(2024, 6, 15).atTime(LocalTime.of(14, 30));
// Or
LocalDateTime dt2 = LocalDate.of(2024, 6, 15).atTime(14, 30);

// Extract parts
LocalDate date = dt.toLocalDate();
LocalTime time = dt.toLocalTime();

// Manipulating
LocalDateTime nextWeek = now.plusWeeks(1);
LocalDateTime truncated = now.truncatedTo(ChronoUnit.HOURS); // zeroes minutes/seconds/nanos

ZonedDateTime

A LocalDateTime combined with a ZoneId. Use when you need to store or transmit a timestamp that is unambiguous across time zones.

ZoneId london     = ZoneId.of("Europe/London");
ZoneId newYork    = ZoneId.of("America/New_York");
ZoneId mumbai     = ZoneId.of("Asia/Kolkata");

// Create
ZonedDateTime nowLondon = ZonedDateTime.now(london);
ZonedDateTime specific  = ZonedDateTime.of(
    LocalDateTime.of(2024, 6, 15, 14, 30), london);

// Convert to another zone — same instant, different wall clock
ZonedDateTime sameInstantNY = nowLondon.withZoneSameInstant(newYork);

// Format
String formatted = nowLondon.format(DateTimeFormatter.RFC_1123_DATE_TIME);

// List all available zone IDs
ZoneId.getAvailableZoneIds().stream()
    .filter(id -> id.startsWith("America/"))
    .sorted()
    .forEach(System.out::println);

ZonedDateTime vs OffsetDateTime

  • ZonedDateTime uses a named zone (Europe/London) which understands DST transitions
  • OffsetDateTime uses a fixed offset (+00:00) which does not adjust for DST

Use ZonedDateTime when zone rules (DST) matter. Use OffsetDateTime for serialisation or when storing a historical timestamp where the exact offset matters more than the zone name.


Instant

Represents a moment on the timeline as epoch seconds + nanoseconds. This is the machine-time representation — no time zone concept.

Instant now = Instant.now();           // current UTC timestamp
Instant epoch = Instant.EPOCH;        // 1970-01-01T00:00:00Z

// From epoch millis (for legacy interop)
Instant fromMillis = Instant.ofEpochMilli(System.currentTimeMillis());

// Arithmetic
Instant later = now.plusSeconds(3600);
Instant earlier = now.minus(Duration.ofMinutes(30));

// Compare
boolean isBefore = now.isBefore(later);

// Convert to ZonedDateTime for display
ZonedDateTime displayTime = now.atZone(ZoneId.of("Europe/London"));

// Convert legacy Date to Instant
Date legacyDate = new Date();
Instant instant = legacyDate.toInstant();

// Convert Instant to legacy Date
Date back = Date.from(instant);

Use Instant for: log timestamps, audit trails, inter-system communication, anything that needs to be timezone-agnostic.


Duration

A time-based amount — hours, minutes, seconds, nanoseconds.

Duration twoHours   = Duration.ofHours(2);
Duration thirtyMin  = Duration.ofMinutes(30);
Duration fromParts  = Duration.of(2, ChronoUnit.HOURS);

// Calculate duration between two instants/times
Instant start = Instant.now();
// ... work ...
Instant end = Instant.now();
Duration elapsed = Duration.between(start, end);

long ms      = elapsed.toMillis();
long seconds = elapsed.getSeconds();
long minutes = elapsed.toMinutes();

// Arithmetic
Duration total = twoHours.plus(thirtyMin); // 2h30m
Duration half  = twoHours.dividedBy(2);    // 1h

Period

A date-based amount — years, months, days.

Period twoYears   = Period.ofYears(2);
Period threeMonths = Period.ofMonths(3);
Period complex    = Period.of(1, 6, 15); // 1 year, 6 months, 15 days

// Calculate period between two dates
LocalDate birthday = LocalDate.of(1990, 6, 15);
LocalDate today    = LocalDate.now();
Period age = Period.between(birthday, today);
System.out.printf("Age: %d years, %d months, %d days%n",
    age.getYears(), age.getMonths(), age.getDays());

// Add period to date
LocalDate future = today.plus(Period.ofMonths(3));

Key difference: Duration works with time (hours, minutes, seconds); Period works with calendar units (years, months, days). Use Period when “3 months from now” needs to account for different month lengths.


DateTimeFormatter

Replaces the non-thread-safe SimpleDateFormat.

// Built-in formatters
DateTimeFormatter iso     = DateTimeFormatter.ISO_LOCAL_DATE;       // 2024-01-15
DateTimeFormatter isoTime = DateTimeFormatter.ISO_LOCAL_DATE_TIME;  // 2024-01-15T14:30:00
DateTimeFormatter rfc1123 = DateTimeFormatter.RFC_1123_DATE_TIME;

// Custom pattern
DateTimeFormatter custom = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
DateTimeFormatter withLocale = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.UK);

// Formatting
String formatted = LocalDateTime.now().format(custom);     // "04/05/2026 14:30"
String localeStr = LocalDate.now().format(withLocale);     // "4 May 2026"

// Parsing
LocalDate date  = LocalDate.parse("15/01/2024", custom);
LocalDateTime dt = LocalDateTime.parse("2024-01-15T14:30:00");

// DateTimeFormatter is thread-safe — safe as a static constant
public static final DateTimeFormatter FMT =
    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

Common Pattern Symbols

SymbolMeaningExample
yyyy4-digit year2024
MM2-digit month01
MMMAbbreviated monthJan
MMMMFull monthJanuary
dd2-digit day15
HH24-hour hour14
hh12-hour hour02
mmMinutes30
ssSeconds45
SSSMilliseconds123
aAM/PMPM
zZone nameGMT
ZZone offset+0530
XXXISO zone offset+05:30

Migrating from java.util.Date / Calendar

Date → Instant → LocalDateTime

// Legacy Date to LocalDateTime
Date legacy = new Date();
LocalDateTime ldt = legacy.toInstant()
    .atZone(ZoneId.systemDefault())
    .toLocalDateTime();

// LocalDateTime to legacy Date
Date back = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());

Calendar → ZonedDateTime

Calendar cal = Calendar.getInstance();
ZonedDateTime zdt = cal.toInstant().atZone(cal.getTimeZone().toZoneId());

SimpleDateFormat → DateTimeFormatter

// Old (not thread-safe)
private static final ThreadLocal<SimpleDateFormat> SDF =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// New (thread-safe)
private static final DateTimeFormatter DTF =
    DateTimeFormatter.ofPattern("yyyy-MM-dd");

SQL Date/Timestamp interop

// java.sql.Date → LocalDate
LocalDate ld = sqlDate.toLocalDate();

// LocalDate → java.sql.Date
java.sql.Date sqlDate = java.sql.Date.valueOf(localDate);

// java.sql.Timestamp → LocalDateTime
LocalDateTime ldt = sqlTimestamp.toLocalDateTime();

// LocalDateTime → java.sql.Timestamp
java.sql.Timestamp ts = java.sql.Timestamp.valueOf(localDateTime);

Common Patterns

Check if a date is a weekend

boolean isWeekend = date.getDayOfWeek() == DayOfWeek.SATURDAY
    || date.getDayOfWeek() == DayOfWeek.SUNDAY;

Get all dates in a month

LocalDate firstDay = YearMonth.of(2024, 1).atDay(1);
LocalDate lastDay  = YearMonth.of(2024, 1).atEndOfMonth();

firstDay.datesUntil(lastDay.plusDays(1))  // Java 9+
    .forEach(System.out::println);

// Java 8 equivalent
LocalDate d = firstDay;
while (!d.isAfter(lastDay)) {
    System.out.println(d);
    d = d.plusDays(1);
}

Convert UTC timestamp to user’s local time

Instant utcTimestamp = Instant.parse("2024-06-15T10:30:00Z");
ZoneId userZone = ZoneId.of("Asia/Kolkata");
ZonedDateTime localTime = utcTimestamp.atZone(userZone);
// 2024-06-15T16:00:00+05:30[Asia/Kolkata]

Summary

TypeUse for
LocalDateDate-only (birthdays, deadlines)
LocalTimeTime-only (schedules, opening hours)
LocalDateTimeDate + time, no zone (local events)
ZonedDateTimeDate + time + zone (global scheduling)
OffsetDateTimeDate + time + fixed offset (serialisation)
InstantMachine timestamp (logs, auditing)
DurationTime-based amount (elapsed time)
PeriodDate-based amount (age, intervals)
DateTimeFormatterParsing/formatting (thread-safe)

Next Step

Default and Static Interface Methods: Backwards-Compatible API Evolution →

Part of the DevOps Monk Java tutorial series: Java 8Java 11Java 17Java 21