Hire Me
← All Writing Java

The Java Time API — A Practical Field Guide

A no-nonsense guide to java.time — when to use LocalDate vs ZonedDateTime vs Instant, how to handle timezones correctly, formatting, Jackson serialisation, and the pitfalls to avoid.

java.util.Date is a lie. It claims to represent a date, but it actually holds a millisecond offset from the Unix epoch — no timezone, no calendar concept, a toString() that quietly applies the JVM default timezone to display it. java.util.Calendar is worse. I spent years stepping around both of them, using Joda-Time when I could get away with it, before Java 8 finally gave us something worth using.

java.time is — with a few caveats — the right way to model time in Java. The core mistake I still see in codebases is using the wrong type for the job. LocalDateTime where Instant should be. ZonedDateTime where LocalDate is sufficient. Getting the type selection right is the most important thing.

The Core Types and When to Use Each

LocalDate — a calendar date with no time, no timezone. Year, month, day. Use it when the time component is irrelevant: birth dates, publication dates, race day, booking date. A LocalDate is the same wherever you are in the world.

LocalDate raceDay = LocalDate.of(2026, Month.JUNE, 21);
LocalDate today = LocalDate.now(); // only use .now() in application entry points
LocalDate tomorrow = today.plusDays(1);
boolean isPast = raceDay.isBefore(today);

LocalTime — a wall-clock time with no date, no timezone. Use it for things like “office hours start at 09:00” or “morning feed at 07:30”. Rarely needed in isolation; usually combined as LocalDateTime.

LocalDateTime — a date and time with no timezone. This is where most bugs live. A LocalDateTime does not represent a specific moment in time. 2026-06-21T14:30:00 means something different in London than in New York. Never use LocalDateTime to record when something happened — it is ambiguous. Use it only when timezone is explicitly irrelevant (e.g., a “do not schedule before this local time” rule).

ZonedDateTime — a date and time with a full IANA timezone (Europe/London, not GMT+1). Use this when you need to work with human-readable times in a specific region, handle daylight saving transitions, or display times to end users. Converting a scheduled event time for display, calculating a race start in local time — this is ZonedDateTime territory.

ZoneId londonZone = ZoneId.of("Europe/London");
ZonedDateTime raceStart = ZonedDateTime.of(2026, 6, 21, 14, 30, 0, 0, londonZone);

// Convert to UTC for storage or transmission
Instant asInstant = raceStart.toInstant();

// Convert to another timezone for display
ZonedDateTime newYorkTime = raceStart.withZoneSameInstant(ZoneId.of("America/New_York"));

Instant — a point on the UTC timeline, expressed as nanoseconds since the Unix epoch. No timezone. No calendar concept. This is the type for timestamps: when a record was created, when a trade was executed, when an event occurred. Instant is always unambiguous.

Instant tradedAt = Instant.now();
Instant fiveMinutesAgo = tradedAt.minusSeconds(300);
boolean isExpired = Instant.now().isAfter(sessionExpiry);

The rule of thumb: capture and store as Instant, display as ZonedDateTime, represent calendar dates as LocalDate.

Duration and Period

Duration measures time in seconds and nanoseconds — machine-scale differences. Period measures in calendar units (days, months, years) — human-scale differences. They are not interchangeable.

// Duration: how long did something take?
Instant start = Instant.now();
// ... do work ...
Duration elapsed = Duration.between(start, Instant.now());
long ms = elapsed.toMillis();

// Period: how many calendar days until an event?
LocalDate today = LocalDate.now();
LocalDate deadline = LocalDate.of(2026, 9, 1);
Period untilDeadline = Period.between(today, deadline);
int daysLeft = untilDeadline.getDays(); // within the month — use ChronoUnit for total days

// For total elapsed days between two LocalDates, ChronoUnit is cleaner:
long totalDays = ChronoUnit.DAYS.between(today, deadline);

DateTimeFormatter — Thread-Safe, Pattern-Based

