Skip to content

How to Use LocalDate, LocalDateTime, ZonedDateTime in Java

Build a ticket SLA tracker in Java 21 that parses, adds and prints times across DST changes with LocalDate, LocalDateTime and ZonedDateTime.

how-to-tutorials java-time java-tutorials
Gopi Gorantala
Reading Progress

On This Page

A support ticket comes in at 23:30 on Saturday night in Berlin. The promise is "first answer within four hours". Here's what the finished program says the deadline is:

T-1002 URGENT
  opened   Sat 24 Oct 2026, 23:30 (CEST, +02:00)
  due      Sun 25 Oct 2026, 02:30 (CET, +01:00)
  customer Sat 24 Oct 2026, 21:30 (EDT, -04:00)
  stored   2026-10-25T01:30:00Z
  due date for customer 2026-10-24

Look at the wall clocks: 23:30 to 02:30 is three hours, not four. Nothing is wrong. Berlin's clocks went back that night, so the same Sunday morning hour happens twice, and four real hours is three on the wall. If your code stores "2026-10-25 03:30" as a plain timestamp, it's off by an hour and nobody sees it until a customer complains.

That's the problem this article solves, and it's the practical answer to how to use LocalDate, LocalDateTime and ZonedDateTime in Java without guessing which one a given line needs. You'll build a small SLA tracker for a support-ticket queue: parse what a person types, work out deadlines (elapsed hours for urgent tickets, working days for normal ones), and print times in the team's zone and the customer's zone. Five short classes, a Main, and twenty-one checks that pin the behaviour. It's for Java developers who already know the old Date and Calendar mess exists and want to know what to use now.

text typed by a person ──> TicketParser ──> Instant ──> Ticket
                                                          │
                          SlaCalculator  <────────────────┘
                          (Duration, working days, ZonedDateTime)
                                  │
                      SlaFormat ──┴──> "Tue 27 Oct 2026, 16:45 (CET, +01:00)"

Before you start

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
Build tool none, plain javac javac -version
IntelliJ IDEA (optional) 2026.2.2 Help → About
Eclipse IDE (optional) 2026-06 Help → About Eclipse IDE
OS I ran it on Linux, system zone UTC date +%Z

Everything here uses java.time, which has shipped with the JDK since Java 8, so any JDK from 17 up is fine. The code uses records and a switch expression, which need 16 and 14 respectively. I built with --release 21.

I didn't open either IDE for this article. The IDE steps come from the vendors' docs and memory, and the versions come from their announcement pages. Every block of console output below was produced by the code in this article, on the machine described above.

If you need a JDK, install one with SDKMAN (sdk list java shows what's current) or from your vendor's download page.

Two prerequisites: you're comfortable with records (I use them for Ticket; the records article covers them) and you know what a time zone is in everyday terms. I'll define the rest where it first appears.

Make the SLA project skeleton

No build tool for this one. It's seven source files, and javac is faster than a pom.xml for that.

From a terminal. Create the folders, then add the files from the steps below:

mkdir -p sla-tracker/src/tickets sla-tracker/out
cd sla-tracker

In IntelliJ IDEA. File → New → Project. Choose Java, build system IntelliJ, pick JDK 21, untick "Add sample code". Right-click src → New → Package → tickets. Add each class under that package.

In Eclipse. File → New → Java Project, name it sla-tracker, JRE 21. Then right-click src → New → Package → tickets.

When you're done, the tree looks like this:

sla-tracker/
├── out/                      (compiled classes land here)
└── src/
    └── tickets/
        ├── Checks.java
        ├── Main.java
        ├── Priority.java
        ├── SlaCalculator.java
        ├── SlaFormat.java
        ├── Ticket.java
        └── TicketParser.java

Compile and run commands, which we'll use after every step that has something to run:

javac --release 21 -d out src/tickets/*.java
java -cp out tickets.Main

On Windows cmd, use javac --release 21 -d out src\tickets\*.java. I didn't run it on Windows; the java.time behaviour is identical, only the path separator changes.

How to work with LocalDate, LocalDateTime and ZonedDateTime: five steps

Before the code, the cast list. You'll need all of these, and they are different things:

  • Instant: one exact moment on the timeline. No zone, no calendar. The same value for everyone on Earth. This is what you store.
  • LocalDate, LocalTime, LocalDateTime: a calendar date, a clock reading, or both, with no zone at all. "2026-10-25 02:30" on its own isn't a moment; it's a label that points at different moments in different places, or at two moments in one place.
  • ZonedDateTime: a LocalDateTime plus a ZoneId plus the offset that applies. This is the one that knows about daylight saving time, so this is where you do calendar arithmetic.
  • Duration counts elapsed seconds. Period counts calendar units (days, months). They give different answers across a DST change, and that difference runs through this whole article.

Step 1: Model a ticket around an Instant

Goal: pick the type the ticket stores. Everything else derives from that choice.

src/tickets/Priority.java

package tickets;

public enum Priority {
    /** Answer within four hours of elapsed time, weekends included. */
    URGENT,
    /** Answer within two working days, counted on the support team's calendar. */
    NORMAL
}

