> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use LocalDate, LocalDateTime, ZonedDateTime in Java
- URL: https://www.ggorantala.dev/how-to-work-localdate-localdatetime-zoneddatetime-java/
- Published: 2026-04-08T05:28:00.000Z
- Updated: 2026-10-31T06:30:50.000Z
- Description: Build a ticket SLA tracker in Java 21 that parses, adds and prints times across DST changes with LocalDate, LocalDateTime and ZonedDateTime.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-time, java-tutorials

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:

```log
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.

```log
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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) 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:

```bash
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:

```log
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:

```bash
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`

```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`

```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`

```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`

```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`

```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`

```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.

```bash
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

```log
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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) 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`

```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:

```bash
javac --release 21 -d out src/tickets/*.java
java -cp out tickets.Checks

```

```log
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](https://ggorantala.dev/how-to-convert-date-instant-localdatetime-java/?ref=ggorantala.dev), [DateTimeFormatter patterns](https://ggorantala.dev/how-to-format-parse-dates-datetimeformatter-java/?ref=ggorantala.dev), [duration and period](https://ggorantala.dev/how-to-calculate-duration-period-java/?ref=ggorantala.dev) and [time zones](https://ggorantala.dev/how-to-handle-time-zones-java/?ref=ggorantala.dev). 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

- [java.time package summary, Java SE 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/package-summary.html?ref=ggorantala.dev)
- [ZonedDateTime, Java SE 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/ZonedDateTime.html?ref=ggorantala.dev)
- [DateTimeFormatter and ResolverStyle, Java SE 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html?ref=ggorantala.dev)
- [ZoneId.SHORT\_IDS, Java SE 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/ZoneId.html?ref=ggorantala.dev)
- [IntelliJ IDEA 2026.2.2 release post](https://blog.jetbrains.com/idea/2026/09/intellij-idea-2026-2-2/?ref=ggorantala.dev) (observed 2026-10-25)
- [Eclipse IDE 2026 release coverage, ADT Mag](https://adtmag.com/articles/2026/06/15/ide-2026-adds-java-26-support-and-developer-tooling-updates.aspx?ref=ggorantala.dev) (observed 2026-10-25)