To get the object with the max date from a list, we stream the list and call max(Comparator.comparing(Release::date)), which returns an Optional with the whole object whose date is the latest. The method min() with the same comparator returns the object with the earliest date.
We need this whenever a list of records carries a date and the screen shows the newest or the oldest one, such as the latest release of an app, the most recent payment of a customer or the oldest open support request.
The following example uses a small release history, where each release is a record with a product name, a version and a release date.
record Release(String product, String version, LocalDate date) {}
List<Release> releases = List.of(
new Release("app", "1.0", LocalDate.of(2026, 1, 15)),
new Release("app", "1.2", LocalDate.of(2026, 6, 3)),
new Release("api", "2.0", LocalDate.of(2026, 4, 10)),
new Release("app", "1.1", LocalDate.of(2026, 3, 20)));
Optional<Release> latest = releases.stream().max(Comparator.comparing(Release::date)); // Optional[Release[product=app, version=1.2, date=2026-06-03]]
String latestVersion = latest.map(Release::version).orElse("none"); // "1.2"
String earliestVersion = releases.stream().min(Comparator.comparing(Release::date)).map(Release::version).orElse("none"); // "1.0"
Notice that we never call get() on the result, so an empty list gives “none” instead of an exception. We go on with the comparator itself, empty lists, null dates, ties, the latest object per group, and other date types.
1. Comparing Objects by Their Date Field
The method max() of a Stream needs a Comparator that says which of two objects is larger. The factory method Comparator.comparing() builds one from a key extractor such as Release::date. It works because LocalDate implements Comparable, where a later date is larger than an earlier one.

