Skip to content
CalliCoder

How to Compare Date and Time in Java

Published Updated Java 12 min read

isBefore and isAfter against compareTo and equals, why two ZonedDateTimes for the same moment are not equal, and the truncation that makes a timestamp comparison behave.

Comparing two dates in Java has an obvious answer and one non-obvious rule: equals and isEqual are different methods with different results, and for zoned values they disagree exactly when it matters most. Two ZonedDateTime objects describing the same instant in different zones are not equal and are isEqual.

Written against Java 17.

The comparison methods

Every java.time type implements the same three:

LocalDate a = LocalDate.of(2026, 8, 25);
LocalDate b = LocalDate.of(2026, 12, 1);

a.isBefore(b);    // true
a.isAfter(b);     // false
a.isEqual(b);     // false   (LocalDate, LocalDateTime, ZonedDateTime)
a.equals(b);      // false
a.compareTo(b);   // negative

isBefore and isAfter are what to write in application code. They read as the question being asked, and they avoid the classic compareTo(...) < 0 sign error.

compareTo earns its place in a Comparator and nowhere else:

articles.sort(Comparator.comparing(Article::getPublishedAt).reversed());

Comparator.comparing uses compareTo internally, which is the right level of abstraction to meet it at. It is also worth remembering that compareTo must stay consistent with equals for a TreeSet or a TreeMap to behave, and for ZonedDateTime it deliberately is, which is exactly why it does not compare instants alone.

equals against isEqual

For LocalDate and LocalDateTime the two agree, the objects hold the same fields, so equal fields means an equal object.

For ZonedDateTime and OffsetDateTime they do not:

ZonedDateTime berlin = ZonedDateTime.of(2026, 8, 25, 14, 0, 0, 0, ZoneId.of("Europe/Berlin"));
ZonedDateTime london = berlin.withZoneSameInstant(ZoneId.of("Europe/London"));

berlin.isEqual(london);   // true  — same point on the timeline
berlin.equals(london);    // false — different zone, different object state
berlin.compareTo(london); // NOT zero — compares fields, then zone

equals asks “are these the same object state”. isEqual asks “do these describe the same moment”. Almost every business question is the second one.

This is why a Set<ZonedDateTime> or a Map keyed by one behaves strangely across zones, and why storing the same appointment read from two systems can produce two entries. If a comparison needs to be about the moment, convert to Instant first and the ambiguity disappears:

berlin.toInstant().equals(london.toInstant());   // true

That is the general rule worth taking away: compare instants, not zoned values. A moment has one representation as an Instant and many as a ZonedDateTime.

Comparing dates and times without a zone

LocalTime opening = LocalTime.of(9, 0);
LocalTime now = LocalTime.now();

boolean open = !now.isBefore(opening) && now.isBefore(LocalTime.of(17, 0));

!isBefore rather than isAfter is deliberate, it includes the boundary. Inclusive and exclusive bounds are where range checks go wrong, and !a.isBefore(b) means “a >= b” in one term.

A range check reads better as a small helper than as a compound condition:

static boolean within(LocalDate value, LocalDate from, LocalDate to) {
    return !value.isBefore(from) && !value.isAfter(to);   // both bounds inclusive
}

Precision, and why an equality check fails

Instant stored = repository.findById(id).getCreatedAt();
Instant expected = Instant.parse("2026-08-25T12:32:07Z");

stored.equals(expected);   // false — stored has nanoseconds

Timestamps carry more precision than the value you are comparing against, and databases truncate differently: PostgreSQL keeps microseconds, MySQL DATETIME keeps whole seconds unless declared DATETIME(6). A value that goes in and comes out unequal is almost always this.

Truncate both sides to the precision that means something:

stored.truncatedTo(ChronoUnit.SECONDS).equals(expected.truncatedTo(ChronoUnit.SECONDS));

Or compare with a tolerance:

Duration.between(stored, expected).abs().compareTo(Duration.ofMillis(1)) < 0;

In tests the tolerance form is usually the honest one, because the clock’s precision is a property of the machine. ChronoUnit.MILLIS, SECONDS, MINUTES, HOURS and DAYS all work with truncatedTo; anything larger than a day does not, because months and years are not a fixed length and there is nothing to truncate to.

The distance between two dates

LocalDate from = LocalDate.of(2026, 1, 15);
LocalDate to   = LocalDate.of(2026, 8, 25);

long days = ChronoUnit.DAYS.between(from, to);        // 222
long months = ChronoUnit.MONTHS.between(from, to);    // 7  — whole months only

Period period = Period.between(from, to);             // P7M10D
period.getMonths();                                   // 7
period.getDays();                                     // 10

ChronoUnit.between returns a single number of whole units, truncated toward zero. Period returns the calendar breakdown. Adding period.getMonths() and period.getDays() to get a total is a common error. They are components of one value, not two separate answers.