src/tickets/Ticket.java

package tickets;

import java.time.Instant;
import java.time.ZoneId;
import java.util.Objects;

/**
 * openedAt is an Instant: one exact moment, the same for everybody.
 * customerZone only decides how we show times to this customer.
 */
public record Ticket(String id, Instant openedAt, Priority priority, ZoneId customerZone) {

    public Ticket {
        Objects.requireNonNull(id, "id");
        Objects.requireNonNull(openedAt, "openedAt");
        Objects.requireNonNull(priority, "priority");
        Objects.requireNonNull(customerZone, "customerZone");
    }
}

openedAt is an Instant and customerZone is only a display preference. I'd store it this way every time: one exact moment, plus the zone you want to show it in. Storing a LocalDateTime here is the mistake that produces the missing hour from the opening.

Checkpoint: javac --release 21 -d out src/tickets/Priority.java src/tickets/Ticket.java prints nothing and creates out/tickets/Ticket.class.

Step 2: Parse what a person types

Goal: turn "2026-10-23 16:45" plus a zone into an Instant, and refuse input that can't be trusted.

A person types a LocalDateTime. To get a moment out of it you must say where they were, and two situations make that ambiguous. In spring a local time can fall into a gap: Berlin skips 02:00 to 03:00 on 29 March 2026, so "02:30" never happened. In autumn a local time can fall into an overlap: "02:30" on 25 October 2026 happened twice. By default LocalDateTime.atZone quietly picks something for both cases. I'd rather reject them and ask.

src/tickets/TicketParser.java

package tickets;

import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;

public final class TicketParser {

    // STRICT + "uuuu": 2026-02-30 is an error instead of a quietly invented date.
    private static final DateTimeFormatter LOCAL_INPUT =
            DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT);

    private TicketParser() {
    }

    /** "2026-10-23 16:45" typed by a person, in a zone we know. Rejects times that don't exist or happen twice. */
    public static Instant parseLocal(String text, ZoneId zone) {
        LocalDateTime local = LocalDateTime.parse(text, LOCAL_INPUT);
        var rules = zone.getRules();
        var offsets = rules.getValidOffsets(local);
        if (offsets.isEmpty()) {
            throw new DateTimeException(text + " does not exist in " + zone + " (clocks skip it)");
        }
        if (offsets.size() > 1) {
            throw new DateTimeException(text + " happens twice in " + zone + "; send an offset like +01:00");
        }
        return ZonedDateTime.of(local, zone).toInstant();
    }

    /** "2026-10-25T02:30:00+01:00" from another system: the offset removes all doubt. */
    public static Instant parseWithOffset(String text) {
        return OffsetDateTime.parse(text).toInstant();
    }
}

Two lines matter most. The formatter uses uuuu, not yyyy, together with ResolverStyle.STRICT; I'll show what yyyy does in the troubleshooting section. And getValidOffsets returns zero offsets for a gap, one for a normal time, and two for an overlap, which is a more honest signal than comparing round-trip values.

Checkpoint: compile everything so far, nothing prints on success.

Step 3: Calculate the deadline with the right kind of arithmetic

Goal: urgent tickets get four elapsed hours; normal tickets get two working days on the support team's calendar.

This is where Duration and calendar arithmetic part ways. Four hours is a length of time, so you add it to the Instant. Two working days is a calendar idea ("same time, two weekdays later"), so you convert to a ZonedDateTime in the team's zone and move by days. LocalTime OPENING is a clock reading with no date, and LocalDate shows up in dueDateForCustomer, which asks "what calendar day is that deadline on, for the customer?"