SimpleDateFormat is not thread-safe. DateTimeFormatter is immutable and thread-safe — declare it as a static constant.

private static final DateTimeFormatter ISO_DATE =
    DateTimeFormatter.ISO_LOCAL_DATE; // "2026-06-21"

private static final DateTimeFormatter DISPLAY_FORMAT =
    DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm z", Locale.UK);

// Formatting
String isoDate = LocalDate.now().format(ISO_DATE);
String displayTime = ZonedDateTime.now(ZoneId.of("Europe/London"))
    .format(DISPLAY_FORMAT); // "21 Jun 2026 14:30 BST"

// Parsing ISO-8601 instants (from external APIs, queues, etc.)
Instant eventTime = Instant.parse("2026-06-21T13:30:00Z");

// Parsing a local datetime string
LocalDateTime scheduledTime = LocalDateTime.parse(
    "2026-06-21T14:30:00",
    DateTimeFormatter.ISO_LOCAL_DATE_TIME
);

For ISO-8601 with offset (e.g., 2026-06-21T14:30:00+01:00), use OffsetDateTime:

OffsetDateTime withOffset = OffsetDateTime.parse("2026-06-21T14:30:00+01:00");
Instant asInstant = withOffset.toInstant();

Never Use the Default Timezone

LocalDate.now(), LocalDateTime.now(), ZonedDateTime.now() — all of these use the JVM default timezone if you don’t specify one. The default timezone is set from the host OS, can be overridden at runtime, and differs between environments. A server running in UTC produces different results to a developer’s machine in Europe/London when BST is in effect.

Always pass a timezone explicitly in non-test code:

// Bad — depends on JVM default timezone
LocalDate today = LocalDate.now();

// Good — explicit
LocalDate today = LocalDate.now(ZoneId.of("Europe/London"));
LocalDate todayUtc = LocalDate.now(ZoneOffset.UTC);

// Also good — use a Clock bean in Spring, makes testing trivial
@Bean
public Clock clock() {
    return Clock.systemUTC();
}

// Then in your service
LocalDate today = LocalDate.now(clock);

The Clock injection pattern is the cleanest approach in Spring Boot. You can replace it with Clock.fixed(...) in tests to control “now” without mocking.

Jackson Serialisation — Register JavaTimeModule

By default, Jackson serialises Instant as an epoch number. Most API consumers expect ISO-8601 strings. Add jackson-datatype-jsr310 and configure the ObjectMapper:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
@Bean
public ObjectMapper objectMapper() {
    return JsonMapper.builder()
        .addModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .build();
}

In application.yml, Spring Boot’s auto-configured ObjectMapper picks this up automatically if you add the dependency — but you still need WRITE_DATES_AS_TIMESTAMPS disabled:

spring:
  jackson:
    serialization:
      write-dates-as-timestamps: false

With this in place, Instant serialises as "2026-06-21T13:30:00Z" and deserialises back cleanly. LocalDate becomes "2026-06-21". No epoch numbers in your API contract.

Common Pitfalls

Comparing dates across timezone boundaries. Two ZonedDateTime values representing the same moment but in different timezones will return false from .equals(). Use .isEqual() for same-moment comparison, or convert both to Instant first.

atStartOfDay() without timezone. localDate.atStartOfDay() returns a LocalDateTime. If you need an Instant, use localDate.atStartOfDay(ZoneId.of("Europe/London")).toInstant() — clocks-forward transitions mean midnight may not exist in some timezones.

Storing ZonedDateTime in the database. Most relational databases don’t have a native TIMESTAMPTZ (Postgres being the notable exception). Store as TIMESTAMP in UTC by converting to Instant, or annotate with @Column and configure the JDBC driver to use UTC.

Mutability paranoia. All java.time types are immutable. Operations like plusDays() return a new instance — the original is unchanged. This trips people up who are used to Calendar.add().

If you’re maintaining a Java application that still uses java.util.Date and you’d like to modernise the time handling without breaking existing behaviour, get in touch.