Java Optional — What It's For, What It's Not For, and How to Use It Well

by Eric Hanson, Backend Developer at Clean Systems Consulting

What Optional is actually for

Optional<T> was introduced in Java 8 as a return type for methods that might not return a value. Stuart Marks, one of the Java library architects, stated the design intent clearly: Optional is intended to provide a limited mechanism for library method return types where there is a clear need to represent "no result," and where using null is likely to cause errors.

The key phrase is "return type." Optional is for method return types at API boundaries. It is a signal to the caller: this method might not produce a result, and you must handle that case.

// Appropriate — repository method that may not find a record
public Optional<User> findByEmail(String email) {
    return Optional.ofNullable(jdbcTemplate.queryForObject(sql, email));
}

// Caller is forced to handle absence
Optional<User> user = userRepository.findByEmail(email);
user.ifPresent(u -> sendWelcomeEmail(u));
// or
User user = userRepository.findByEmail(email)
    .orElseThrow(() -> new UserNotFoundException(email));

The contract is visible in the method signature. A caller who sees Optional<User> knows to handle the absent case. A caller who sees User can assume a result (or is expected to handle null, but nothing in the signature communicates that expectation).

The cases where Optional should not be used

Fields. Optional is not Serializable and was not designed to be stored. An Optional field is an antipattern:

// Wrong — Optional field
public class User {
    private Optional<String> phoneNumber; // don't do this
}

// Correct — nullable field, Optional in getter
public class User {
    private String phoneNumber; // may be null

    public Optional<String> getPhoneNumber() {
        return Optional.ofNullable(phoneNumber);
    }
}

The field stores a primitive nullable value. The getter wraps it in Optional for the caller's benefit. Serialization frameworks, JPA, and reflection-based tools work with the field; callers work with the Optional getter.

Method parameters. Optional parameters force callers to wrap values unnecessarily:

// Wrong — callers must wrap
public void sendEmail(String to, Optional<String> subject) { ... }
// Caller: sendEmail("user@example.com", Optional.of("Hello"));

// Correct — overload or nullable
public void sendEmail(String to) { sendEmail(to, null); }
public void sendEmail(String to, String subject) { ... }
// Caller: sendEmail("user@example.com", "Hello");

If a parameter is optional, use an overloaded method or accept a nullable value with a null check internally. Requiring callers to wrap in Optional adds ceremony with no benefit — the caller could just as easily pass null.

Collections. Return an empty collection, not Optional<List<T>>. An empty list and an absent list are semantically the same for most operations, and empty collections require no special handling:

// Wrong — unnecessary Optional wrapping
public Optional<List<Order>> findOrdersByUser(long userId) {
    List<Order> orders = query(userId);
    return orders.isEmpty() ? Optional.empty() : Optional.of(orders);
}

// Correct — empty collection communicates absence
public List<Order> findOrdersByUser(long userId) {
    return query(userId); // returns empty list when no orders exist
}

Optional<List<T>> has a weird edge case: what does Optional.empty() mean vs Optional.of(Collections.emptyList())? An absent list and an empty list are different things only if you need to distinguish "we looked and found nothing" from "we didn't look." In most cases, you don't.

Performance-sensitive hot paths. Optional allocates an object. In a method called millions of times per second, that's millions of short-lived allocations — GC pressure. For hot paths, returning a sentinel value, throwing an exception for truly exceptional absence, or using a primitive return with a separate contains check is more appropriate.

The methods worth using

map and flatMap — transform the value if present:

// map: Optional<T> -> Optional<U>
Optional<String> email = findUser(userId)
    .map(User::getEmail);

// flatMap: Optional<T> -> Optional<U> when the function returns Optional<U>
Optional<Address> address = findUser(userId)
    .flatMap(User::getPrimaryAddress); // getPrimaryAddress returns Optional<Address>

flatMap avoids Optional<Optional<T>> when the mapping function itself returns an Optional. Use map when the function returns T; use flatMap when it returns Optional<T>.

orElse vs orElseGet — provide a fallback value:

// orElse: always evaluates the fallback expression
User user = findUser(id).orElse(guestUser()); // guestUser() called even if user is present

// orElseGet: evaluates the fallback lazily
User user = findUser(id).orElseGet(() -> guestUser()); // guestUser() called only if absent