src/tickets/SlaCalculator.java

package tickets;

import java.time.DayOfWeek;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.TemporalAdjusters;

public final class SlaCalculator {

    private static final LocalTime OPENING = LocalTime.of(9, 0);

    private final ZoneId teamZone;

    public SlaCalculator(ZoneId teamZone) {
        this.teamZone = teamZone;
    }

    public Instant deadline(Ticket ticket) {
        return switch (ticket.priority()) {
            case URGENT -> ticket.openedAt().plus(Duration.ofHours(4));
            case NORMAL -> addWorkingDays(ticket.openedAt(), 2);
        };
    }

    /** Calendar arithmetic: done on a ZonedDateTime, so "same local time" survives a DST change. */
    Instant addWorkingDays(Instant start, int days) {
        ZonedDateTime cursor = start.atZone(teamZone);
        if (isWeekend(cursor.getDayOfWeek())) {
            // Opened on a weekend: the clock starts Monday at opening time.
            cursor = cursor.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
                           .with(OPENING);
        }
        int added = 0;
        while (added < days) {
            cursor = cursor.plusDays(1);
            if (!isWeekend(cursor.getDayOfWeek())) {
                added++;
            }
        }
        return cursor.toInstant();
    }

    public LocalDate dueDateForCustomer(Ticket ticket) {
        return deadline(ticket).atZone(ticket.customerZone()).toLocalDate();
    }

    private static boolean isWeekend(DayOfWeek day) {
        return day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY;
    }
}

Two things to notice. cursor.plusDays(1) keeps the local time of day even when a clock change sits in between, and TemporalAdjusters.next(DayOfWeek.MONDAY) followed by .with(OPENING) gives you "next Monday at 09:00" in two readable lines. A weekend ticket starts its clock on Monday morning; that's my rule for this example, so change it if your contract says otherwise.

Checkpoint: compile all the files so far. Nothing prints.

Step 4: Format times for people, store them as ISO text

Goal: one formatter for humans, one string form for storage.

src/tickets/SlaFormat.java

package tickets;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public final class SlaFormat {

    // HH = 24-hour clock, uuuu = year, Locale.ENGLISH pins day and month names.
    private static final DateTimeFormatter FRIENDLY =
            DateTimeFormatter.ofPattern("EEE d MMM uuuu, HH:mm (zzz, xxx)", Locale.ENGLISH);

    private SlaFormat() {
    }

    public static String friendly(Instant moment, ZoneId zone) {
        return FRIENDLY.format(moment.atZone(zone));
    }

    /** What you store or send to another system: ISO-8601, UTC, always sortable as text. */
    public static String iso(Instant moment) {
        return moment.toString();
    }
}

zzz prints the zone abbreviation (CET, EDT) and xxx prints the offset (+01:00). I print both because abbreviations are ambiguous (CST is three different zones) and offsets alone don't tell a reader the place. Locale.ENGLISH is deliberate. Without it the day and month names follow whatever the JVM's default locale is, and I'll show that bite you later. Instant.toString() is already ISO-8601 in UTC, and strings in that form sort in time order, so that's your storage format.

Step 5: Run a normal ticket and an urgent ticket

Goal: see it work.

src/tickets/Main.java

package tickets;

import java.time.Instant;
import java.time.ZoneId;

public class Main {

    public static void main(String[] args) {
        ZoneId berlin = ZoneId.of("Europe/Berlin");
        ZoneId newYork = ZoneId.of("America/New_York");
        SlaCalculator sla = new SlaCalculator(berlin);

        Instant opened = TicketParser.parseLocal("2026-10-23 16:45", berlin);
        Ticket normal = new Ticket("T-1001", opened, Priority.NORMAL, newYork);
        Ticket urgent = new Ticket("T-1002", TicketParser.parseLocal("2026-10-24 23:30", berlin),
                Priority.URGENT, newYork);

        for (Ticket t : new Ticket[] {normal, urgent}) {
            Instant due = sla.deadline(t);
            System.out.println(t.id() + " " + t.priority());
            System.out.println("  opened   " + SlaFormat.friendly(t.openedAt(), berlin));
            System.out.println("  due      " + SlaFormat.friendly(due, berlin));
            System.out.println("  customer " + SlaFormat.friendly(due, t.customerZone()));
            System.out.println("  stored   " + SlaFormat.iso(due));
            System.out.println("  due date for customer " + sla.dueDateForCustomer(t));
        }
    }
}

