Date & Time API

The java.time API (Java 8+) gives Java a clear, immutable, thread-safe model for dates, times, durations, and time zones — replacing the deeply flawed legacy Date and Calendar classes.

Before Java 8, date/time handling relied on java.util.Date and java.util.Calendar, both of which were mutable, poorly designed (Date's month is 0-indexed, its year is offset from 1900), and not thread-safe. Java 8 introduced java.time (JSR-310, heavily influenced by the Joda-Time library), a comprehensive, immutable, thread-safe API that clearly separates human-readable date/time concepts from machine timestamps and from time-zone-aware concepts.

The Core Design: Immutability and Clear Concepts

Every class in java.time is immutable: methods like plusDays() or withYear() return a new instance rather than mutating the original. This makes date/time values inherently thread-safe and eliminates a whole class of bugs from shared mutable Date objects. The API also deliberately separates several concepts that legacy Date conflated into one class: a point on the timeline with no human calendar context (Instant), a human date with no time (LocalDate), a human time with no date (LocalTime), a human date+time with no time zone (LocalDateTime), and a date+time anchored to a specific time zone (ZonedDateTime) or a fixed UTC offset (OffsetDateTime).

Instant: A Machine Timestamp

Instant represents a point on the timeline, measured as nanoseconds since the epoch (1970-01-01T00:00:00Z), with no calendar or time-zone information attached. It is the java.time analog of a raw Unix timestamp and is the right type for logging 'when did this event happen' in a zone-agnostic way.

import java.time.Instant;

Instant now = Instant.now();
Instant later = now.plusSeconds(3600);
System.out.println(now);   // e.g. 2024-06-01T12:34:56.789Z
System.out.println(later.isAfter(now)); // true

LocalDate, LocalTime, LocalDateTime

LocalDate holds a date (year, month, day) with no time or zone — ideal for birthdays, anniversaries, deadlines expressed by date alone. LocalTime holds a time of day (hour, minute, second, nanosecond) with no date or zone. LocalDateTime combines both, still with no time-zone attached — appropriate when zone is irrelevant or handled separately (e.g. 'local wall-clock time at some unspecified place').

Unlike legacy Date, LocalDate.of() takes a 1-indexed month (1=January), matching human expectation — a deliberate fix of one of Calendar/Date's most infamous footguns.

import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;

LocalDate date = LocalDate.of(2024, 6, 15);
LocalTime time = LocalTime.of(14, 30);
LocalDateTime dateTime = LocalDateTime.of(date, time);
LocalDateTime alsoDateTime = date.atTime(time); // equivalent, more fluent

LocalDate nextWeek = date.plusWeeks(1);
System.out.println(date.getDayOfWeek()); // e.g. SATURDAY
System.out.println(dateTime);            // 2024-06-15T14:30

ZonedDateTime and OffsetDateTime

ZonedDateTime attaches a full time-zone rule set (a ZoneId, such as "America/New_York") to a date-time, which means it knows about daylight saving transitions and can correctly compute things like 'add one day' across a DST boundary. OffsetDateTime attaches only a fixed UTC offset (like +02:00) with no knowledge of DST rules or region — appropriate for formats like ISO-8601 timestamps in APIs where a fixed offset, not a full zone, is all that's exchanged.

import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;

ZonedDateTime nyTime = ZonedDateTime.now(ZoneId.of("America/New_York"));
ZonedDateTime tokyoTime = nyTime.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
System.out.println(nyTime);
System.out.println(tokyoTime); // same instant, different local wall-clock time

OffsetDateTime odt = OffsetDateTime.now(ZoneOffset.of("+05:30"));

Time Zone Versus Offset — A Critical Distinction

A UTC offset (e.g. +05:30) is a fixed number of hours/minutes from UTC. A time zone (e.g. "Europe/London") is a set of rules that determines the offset in effect at a given moment, including seasonal daylight-saving changes — meaning the same ZoneId can correspond to different offsets depending on the date. This is why ZonedDateTime (zone-aware, can compute across DST changes) and OffsetDateTime (offset-only, no DST logic) are genuinely different tools: use ZonedDateTime when you need to reason about a real place's local time across dates, and OffsetDateTime when a fixed offset is all the data format actually gives you.

ZoneId.of("UTC") or ZoneOffset.UTC represent Coordinated Universal Time, which has no DST — useful as a canonical zero point distinct from any specific region.

Duration and Period

Duration represents an amount of time in seconds/nanoseconds — appropriate for machine-precision, time-based amounts (e.g. "this call took 250 milliseconds", "retry after 30 seconds"). Period represents an amount of time in years/months/days — appropriate for calendar-based amounts (e.g. "this subscription lasts 1 month"), where the actual elapsed real-world time can vary (a 1-month Period spans 28-31 days depending on which month).

import java.time.Duration;
import java.time.Period;
import java.time.LocalDate;
import java.time.LocalTime;

Duration d = Duration.ofMinutes(90);
System.out.println(d.toHours() + "h " + (d.toMinutes() % 60) + "m"); // 1h 30m

Period p = Period.between(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 15));
System.out.println(p.getMonths() + " months, " + p.getDays() + " days"); // 2 months, 14 days