orElse is fine when the fallback is a constant or a reference. orElseGet is necessary when the fallback requires computation or has side effects — calling a method to create a default value, querying a fallback source. Using orElse(expensiveComputation()) computes the fallback even when a value is present — a performance issue in hot paths.

orElseThrow — throw if absent:

User user = findUser(id)
    .orElseThrow(() -> new UserNotFoundException(id));

The lambda is only evaluated when the Optional is empty — the exception is created only when thrown. orElseThrow() with no argument (Java 10+) throws NoSuchElementException — useful for cases where absence is genuinely unexpected and a more descriptive exception isn't warranted.

or — fallback to another Optional (Java 9+):

Optional<User> user = findInPrimaryDatabase(id)
    .or(() -> findInSecondaryDatabase(id))
    .or(() -> findInCache(id));

or is the Optional equivalent of flatMap for fallback chains. Each fallback is only evaluated if the preceding Optional is empty.

ifPresent and ifPresentOrElse — side effects:

findUser(id).ifPresent(user -> auditLog.record(user.getId()));

findUser(id).ifPresentOrElse(
    user -> auditLog.record(user.getId()),
    () -> auditLog.recordMissingUser(id)
);

ifPresentOrElse (Java 9+) handles both branches — present and absent — in one call, avoiding the if (opt.isPresent()) { ... } else { ... } pattern that defeats the purpose of Optional.

The antipatterns that defeat Optional's purpose

isPresent() followed by get():

// Antipattern — equivalent to null check followed by use
if (user.isPresent()) {
    sendEmail(user.get().getEmail()); // get() throws if empty — why bother with Optional?
}

// Correct
user.ifPresent(u -> sendEmail(u.getEmail()));

isPresent() + get() is a null check with extra steps. It provides no safety improvement over returning a nullable value — get() on an empty Optional throws NoSuchElementException. Use the functional methods instead.

Optional.get() without a preceding check:

String email = findUser(id).get().getEmail(); // throws if empty

This is strictly worse than a nullable return — it allocates an Optional and then calls get() that throws an unchecked exception. If you're certain the value is present, restructure so it can't be absent, or use orElseThrow with a descriptive exception.

Wrapping nullable results in Optional.of() instead of Optional.ofNullable():

// NullPointerException if result is null
return Optional.of(jdbcTemplate.queryForObject(sql, args));

// Correct
return Optional.ofNullable(jdbcTemplate.queryForObject(sql, args));

Optional.of(null) throws NullPointerException — it's for wrapping values you know are non-null. Optional.ofNullable(value) handles null by returning Optional.empty(). When wrapping a value that might be null, always use ofNullable.

Optional in streams

Stream<Optional<T>> is a common intermediate result when mapping to a method that returns Optional. Java 9's Optional.stream() combined with flatMap is the idiomatic way to filter and unwrap:

List<String> emails = userIds.stream()
    .map(id -> findUser(id))              // Stream<Optional<User>>
    .flatMap(Optional::stream)            // Stream<User> — only present values
    .map(User::getEmail)                  // Stream<String>
    .collect(Collectors.toList());

Optional::stream returns a stream of one element if present, empty stream if absent. flatMap with Optional::stream filters out empties and unwraps in one operation. This is cleaner than filter(Optional::isPresent).map(Optional::get), which uses get() after checking.

The practical summary

Return Optional<T> from public API methods that might not produce a result. Use the functional methods — map, flatMap, orElseGet, or, ifPresentOrElse — instead of isPresent() + get(). Don't use Optional for fields, parameters, or collections. Prefer Optional.ofNullable() over Optional.of() when the value might be null.

Optional is a communication tool as much as a coding tool. A method that returns Optional<User> communicates to every caller — today and in the future — that absence is a legitimate outcome that must be handled. That communication is the primary value, not the null safety mechanism.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Hollywood, Gaming, and Startups All Want the Same LA Backend Developers

Los Angeles has three of the most technically demanding industries in the world competing for backend talent. Startups are usually last in line.

Read more

Why AI Doesn’t Replace the Judgment of a Tech Lead

AI can generate code, suggest patterns, and even review pull requests. But it cannot replace the nuanced judgment a human tech lead brings to a team.

Read more

Risk Management in Software Development

Software projects rarely fail because of one big mistake. They fail because of many small risks left unchecked.

Read more

The Risks of Losing Source Code Before Deployment

Imagine finishing a feature, ready to deploy, and then—poof—it-is gone. No backup, no commits, just empty folders.

Read more