Checkpoint: compile and run.

javac --release 21 -d out src/tickets/*.java
java -cp out tickets.Main

You should see the output from the top of the article for T-1002, preceded by T-1001. If you do, the hard part's done. The remaining sections explain what you're looking at, then prove it and then break it.

Run it and read the dates

T-1001 NORMAL
  opened   Fri 23 Oct 2026, 16:45 (CEST, +02:00)
  due      Tue 27 Oct 2026, 16:45 (CET, +01:00)
  customer Tue 27 Oct 2026, 11:45 (EDT, -04:00)
  stored   2026-10-27T15:45:00Z
  due date for customer 2026-10-27
T-1002 URGENT
  opened   Sat 24 Oct 2026, 23:30 (CEST, +02:00)
  due      Sun 25 Oct 2026, 02:30 (CET, +01:00)
  customer Sat 24 Oct 2026, 21:30 (EDT, -04:00)
  stored   2026-10-25T01:30:00Z
  due date for customer 2026-10-24

Read T-1001 first. It was opened Friday 16:45 Berlin time, summer time (CEST, +02:00). Two working days later is Tuesday 16:45, and by then Berlin is on winter time (CET, +01:00). The wall clock says 16:45 both times, but the instant is 15:45 UTC, one hour more than four full days of elapsed time after the opening (14:45 UTC on Friday). The customer in New York sees 11:45 on Tuesday, still on summer time (EDT, -04:00) because the US changes its clocks a week later.

T-1002 is the case from the top. Urgent means four elapsed hours, so the deadline is 01:30Z. Berlin shows that as 02:30 winter time, because the overlap hour was in between. New York shows 21:30 on Saturday, so the customer's due date is the 24th, one day before Berlin's. That's why dueDateForCustomer converts to the customer's zone before taking the date.

Twenty-one checks, including the two days that lie

JUnit would be the usual choice here. Maven Central wasn't reachable from the machine I built this on, so the checks are a plain main that prints PASS or FAIL and exits with a non-zero status on any failure. If you're on a normal network, the JUnit 5 article shows how to move them over (that one's not written yet). The expected strings are the point; the runner isn't.

src/tickets/Checks.java

package tickets;

import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.time.temporal.UnsupportedTemporalTypeException;
import java.util.function.Supplier;

public class Checks {

    static int passed;
    static int failed;

    static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
    static final ZoneId NEW_YORK = ZoneId.of("America/New_York");

    public static void main(String[] args) {
        SlaCalculator sla = new SlaCalculator(BERLIN);

        // Duration vs calendar days across the autumn change (Berlin, 2026-10-25 has 25 hours)
        ZonedDateTime sat = ZonedDateTime.of(2026, 10, 24, 12, 0, 0, 0, BERLIN);
        eq("24 hours later is 11:00 on Sunday", sat.plus(Duration.ofHours(24)).toLocalTime().toString(), "11:00");
        eq("one calendar day later is 12:00 on Sunday", sat.plusDays(1).toLocalTime().toString(), "12:00");
        eq("that Sunday is 25 hours long", String.valueOf(Duration.between(
                sat.toLocalDate().plusDays(1).atStartOfDay(BERLIN),
                sat.toLocalDate().plusDays(2).atStartOfDay(BERLIN)).toHours()), "25");

        // Spring change: 2026-03-29 has 23 hours
        eq("spring Sunday is 23 hours long", String.valueOf(Duration.between(
                LocalDate.of(2026, 3, 29).atStartOfDay(BERLIN),
                LocalDate.of(2026, 3, 30).atStartOfDay(BERLIN)).toHours()), "23");

        // SLA rules
        Ticket urgent = new Ticket("U", TicketParser.parseLocal("2026-10-24 23:30", BERLIN), Priority.URGENT, NEW_YORK);
        eq("urgent: 4 elapsed hours cross the overlap, wall clock moves 3h",
                SlaFormat.friendly(sla.deadline(urgent), BERLIN), "Sun 25 Oct 2026, 02:30 (CET, +01:00)");
        Ticket fri = new Ticket("F", TicketParser.parseLocal("2026-10-23 16:45", BERLIN), Priority.NORMAL, NEW_YORK);
        eq("normal opened Friday is due Tuesday same local time",
                SlaFormat.friendly(sla.deadline(fri), BERLIN), "Tue 27 Oct 2026, 16:45 (CET, +01:00)");
        Ticket sun = new Ticket("S", TicketParser.parseLocal("2026-10-25 11:00", BERLIN), Priority.NORMAL, NEW_YORK);
        eq("normal opened Sunday starts Monday 09:00, due Wednesday 09:00",
                SlaFormat.friendly(sla.deadline(sun), BERLIN), "Wed 28 Oct 2026, 09:00 (CET, +01:00)");
        Ticket spring = new Ticket("P", TicketParser.parseLocal("2026-03-27 16:45", BERLIN), Priority.NORMAL, NEW_YORK);
        eq("normal across spring change keeps 16:45 local",
                SlaFormat.friendly(sla.deadline(spring), BERLIN), "Tue 31 Mar 2026, 16:45 (CEST, +02:00)");
        eq("customer due date is read in the customer's zone",
                sla.dueDateForCustomer(urgent).toString(), "2026-10-24");

        // Parsing
        eq("gap time rejected", msg(() -> TicketParser.parseLocal("2026-03-29 02:30", BERLIN)),
                "2026-03-29 02:30 does not exist in Europe/Berlin (clocks skip it)");
        eq("overlap time rejected", msg(() -> TicketParser.parseLocal("2026-10-25 02:30", BERLIN)),
                "2026-10-25 02:30 happens twice in Europe/Berlin; send an offset like +01:00");
        eq("offset removes the doubt (first pass)",
                TicketParser.parseWithOffset("2026-10-25T02:30:00+02:00").toString(), "2026-10-25T00:30:00Z");
        eq("offset removes the doubt (second pass)",
                TicketParser.parseWithOffset("2026-10-25T02:30:00+01:00").toString(), "2026-10-25T01:30:00Z");
        throwsType("Feb 30 rejected by STRICT", DateTimeParseException.class,
                () -> TicketParser.parseLocal("2026-02-30 10:00", BERLIN));
        throwsType("24:00 rejected", DateTimeParseException.class,
                () -> TicketParser.parseLocal("2026-02-10 24:00", BERLIN));

        // Types
        throwsType("Instant can't add months", UnsupportedTemporalTypeException.class,
                () -> Instant.parse("2026-10-25T00:00:00Z").plus(1, ChronoUnit.MONTHS));
        eq("Instant can add days (exactly 24h each)",
                Instant.parse("2026-10-25T00:00:00Z").plus(1, ChronoUnit.DAYS).toString(), "2026-10-26T00:00:00Z");
        eq("Period counts calendar", Period.between(LocalDate.of(2026, 1, 31), LocalDate.of(2026, 3, 1)).toString(), "P1M1D");
        eq("ChronoUnit.DAYS counts days", String.valueOf(ChronoUnit.DAYS.between(
                LocalDate.of(2026, 1, 31), LocalDate.of(2026, 3, 1))), "29");
        eq("month end is clamped", LocalDate.of(2026, 1, 31).plusMonths(1).toString(), "2026-02-28");
        eq("ISO storage form sorts as text", String.valueOf(
                "2026-10-25T00:30:00Z".compareTo("2026-10-25T01:30:00Z") < 0), "true");

        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) {
            System.exit(1);
        }
    }

    static void eq(String name, String actual, String expected) {
        if (actual.equals(expected)) {
            passed++;
            System.out.println("PASS  " + name);
        } else {
            failed++;
            System.out.println("FAIL  " + name + "\n      expected: " + expected + "\n      actual:   " + actual);
        }
    }

    static void throwsType(String name, Class<? extends Exception> type, Runnable r) {
        try {
            r.run();
            failed++;
            System.out.println("FAIL  " + name + " (nothing thrown)");
        } catch (Exception e) {
            if (type.isInstance(e)) {
                passed++;
                System.out.println("PASS  " + name);
            } else {
                failed++;
                System.out.println("FAIL  " + name + " (got " + e + ")");
            }
        }
    }

    static String msg(Supplier<?> s) {
        try {
            s.get();
            return "nothing thrown";
        } catch (DateTimeException e) {
            return e.getMessage();
        }
    }
}

Run them:

javac --release 21 -d out src/tickets/*.java
java -cp out tickets.Checks
PASS  24 hours later is 11:00 on Sunday
PASS  one calendar day later is 12:00 on Sunday
PASS  that Sunday is 25 hours long
PASS  spring Sunday is 23 hours long
PASS  urgent: 4 elapsed hours cross the overlap, wall clock moves 3h
PASS  normal opened Friday is due Tuesday same local time
PASS  normal opened Sunday starts Monday 09:00, due Wednesday 09:00
PASS  normal across spring change keeps 16:45 local
PASS  customer due date is read in the customer's zone
PASS  gap time rejected
PASS  overlap time rejected
PASS  offset removes the doubt (first pass)
PASS  offset removes the doubt (second pass)
PASS  Feb 30 rejected by STRICT
PASS  24:00 rejected
PASS  Instant can't add months
PASS  Instant can add days (exactly 24h each)
PASS  Period counts calendar
PASS  ChronoUnit.DAYS counts days
PASS  month end is clamped
PASS  ISO storage form sorts as text

21 passed, 0 failed

The first four checks are the ones to remember. A Sunday in Berlin's autumn is 25 hours long and its spring counterpart is 23. "Plus 24 hours" and "plus one day" are different operations, and the first four checks show that on a real Saturday.

What java.time does with a changing clock

The core rule: a ZonedDateTime is an Instant seen through a set of zone rules. Those rules (the tz database that ships inside the JDK, or the system copy on some Linux packages) say "at this instant, Berlin's offset is +02:00; at that instant, it's +01:00". So when you call plusDays(1) on a ZonedDateTime the API changes the local date, then asks the rules what offset applies there. When you call plus(Duration.ofHours(24)) it changes the instant and then asks the rules how that looks locally. Same starting value, two different questions.

LocalDateTime has no rules to ask, which is why it can't become an Instant without help. Java makes you pass a zone (atZone) or an offset (toInstant(ZoneOffset)) and that's the compiler holding you to the question "where were you?".

For the next layers of detail, there are separate tutorials on converting between Date, Instant and LocalDateTime, DateTimeFormatter patterns, duration and period and time zones. None of those is published yet, so those four links will 404 until they are. I'm not repeating their material here.

Date bugs I hit on purpose

Most of these I forced after the checks were green, to get the exact messages. The two compile errors and the locale one I'd already hit in other projects, so I reproduced them here.

Text '2026-02-30' could not be parsed: Invalid date 'FEBRUARY 30'

Cause: LocalDate.parse on the ISO format is strict about real dates. 2026-04-31 fails the same way. Fix: nothing to fix, that's the library doing its job; catch DateTimeParseException and show the user a message. If you pick ResolverStyle.SMART instead, 2026-04-31 becomes 2026-04-30 without a word, which I wouldn't accept for deadlines.

Unable to obtain LocalDate from TemporalAccessor: {YearOfEra=2026, MonthOfYear=10, DayOfMonth=25},ISO of type java.time.format.Parsed

Cause: the pattern said yyyy (year of era) and the resolver was STRICT. Strict mode won't guess whether year-of-era 2026 means AD or BC. Fix: use uuuu for the year, as TicketParser does. Without STRICT, yyyy works, which is why this bites only when someone tightens the parser.

java.time.zone.ZoneRulesException: Unknown time-zone ID: EST

Cause: three-letter IDs like EST aren't region IDs. Fix: use America/New_York, or if you must accept legacy IDs, ZoneId.of("EST", ZoneId.SHORT_IDS), which gave me -05:00 (a fixed offset, no daylight saving). Europe/Berlin with a trailing space gave Invalid ID for region-based ZoneId, invalid format, and Europe/Berlinn gave Unknown time-zone ID. Trim input and catch both.

java.time.DateTimeException: Unable to extract ZoneId from temporal 2026-10-25T02:30

Cause: a pattern with zzz was applied to a LocalDateTime, which has no zone to print. Fix: convert to a ZonedDateTime first, as SlaFormat.friendly does.

UnsupportedTemporalTypeException: Unsupported field: Year

Cause: a date pattern (uuuu-MM-dd) applied straight to an Instant. An Instant has no calendar, so it has no year. Fix: instant.atZone(zone) first; the check "Instant can't add months" is the same mistake in arithmetic form (Instant takes days, which are exactly 24 hours, but not months).

Compile errors from mixing the types. LocalDateTime.now().toInstant() fails with method toInstant in interface ChronoLocalDateTime<D> cannot be applied to given types; required: ZoneOffset; found: no arguments, and Instant.now().getMonthValue() fails with cannot find symbol. Both are the type system saying "you skipped the zone".

The date that changes with the machine. DateTimeFormatter.ofPattern("EEE d MMM uuuu") without a locale printed Mon 26 Oct 2026 on my machine and Mo. 26 Okt. 2026 when I started the JVM with -Duser.language=de -Duser.country=DE. Always pass a Locale for text you parse or compare, and Locale.ROOT or Locale.ENGLISH for machine-facing text.

The zone that changes with the machine. ZoneId.systemDefault() printed Etc/UTC here and America/Los_Angeles with TZ=America/Los_Angeles. Any code calling LocalDateTime.now() or ZonedDateTime.now() without a zone gets that, so servers in different regions disagree. Pass a Clock or a ZoneId explicitly.

The gap and the overlap, with no guard. LocalDateTime.of(2026,3,29,2,30).atZone(Berlin) quietly returned 2026-03-29T03:30+02:00[Europe/Berlin], and the overlap one returned 2026-10-25T02:30+02:00[Europe/Berlin], the earlier of the two. withLaterOffsetAtOverlap() flips to +01:00. Fine if you've decided that on purpose; a bug if you didn't.

One accidental snag: my first draft of the urgent ticket opened at 22:30, and its deadline landed at 02:30 CEST, before the clocks changed, so the demo showed nothing odd. Moving it to 23:30 pushed the four hours across the overlap. Pick test data on the changeover night or you'll never see the bug in your own tests. And hh:mm on a 16:45 time printed 04:45 with no AM/PM marker; hh is the 12-hour clock and HH is the 24-hour one.

Before it meets real customers

  • Store Instant (or timestamptz in PostgreSQL, TIMESTAMP WITH TIME ZONE in Oracle). Never LocalDateTime for "when did this happen".
  • Keep the user's zone as a separate column, as a region ID like Europe/Berlin, not an offset.
  • Inject a java.time.Clock into anything that calls now(), so tests can freeze it with Clock.fixed(...).
  • Business calendars need holidays. SlaCalculator skips weekends only; a real one needs a holiday list per team.
  • The tz database changes several times a year, because governments change their rules. Keep your JDK patched; an old JDK gets a country's new DST dates wrong.
  • Version note: the java.time API I used is the same from Java 8 to 21, apart from the language features (records need 16, the switch expression 14). On Java 8 you'd write the Ticket as a plain class.

Five rules to keep

  • Store an Instant; show a ZonedDateTime.
  • A LocalDateTime isn't a moment. Don't put one in a database column named created_at.
  • Add elapsed time to an Instant or with a Duration; add calendar days to a ZonedDateTime.
  • Reject gaps and overlaps in human input, or ask for an offset.
  • Pass a Locale and a ZoneId or Clock explicitly. Never lean on the machine's defaults.

Questions people ask about LocalDate, LocalDateTime and ZonedDateTime

When should I use LocalDateTime instead of ZonedDateTime?
Use LocalDateTime for something that really is zone-less: a shop's opening hours ("09:00 every day"), or a recurring alarm that should follow the wall clock. Use ZonedDateTime or Instant for anything that happened or will happen at one moment. If you can't answer "where?", you don't have a moment yet.

How do I convert a LocalDateTime to an Instant?
Give it a zone: local.atZone(ZoneId.of("Europe/Berlin")).toInstant(), or give it an offset: local.toInstant(ZoneOffset.UTC). The first handles daylight saving time, the second only works when you already know the fixed offset. The parser above shows how to guard against gaps and overlaps first.

Is plusDays(1) the same as plus 24 hours in Java?
On a ZonedDateTime, no. plusDays(1) keeps the local time and gives you 23 or 25 hours across a clock change; plus(Duration.ofHours(24)) gives exactly 24 hours and the local time moves. On a LocalDate, only plusDays exists.

Why does my date print differently on the server than on my laptop?
The default time zone and locale differ. Pass both explicitly, or set -Duser.timezone= and -Duser.language= on the JVM as a stopgap while you fix the code.

Sources

how-to-tutorialsjava-timejava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments