Optional
A return type that says "maybe", the API that makes it readable, and the three misuses that make it worse than null.
Optional exists to make one thing explicit in a method signature: this may return nothing. It was designed as a return type, and the misuses that make people hate it are all cases of putting it somewhere else. Used for its purpose, it removes a whole class of NullPointerExceptions and makes call sites read well. This lesson is the API, what an Optional costs (usually nothing, occasionally a database query), and why the class carries a warning about its own identity.
The problem it solves
User findByEmail(String email); // returns null if absent — says nothing about thatEvery caller must remember to check for null, and the compiler will not remind them. The first caller who forgets gets an NPE somewhere downstream. With Optional:
Optional<User> findByEmail(String email);The signature says "maybe". The caller cannot use the result as a User without deciding what to do when it is absent.
Using the result
Optional<User> maybe = repo.findByEmail(email);
// Provide a fallback
User u = maybe.orElse(User.GUEST);
User u = maybe.orElseGet(() -> createGuest(email)); // lazy: only runs if empty
User u = maybe.orElseThrow(() -> new NotFoundException(email));
User u = maybe.orElseThrow(); // NoSuchElementException
// Transform without unwrapping
String display = maybe.map(User::displayName).orElse("anonymous");
Optional<Address> addr = maybe.flatMap(User::primaryAddress); // when the mapper returns Optional
// Act if present
maybe.ifPresent(u -> audit.log(u));
maybe.ifPresentOrElse(u -> audit.log(u), () -> audit.logAnonymous());
// Filter
maybe.filter(User::isActive).isPresent();
// Into a stream
users.stream().map(repo::findByEmail).flatMap(Optional::stream).toList();orElse(x) evaluates x always, even when the value is present. orElse(loadDefault()) calls loadDefault every time; orElseGet(this::loadDefault) calls it only when needed. This is the most common performance mistake with Optional.
Under the hood: a box, a singleton, and a class that disowns its identity
Optional<T> is a final class with one field, private final T value, and nothing else: a 16-byte object (12-byte header + one reference, padded). Optional.empty() returns one shared static instance, EMPTY, for every type; Optional.of(x) allocates a new box around x. map allocates another box for the result (or returns EMPTY), so a chain of three maps on a present value is three small allocations, which is where "Optional is slow" comes from and where it is wrong: in a hot path the JIT's escape analysis sees a box created and consumed within one compiled method and removes it, and in a cold path 48 bytes of garbage is nothing. What is not nothing is orElse(expensive()), which is not an allocation problem but an evaluation-order one: the argument is computed before orElse is called, like any argument.
The class is annotated @jdk.internal.ValueBased and its Javadoc says so: it is a value-based class, meaning the JDK reserves the right to make it a Valhalla value type whose instances have no identity. Today Optional.of(1) == Optional.of(1) is false (two boxes) and could become true tomorrow (one value); synchronized (optional) throws a warning now and will throw an exception then. Compare with equals, never ==, and never lock on one. The same warning applies to Integer, LocalDate and every record.
Optional is deliberately not Serializable, which is the technical reason it cannot be a field in an entity or a session attribute, and the API's authors have said the reason it is not: a field's absence is the field being null, and wrapping it buys nothing except a second way to be empty.
The three misuses
1. isPresent() then get().
if (maybe.isPresent()) { use(maybe.get()); } // null check with more charactersThis is the null check you were trying to replace. Use ifPresent, map, or orElseThrow. get() should be rare enough that seeing it is a code-review flag.
2. Optional as a field or parameter.
class Order { private Optional<Coupon> coupon; } // no
void apply(Optional<Coupon> coupon) { ... } // noOptional is not Serializable, adds an allocation per object, and — for a parameter — forces every caller to wrap: apply(Optional.ofNullable(c)). A nullable field with a getter that returns Optional is the accepted compromise; for parameters, overload the method or accept null with a documented meaning. The JDK's own designers said this plainly: it is for return types.
3. Optional for collections.
Optional<List<Order>> has two ways to say "none": empty optional and empty list. Return an empty list. The same applies to Optional<String> where an empty string means absent — pick one.
Walkthrough: the fallback that ran a query on every request
A profile endpoint:
public Profile profile(UserId id) {
return cache.find(id) // Optional<Profile>, hit 97% of the time
.orElse(repository.loadAndCache(id)); // the fallback
}- The cache hit rate was 97%, and yet the database showed one
loadAndCachequery per request. Latency at p50 was 12 ms instead of the 200 µs a cache hit should cost. orElseis an ordinary method; Java evaluates its argument before the call.repository.loadAndCache(id)ran on every request, hit or miss, and its result was discarded 97% of the time. The cache was correct and irrelevant.- The query also wrote to the cache each time, so the "cache" was being refreshed 30 times a second per user, and the metrics showed a hit rate nobody could explain.
.orElseGet(() -> repository.loadAndCache(id))was the fix: the supplier runs only on a miss. p50 dropped to 300 µs, database load by 97%.- The review rule that would have caught it: any
orElsewhose argument is a method call is suspect; a literal or a constant is fine.
orElse for values, orElseGet for anything that computes. The difference is not style; it is whether the computation runs.
of, ofNullable, empty
Optional.of(x) throws if x is null — use it when null would be a bug. Optional.ofNullable(x) maps null to empty — use it at the boundary with null-returning code (a legacy API, a map lookup). Optional.empty() is the shared empty instance. The primitive variants OptionalInt, OptionalLong and OptionalDouble avoid boxing and are what IntStream.max() returns; they have no map, which is a hint that they are for returning a number, not for chaining.
Equality and identity
Optional is a value-based class: equals compares contents, == is undefined behaviour to rely on, and future JVMs may not give Optional.of(x) a stable identity. Never synchronise on one.
Where it fits in a service
- Repository methods returning one row:
Optional<T> findById(ID id). Spring Data does this. - Service methods that look something up: return
Optional, or throw a domain exception — choose per method.getById(throws) besidefindById(optional) is a common and clear pair. - Controllers:
return service.find(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());— and note thatnotFound().build()is a cheap constant, soorElseis right there. - DTOs and entities: plain nullable fields with
@Nullableannotations, notOptional.
Chaining across boundaries
The API pays off most when several maybes line up. A user may have a primary address, an address may have a postcode, and a postcode may map to a delivery zone:
// Nested null checks
Zone zone = null;
User u = repo.find(id).orElse(null);
if (u != null) {
Address a = u.primaryAddress();
if (a != null && a.postcode() != null) zone = zones.lookup(a.postcode()).orElse(null);
}
// The same, flattened
Optional<Zone> zone = repo.find(id)
.flatMap(User::primaryAddress) // returns Optional<Address>
.map(Address::postcode) // may return null → map treats it as empty
.flatMap(zones::lookup); // returns Optional<Zone>map on a function that returns null yields empty, which is what makes the second chain safe at the postcode step, and flatMap is for functions that already return Optional. Use map when the step cannot fail, flatMap when it can, and read the chain top to bottom as "if all of these, then". The absence of an if is the readability win; the absence of a forgotten null check is the correctness one.
Two additions since Java 9 round it out: or(() -> other()) tries a second Optional when the first is empty, useful for "cache, then database, then default", and stream() lets a collection of maybes be filtered to the present ones in a pipeline. isEmpty() (Java 11) is the readable negation of isPresent() for the rare guard that genuinely needs one.
Try it yourself
How many calls?
Optional<String> present = Optional.of("x");
Optional<String> absent = Optional.empty();
String a = present.orElse(load("A"));
String b = present.orElseGet(() -> load("B"));
String c = absent.orElse(load("C"));
String d = absent.orElseGet(() -> load("D"));load prints its argument. What prints?
Answer
A, C, D. orElse evaluates its argument eagerly, present or not, so A and C print. orElseGet calls the supplier only when empty, so B never prints and D does. Three calls where one was needed.
Rewrite without get
Optional<User> u = repo.find(id);
if (u.isPresent() && u.get().isActive()) return u.get().name();
return "unknown";Answer
return repo.find(id).filter(User::isActive).map(User::name).orElse("unknown");. One expression, no get, no repeated unwrapping, and each step says what it does. filter returns empty when the predicate fails, so an inactive user falls through to the default exactly as before.
Why does the entity fail to save?
@Entity class Order { private Optional<Coupon> coupon; } compiles, and Hibernate throws at startup. Then a colleague changes it to Coupon coupon with Optional<Coupon> getCoupon() { return Optional.ofNullable(coupon); }. Why does the second work and the first not?
Answer
Hibernate maps fields to columns and has no mapping for Optional, which is also not Serializable; the field's type must be the column's. The getter version keeps the field a plain nullable Coupon (the column is nullable) and exposes absence to callers as Optional, which is the sanctioned compromise: Optional on the way out, null at rest. Jackson handles it the same way with the Jdk8Module.
Misconceptions
- "
Optionalis a null-safe wrapper for anything." It is a return type that makes absence explicit. As a field or parameter it adds a box, a second null state, and a serialisation failure. - "
orElseandorElseGetare interchangeable."orElseevaluates its argument always;orElseGetonly on empty. With a method call as the argument, that is a query per call. - "
Optionalallocations matter." Sixteen bytes, often removed by escape analysis. The cost that matters is what you put inorElse. - "
Optional.of(x) == Optional.of(x)is false, so==is a safe 'different' test." It is undefined. The class is value-based and may lose identity in a future JVM. - "Returning
Optional<List<T>>is more precise." It is two empties. An empty list already says "none".
Going deeper
java.util.OptionalJavadoc, the class comment: "intended to provide a limited mechanism for library method return types" and the value-based note.Optionalsource: sixteen lines of state, andEMPTY.- Stuart Marks, "Optional: The Mother of All Bikesheds" (Devoxx), from one of its authors.
- JEP 390 (warnings for value-based classes) and JEP 401 (value classes, Valhalla) for where identity is going.
- Effective Java, item 55.