For time-based amounts, Duration is the equivalent:

Duration elapsed = Duration.between(startInstant, endInstant);
elapsed.toMinutes();

Duration counts seconds and nanoseconds; Period counts years, months and days. The difference is not stylistic: a Duration of 24 hours added across a daylight-saving transition lands on a different wall-clock time than a Period of one day, because a calendar day is sometimes 23 or 25 hours.

Overlapping intervals

The question behind most date comparisons is not “which is earlier” but “do these two ranges collide”: two bookings, two shifts, a discount window against an order. Written directly it is four comparisons and easy to get wrong at the boundaries. Written as the negation it is two:

record Interval(Instant start, Instant end) {

    boolean overlaps(Interval other) {
        return start.isBefore(other.end) && other.start.isBefore(end);
    }

    boolean contains(Instant moment) {
        return !moment.isBefore(start) && moment.isBefore(end);
    }
}

Both use half-open intervals: the start is included, the end is not. That convention is worth adopting deliberately, because it makes adjacent ranges (one ending at 10:00, the next starting at 10:00) not overlap, which is almost always the intent. With inclusive ends they collide at a single instant, and the bug appears only when two ranges happen to touch.

The same convention removes the “23:59:59” habit. A day is [00:00, next 00:00), not [00:00, 23:59:59], and the second form silently drops anything in the final second, a real defect once timestamps carry sub-second precision.

LocalDate day = LocalDate.of(2026, 8, 25);
Instant dayStart = day.atStartOfDay(zone).toInstant();
Instant dayEnd = day.plusDays(1).atStartOfDay(zone).toInstant();

atStartOfDay rather than atTime(0, 0) matters in the few zones where midnight does not exist on a daylight-saving night; it returns the first valid moment instead of throwing away an hour.

Comparing across zones in a query

A database comparison follows the same rule as a Java one: compare instants. A column storing local times cannot answer “which of these happened first” without knowing the zone of every row, so a mixed-zone table makes an ORDER BY meaningless.

Store UTC, filter on UTC, and convert on the way out. When a query genuinely is about a local calendar, “everything that happened on the 25th in Berlin”, compute the two instant bounds in Java as above and pass those, rather than applying a time-zone conversion function to the column, which also prevents the index from being used.

Sorting and null handling

List<Article> sorted = articles.stream()
        .sorted(Comparator.comparing(Article::getPublishedAt,
                Comparator.nullsLast(Comparator.naturalOrder())))
        .toList();

Comparator.comparing throws a NullPointerException on a null key, which for a nullable publishedAt, a draft, is a runtime failure in a sort. nullsFirst and nullsLast state the intent instead.

The legacy types

Date a = new Date();
Date b = new Date();

a.before(b);
a.after(b);
a.compareTo(b);

java.util.Date compares as a millisecond count, which is at least unambiguous. Calendar also implements Comparable, and comparing two Calendar instances with different zones compares the underlying instant, the opposite of ZonedDateTime.compareTo.

Convert on arrival rather than reasoning about which rule applies:

a.toInstant().isBefore(b.toInstant());

Related: getting the current date and time, formatting, and adding or subtracting. The Comparable and Comparator walkthrough covers the sorting side in general.

Frequently asked questions

Should I use isBefore or compareTo?

isBefore and isAfter in application code, they read as the question. compareTo belongs inside a Comparator.

What is the difference between equals and isEqual?

equals compares object state including the zone; isEqual compares the point on the timeline. For ZonedDateTime they disagree whenever two values describe the same moment in different zones.

Why are two ZonedDateTimes for the same moment not equal?

Because they hold different zones, and equals includes it. Use isEqual, or convert both to Instant.

How do I compare only the date part of two timestamps?

Convert with a zone — instant.atZone(zone).toLocalDate(), and compare the LocalDates. Comparing timestamps and hoping the times match is the bug this avoids.

Why does an equality check on a stored timestamp fail?

Precision. The database truncated the value. Compare with truncatedTo(ChronoUnit.SECONDS) or a tolerance.

How many days are between two dates?

ChronoUnit.DAYS.between(from, to). It returns whole units truncated toward zero, so a partial day does not count.

When should I use Period instead of Duration?

Period for calendar amounts, years, months, days. Duration for exact time. Across a daylight-saving change, one day and 24 hours are different amounts.

How do I check whether a date falls in a range?

!value.isBefore(from) && !value.isAfter(to) for inclusive bounds. Writing it as a named helper makes the inclusivity visible.

How do I sort a list where the date can be null?

Wrap the key comparator with Comparator.nullsFirst or nullsLast. Plain Comparator.comparing throws on a null key.

Can I compare a LocalDateTime with an Instant?

Not directly. One has no zone and the other has no calendar fields. Attach a zone to the LocalDateTime, or convert the Instant with atZone, and compare like with like.