A comparator is a value, so we can store it in a variable and reuse it for max(), min() and sorting. Reversing it swaps the meaning, which is why max(byDate.reversed()) returns the same object as min(byDate). We use min() for the earliest date, because it says what we mean.
List<Release> releases = List.of(
new Release("app", "1.0", LocalDate.of(2026, 1, 15)),
new Release("app", "1.2", LocalDate.of(2026, 6, 3)),
new Release("api", "2.0", LocalDate.of(2026, 4, 10)),
new Release("app", "1.1", LocalDate.of(2026, 3, 20)));
Comparator<Release> byDate = Comparator.comparing(Release::date);
String newest = releases.stream().max(byDate).map(Release::version).orElse("none"); // "1.2"
String oldest = releases.stream().min(byDate).map(Release::version).orElse("none"); // "1.0"
String oldestReversed = releases.stream().max(byDate.reversed()).map(Release::version).orElse("none"); // "1.0"
2. Getting the Object With Max Date From a Possibly Empty List
Both max() and min() return an empty Optional when the list is empty. Calling get() on it throws a NoSuchElementException, which is the bug in many copied examples. We pick the Optional method that matches what the screen should do when there is no data.
List<Release> none = List.of();
Optional<Release> nothing = none.stream().max(Comparator.comparing(Release::date)); // Optional.empty
String label = nothing.map(Release::version).orElse("no releases yet"); // "no releases yet"
Release mustExist = nothing.orElseThrow(); // NoSuchElementException: No value present
When an empty list is a programming error, orElseThrow() fails fast with a clear exception. When it is a normal state, such as a new product without releases, orElse() or ifPresentOrElse() handles it.
3. Getting Only the Max Date
Sometimes the screen needs the date, not the object, for example a “last release on” label. We map each object to its date and call max() with the natural order of LocalDate.
List<Release> releases = List.of(
new Release("app", "1.0", LocalDate.of(2026, 1, 15)),
new Release("app", "1.2", LocalDate.of(2026, 6, 3)),
new Release("api", "2.0", LocalDate.of(2026, 4, 10)),
new Release("app", "1.1", LocalDate.of(2026, 3, 20)));
Optional<LocalDate> lastDate = releases.stream().map(Release::date).max(Comparator.naturalOrder()); // Optional[2026-06-03]
Optional<LocalDate> firstDate = releases.stream().map(Release::date).min(Comparator.naturalOrder()); // Optional[2026-01-15]
4. Lists Where Some Dates Are null
A release that is planned but not yet published can have a null date. The comparator from Comparator.comparing(Release::date) calls compareTo() on the dates, so a null date throws a NullPointerException. We pass a second comparator that places null values first, or we filter them out.
List<Release> withPlanned = List.of(
new Release("app", "1.3", null),
new Release("app", "1.2", LocalDate.of(2026, 6, 3)));
Optional<Release> crash = withPlanned.stream().max(Comparator.comparing(Release::date)); // NullPointerException
String nullsFirst = withPlanned.stream().max(Comparator.comparing(Release::date, Comparator.nullsFirst(Comparator.naturalOrder()))).map(Release::version).orElse("none"); // "1.2"
String filtered = withPlanned.stream().filter(r -> r.date() != null).max(Comparator.comparing(Release::date)).map(Release::version).orElse("none"); // "1.2"
With nullsFirst(), a null date counts as the smallest value, so max() skips it unless every date is null. If a planned release should count as the newest, nullsLast() does the opposite. The null-safe sorting article explains both comparators in more detail.
5. Two Objects With the Same Date
When two objects share the latest date, the contract of max() does not say which one we get. Code that depends on one of them needs a tie-breaker with thenComparing(), and code that needs all of them finds the max date first and filters by it.
List<Release> releases = List.of(
new Release("app", "1.0", LocalDate.of(2026, 1, 15)),
new Release("app", "1.2", LocalDate.of(2026, 6, 3)),
new Release("api", "2.0", LocalDate.of(2026, 4, 10)),
new Release("app", "1.1", LocalDate.of(2026, 3, 20)),
new Release("api", "2.1", LocalDate.of(2026, 6, 3)));
Comparator<Release> byDateThenVersion = Comparator.comparing(Release::date).thenComparing(Release::version);
String winner = releases.stream().max(byDateThenVersion).map(Release::version).orElse("none"); // "2.1"
LocalDate maxDate = releases.stream().map(Release::date).max(Comparator.naturalOrder()).orElseThrow(); // 2026-06-03
List<String> sameDay = releases.stream().filter(r -> r.date().equals(maxDate)).map(Release::version).toList(); // [1.2, 2.1]
Whenever two objects can share a date, we add a tie-breaker, so the same data always shows the same result. Comparing version strings works here because the versions have the same format. Real version numbers such as 1.10 and 1.9 need a numeric comparison.
6. Latest Release per Product
A release notes page shows the latest release of each product. Grouping with Collectors.groupingBy() and Collectors.maxBy() returns one entry per product, and a TreeMap keeps the products in alphabetical order on the page.
List<Release> releases = List.of(
new Release("app", "1.0", LocalDate.of(2026, 1, 15)),
new Release("app", "1.2", LocalDate.of(2026, 6, 3)),
new Release("api", "2.0", LocalDate.of(2026, 4, 10)),
new Release("app", "1.1", LocalDate.of(2026, 3, 20)));
Map<String, Optional<Release>> latestPerProduct = releases.stream()
.collect(Collectors.groupingBy(Release::product, TreeMap::new, Collectors.maxBy(Comparator.comparing(Release::date))));
String appViaGrouping = latestPerProduct.get("app").map(Release::version).orElse("none"); // "1.2"
Map<String, Release> latestByProduct = releases.stream()
.collect(Collectors.toMap(Release::product, r -> r, BinaryOperator.maxBy(Comparator.comparing(Release::date)), TreeMap::new));
String appLatest = latestByProduct.get("app").version(); // "1.2"
String apiLatest = latestByProduct.get("api").version(); // "2.0"
Set<String> products = latestByProduct.keySet(); // [api, app]
The maxBy() collector wraps each value in an Optional, although a group is never empty. The toMap() version with BinaryOperator.maxBy() keeps the plain object, because the merge function compares two releases of the same product and keeps the later one. The stream to map guide covers toMap() merge functions in detail.
7. Other Date Types and Collections.max()
The same comparator works for every date and time class that implements Comparable. Only the meaning of “later” differs between them.
| Field type | Comparator | Note |
|---|---|---|
| LocalDate, LocalDateTime | comparing(X::date) | Compares the calendar values without a time zone |
| Instant | comparing(X::createdAt) | Compares points on the timeline |
| OffsetDateTime, ZonedDateTime | comparing(x -> x.time().toInstant()) | Compares the moment, not the local clock value |
| java.util.Date | comparing(X::date) | Legacy and mutable, convert with toInstant() where possible |
Without a stream, Collections.max(list, comparator) returns the object itself instead of an Optional. It throws a NoSuchElementException on an empty list, so it fits only where the list cannot be empty.
List<Release> releases = List.of(
new Release("app", "1.0", LocalDate.of(2026, 1, 15)),
new Release("app", "1.2", LocalDate.of(2026, 6, 3)),
new Release("api", "2.0", LocalDate.of(2026, 4, 10)),
new Release("app", "1.1", LocalDate.of(2026, 3, 20)));
Release viaCollections = Collections.max(releases, Comparator.comparing(Release::date)); // Release[product=app, version=1.2, date=2026-06-03]
Release fromEmpty = Collections.max(new ArrayList<Release>(), Comparator.comparing(Release::date)); // NoSuchElementException
For more ways to find the largest and smallest element, including numbers and strings, see the max and min with streams guide.
8. Max Date FAQs
Readers who find the latest date in a list often ask next about old Java versions, future dates, equal dates and the best method for the job.
8.1. Does this work in Java 8?
Yes. The methods Stream.max() and Comparator.comparing() exist since Java 8. Records came in Java 16, so on older versions we use a class with a getter, such as Comparator.comparing(Release::getDate), and collect(Collectors.toList()) instead of toList().
8.2. How do we find the latest date that is not in the future?
We filter out future dates before calling max(). For example, releases.stream().filter(r -> !r.date().isAfter(today)).max(byDate) returns the latest release on or before today, where today is LocalDate.now() or a date passed in for tests.
8.3. Which object does max() return when two dates are equal?
The result is not specified, so code must not rely on it. We add a tie-breaker with thenComparing() or collect all objects with the max date, as shown in section 5.
8.4. Is Collections.max() faster than stream().max()?
Both read every element once, so the difference is small. We choose by the empty case, because stream().max() returns an Optional while Collections.max() throws an exception.
9. Conclusion
The object with the max date comes from one line, max(Comparator.comparing(Release::date)), and min() with the same comparator gives the earliest one. Both return an Optional, so we handle an empty list with orElse() or orElseThrow() instead of get().
Real data adds three cases. Null dates need nullsFirst() or a filter, equal dates need a tie-breaker, and one result per product needs groupingBy() with maxBy().
10. References
Happy Learning !!