Duration elapsed = Duration.between(LocalTime.of(9, 0), LocalTime.of(17, 30));
System.out.println(elapsed.toHours()); // 8

ZoneId and the IANA Time Zone Database

ZoneId identifies a time zone by its IANA Time Zone Database name (e.g. "America/Los_Angeles", "Europe/Paris"), which encodes the region's historical and current DST rules. ZoneId.systemDefault() returns the JVM host's configured zone, but relying on it implicitly is risky for anything that must behave consistently across environments — code that must be zone-correct should generally pass an explicit ZoneId rather than depending on wherever it happens to run.

import java.time.ZoneId;
import java.util.Set;

ZoneId.getAvailableZoneIds().stream().filter(z -> z.startsWith("America")).limit(3)
    .forEach(System.out::println);
ZoneId defaultZone = ZoneId.systemDefault(); // JVM/host-dependent

Formatting and Parsing

DateTimeFormatter converts between java.time objects and Strings. Predefined constants like DateTimeFormatter.ISO_LOCAL_DATE cover common standard formats, and ofPattern() builds a custom formatter from a pattern string. Formatters are immutable and thread-safe, unlike the legacy SimpleDateFormat, which is famously not thread-safe and must never be shared across threads without external synchronization.

DateTimeFormatter instances are safe to share as static final constants and reuse across threads, unlike SimpleDateFormat.

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date = LocalDate.of(2024, 6, 15);
String text = date.format(fmt);       // "15/06/2024"
LocalDate parsed = LocalDate.parse("15/06/2024", fmt);
System.out.println(text + " -> " + parsed);

Legacy Date and Calendar: A Historical Overview

java.util.Date represents a point in time (much like Instant) but is mutable and has notoriously confusing constructors and getters (e.g. getYear() returns the year minus 1900; months are 0-indexed). java.util.Calendar (and its common subclass GregorianCalendar) attempted to add calendar-aware operations but remained mutable, not thread-safe, and awkward to use correctly. SimpleDateFormat, used for formatting/parsing both, is also not thread-safe — sharing one instance across threads without synchronization is a classic concurrency bug.

toInstant()/Date.from() and Calendar.toInstant() are the supported bridges for interoperating with older APIs that still require java.util.Date/Calendar, without rewriting everything at once.

New code should use java.time exclusively; Date/Calendar are covered here only because they remain common in legacy codebases and some older third-party APIs still require them.

import java.util.Date;
import java.time.Instant;

// Legacy -> java.time interop, useful when working with old APIs
Date legacyDate = new Date();
Instant instant = legacyDate.toInstant();
Date backToLegacy = Date.from(instant);

Practical Usage: Combining the Pieces

import java.time.*;
import java.time.format.DateTimeFormatter;

public class MeetingScheduler {
    public static void main(String[] args) {
        ZonedDateTime meetingUtc = ZonedDateTime.of(2024, 6, 15, 15, 0, 0, 0, ZoneId.of("UTC"));
        ZonedDateTime meetingLondon = meetingUtc.withZoneSameInstant(ZoneId.of("Europe/London"));
        ZonedDateTime meetingTokyo = meetingUtc.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));

        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("EEE dd MMM yyyy, HH:mm");
        System.out.println("London: " + meetingLondon.format(fmt));
        System.out.println("Tokyo:  " + meetingTokyo.format(fmt));
    }
}

Mental Model

Think of Instant as an unlabeled tick on a single universal timeline. LocalDate/LocalTime/LocalDateTime are what a wall clock and calendar show with no indication of which timeline-offset they correspond to. ZonedDateTime is a LocalDateTime plus a specific place's full rulebook (so it knows exactly which Instant it corresponds to, including through DST changes); OffsetDateTime is a LocalDateTime plus just a fixed offset, with no rulebook attached.

Key Takeaways

java.time classes are immutable and thread-safe; every 'modifying' method returns a new instance.

Instant is a machine timestamp with no calendar/zone context; LocalDate/LocalTime/LocalDateTime are human calendar concepts with no zone; ZonedDateTime and OffsetDateTime attach zone or offset information.

A time zone (ZoneId) encodes rules including DST that can change the offset over time; a raw offset (ZoneOffset) is fixed and carries no DST logic.

Duration measures time-based amounts (seconds/nanos); Period measures calendar-based amounts (years/months/days).

DateTimeFormatter is immutable and thread-safe, unlike the legacy SimpleDateFormat, which must never be shared across threads without synchronization.

Legacy Date/Calendar are mutable, not thread-safe, and error-prone (0-indexed months, 1900-offset years); prefer java.time for all new code.

Date d = new Date(2024 - 1900, 5, 15); // legacy year offset & 0-indexed month, easy to get wrong

LocalDate d = LocalDate.of(2024, 6, 15); // explicit, 1-indexed month, no offset math

Legacy Date's year-since-1900 and 0-indexed-month constructors are a well-known source of off-by-one bugs; java.time's LocalDate uses plain, intuitive values.

SimpleDateFormat sdf = SHARED_FORMATTER; // shared across threads sdf.format(new Date());

DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); // safe to share, immutable fmt.format(LocalDate.now());

SimpleDateFormat is not thread-safe; concurrent use from multiple threads can corrupt its internal state and produce wrong results. DateTimeFormatter has no such issue.

LocalDateTime.now().plusDays(1); // ambiguous which zone this 'now' or DST math applies to

ZonedDateTime.now(ZoneId.of("America/New_York")).plusDays(1); // zone-aware, correct across DST

LocalDateTime has no time-zone context, so arithmetic on it cannot account for daylight saving transitions; use ZonedDateTime when the calculation must be correct in a specific real place.

Period.between(start, end).toDays(); // Period has no toDays() and mixes calendar units incorrectly

Duration.between(startInstant, endInstant).toDays(); // Duration is for elapsed time-based amounts

Period represents calendar-based amounts (years/months/days) that don't convert cleanly into a total day count when months of varying length are involved; use ChronoUnit.DAYS.between(...) or Duration for elapsed time.

What is the key structural difference between Instant and LocalDateTime?

Instant is a machine timestamp on the universal timeline with no calendar/zone context; LocalDateTime is a human calendar date+time with no timeline/zone context — you cannot convert between them without supplying a ZoneId or offset.

What is the difference between a time zone and a UTC offset?

A time zone (ZoneId) is a rule set that can produce different offsets over time (e.g. due to daylight saving); a UTC offset (ZoneOffset) is a fixed difference from UTC with no such rules.

When should you use Duration versus Period?

Duration for time-based, machine-precision amounts (seconds/nanoseconds, e.g. elapsed processing time); Period for calendar-based amounts (years/months/days), where the real elapsed time can vary by which dates are involved.

Why is SimpleDateFormat dangerous to use as a shared static field across threads?

It is not thread-safe; concurrent formatting/parsing calls can corrupt its internal mutable state, producing incorrect or inconsistent results. DateTimeFormatter has no such issue because it is immutable.

Why are java.time classes considered thread-safe?

They are all immutable — every 'mutating' operation (plusDays, withYear, etc.) returns a new instance rather than modifying the original, so shared instances cannot be corrupted by concurrent access.

What are two well-known design flaws in java.util.Date's API?

Its year values are offset from 1900 (getYear() returns actual year minus 1900) and its month values are 0-indexed (0=January), both frequent sources of off-by-one bugs.

How would you convert a legacy java.util.Date into a java.time.Instant?

Call date.toInstant() — Date and Calendar both provide bridge methods to interoperate with java.time without full API rewrites.

Write a program that computes a person's age in full years given their birth LocalDate and today's date, using Period.

import java.time.LocalDate; import java.time.Period; public class AgeCalculator { public static void main(String[] args) { LocalDate birthDate = LocalDate.of(1995, 3, 20); LocalDate today = LocalDate.now(); int age = Period.between(birthDate, today).getYears(); System.out.println("Age: " + age); } }

Period.between(start, end).getYears() gives the whole number of completed years between the two dates, correctly accounting for whether the birthday has occurred yet this year.

On the night US clocks spring forward (2:00 AM becomes 3:00 AM), what does this print, and why?

1 -> 3 — ZonedDateTime is DST-aware: adding one real hour to 01:30 crosses the 2:00->3:00 skip in America/New_York, landing on 03:30, so the hour jumps from 1 to 3 rather than to 2.

Write a program that parses a date string in "yyyy-MM-dd" format into a LocalDate and prints what day of the week it falls on.

import java.time.LocalDate; import java.time.format.DateTimeFormatter; public class DayOfWeekFinder { public static void main(String[] args) { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); LocalDate date = LocalDate.parse("2024-12-25", fmt); System.out.println(date.getDayOfWeek()); } }

LocalDate.parse with the given DateTimeFormatter builds the date, and getDayOfWeek() returns a DayOfWeek enum constant (e.g. WEDNESDAY) computed from the proleptic Gregorian calendar.

ZonedDateTime spring = ZonedDateTime.of(2024, 3, 10, 1, 30, 0, 0, ZoneId.of("America/New_York"));
ZonedDateTime next = spring.plusHours(1);
System.out.println(spring.getHour() + " -> " + next.getHour());