Skip to content

How to Use Optional in Java Without the Common Pitfalls

Replace nested null checks with Optional in a support-ticket service, then see which orElse, get and field habits bite. Java 21 code you can run today.

how-to-tutorials core-java java-tutorials
Gopi Gorantala
Reading Progress

On This Page

What you'll build: a phone lookup that can't throw

Here's the output of the finished program. Four tickets go in: one with a phone number, one whose agent has no number, one nobody owns, and one that doesn't exist. The old null-check code and the new Optional code run side by side.

ticket 1  legacy=555-0101  optional=Optional[555-0101] call=555-0101
ticket 2  legacy=null      optional=Optional.empty call=555-9999
ticket 3  legacy=null      optional=Optional.empty call=555-9999
ticket 99 legacy=null      optional=Optional.empty call=555-9999
on-call supplier ran 3 times for 4 lookups

That last line is the point of half this article. Four lookups, and the fallback ran three times, not four. Get the orElse / orElseGet choice wrong and it runs four times. For a phone number that's harmless. For a database call or a remote service it isn't.

This is how to use Optional in Java the way the JDK authors meant it: as a return type that says "there might be nothing here", and not as a wrapper you sprinkle everywhere. You'll build a small support-ticket service, replace a pyramid of if (x != null) with one chain, and then break things on purpose so you know the traps by sight.

TicketRepository.findById(id) --> Optional<Ticket>
        |  flatMap(Ticket::assignedAgent)  --> Optional<Agent>
        |  flatMap(Agent::phone)           --> Optional<String>
        v
   orElseGet(onCallPhone)  /  orElseThrow(...)  /  ifPresentOrElse(...)

This is for you if you know Java and have seen Optional in other people's code, but you've never been sure when get() is acceptable (almost never) or what orElse really does.

What you need

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 java -version
Build plain javac (Maven pom.xml below, not executed) javac -version
IntelliJ IDEA (optional) 2026.2 line Help → About
Eclipse IDE (optional) current release package "for Java Developers" Help → About Eclipse IDE

You need Java 17 or newer for the code as written, because it uses records (Java 16). Everything in Optional itself works back to Java 8, apart from the methods I flag in the version notes. If you don't have a JDK, install one with SDKMAN (sdk install java 21.0.x-tem, pick the exact id from sdk list java) or from your OS package manager.

No Docker or Podman this time. Nothing here needs a database or a container, so I left them out.

Spin up the project

I used the plain command line for this one, because there's nothing to download and the checks run in under a second. IDE steps follow.

mkdir tickets && cd tickets
mkdir -p src/main/java/com/example/tickets src/test/java/com/example/tickets

The tree you'll end up with:

tickets/
├── pom.xml                      (only if you use Maven)
└── src/
    ├── main/java/com/example/tickets/
    │   ├── Agent.java
    │   ├── Main.java
    │   ├── Ticket.java
    │   ├── TicketRepository.java
    │   └── TicketService.java
    └── test/java/com/example/tickets/
        └── TicketChecks.java

In IntelliJ IDEA

Choose File → New → Project, pick Java on the left, set the build system to IntelliJ (or Maven), and select JDK 21. Name it tickets and create it. Right-click src → New → Package and create com.example.tickets. I verified these menu paths from the JetBrains documentation. I didn't click through them.

In Eclipse

Choose File → New → Java Project, name it tickets, and set the execution environment to JavaSE-21. Then right-click src → New → Package. Docs only; not executed.

With Maven

Maven isn't needed to run any of this, and I couldn't download plugins from Maven Central in my environment, so I didn't execute this file. It has no dependencies, and I'd expect it to work as-is:

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>tickets</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
</project>

Wire it up: from null checks to one chain

Step 1: Model the data and say what's optional

Goal: make "this field might be missing" visible in the types.

An Agent always has a name. The phone number might not exist. A Ticket might have no assignee. I'm using records here (see how to use Java records) to keep the data classes short.

src/main/java/com/example/tickets/Agent.java

package com.example.tickets;

import java.util.Optional;

public record Agent(String name, String phoneNumber) {

    public Agent {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name is required");
        }
    }

    /** The phone number is optional, so the accessor says so in its type. */
    public Optional<String> phone() {
        return Optional.ofNullable(phoneNumber).map(String::strip).filter(s -> !s.isEmpty());
    }
}

src/main/java/com/example/tickets/Ticket.java

package com.example.tickets;

import java.util.Optional;

public record Ticket(long id, String subject, Agent assignee) {

    public Optional<Agent> assignedAgent() {
        return Optional.ofNullable(assignee);
    }
}

Why: the record components stay plain String and Agent (nullable), and the accessor-style methods phone() and assignedAgent() hand back Optional. I'd rather not make the component itself an Optional; more on that in the traps. The phone() method also strips whitespace and treats a blank string as missing, so every caller gets the same answer.

Checkpoint: compile from the project root.

mkdir -p out
javac --release 21 -d out $(find src -name '*.java')

Nothing printed means success. If you add the other files first and compile early, you'll get cannot find symbol errors until TicketService exists; that's expected.

Step 2: Return Optional from the lookup

Goal: stop returning null from findById.

src/main/java/com/example/tickets/TicketRepository.java

package com.example.tickets;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class TicketRepository {

    private final Map<Long, Ticket> store = new HashMap<>();

    public void save(Ticket ticket) {
        store.put(ticket.id(), ticket);
    }

    /** "Might not exist" is part of the signature, not a comment. */
    public Optional<Ticket> findById(long id) {
        return Optional.ofNullable(store.get(id));
    }
}

Why: Optional.ofNullable is the bridge between old null-returning APIs (here, Map.get) and your new signature. Use of only when you know the value isn't null; it throws on null, which we'll trigger later.

Step 3: Replace the null-check pyramid with a chain

Goal: write the lookup twice, once the old way and once with Optional, so you can compare them.

src/main/java/com/example/tickets/TicketService.java

package com.example.tickets;

import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;

public class TicketService {

    private final TicketRepository repo;
    private final Supplier<String> onCallPhone;

    public TicketService(TicketRepository repo, Supplier<String> onCallPhone) {
        this.repo = repo;
        this.onCallPhone = onCallPhone;
    }

    /** The null-check version most of us have written (and then regretted). */
    public String assigneePhoneLegacy(long id) {
        Ticket t = repo.findById(id).orElse(null);
        if (t != null) {
            Agent a = t.assignee();
            if (a != null) {
                String p = a.phoneNumber();
                if (p != null && !p.isBlank()) {
                    return p.strip();
                }
            }
        }
        return null;
    }

    /** Same lookup as one chain; the result still says "might be empty". */
    public Optional<String> assigneePhone(long id) {
        return repo.findById(id)
                .flatMap(Ticket::assignedAgent)
                .flatMap(Agent::phone);
    }

    /** Fall back to the on-call phone, computed only when it's needed. */
    public String phoneToCall(long id) {
        return assigneePhone(id).orElseGet(onCallPhone);
    }

    /** Callers who can't continue without a ticket get a clear exception. */
    public Ticket requireTicket(long id) {
        return repo.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("No ticket with id " + id));
    }

    /** Try the assignee's phone, then the team lead's, before giving up. */
    public Optional<String> phoneWithFallback(long id, Agent teamLead) {
        return assigneePhone(id).or(teamLead::phone);
    }

    /** Names of everyone who owns at least one of these tickets. */
    public List<String> assigneeNames(List<Long> ids) {
        return ids.stream()
                .map(repo::findById)
                .flatMap(Optional::stream)
                .flatMap(t -> t.assignedAgent().stream())
                .map(Agent::name)
                .distinct()
                .toList();
    }

    public String describe(long id) {
        return repo.findById(id)
                .filter(t -> t.assignee() != null)
                .map(t -> "#" + t.id() + " " + t.subject() + " -> " + t.assignee().name())
                .orElse("#" + id + " is missing or unassigned");
    }
}

Read assigneePhoneLegacy and assigneePhone next to each other. They return the same answers (the tests prove it later), but the second one has no variables, no nesting, and no way to forget a check. The tools in play:

  • map transforms the value if one is present. Use it when your function returns a plain value.
  • flatMap is map for functions that already return an Optional. Using map there gives you Optional<Optional<Agent>>, which compiles and is useless.
  • orElseGet(supplier) computes a fallback only when the Optional is empty.
  • orElseThrow(supplier) turns "empty" into an exception with a message you chose.
  • or(supplier) (Java 9+) tries another Optional before giving up.
  • Optional::stream (Java 9+) turns an Optional into a stream of zero or one elements, which is the tidy way to drop the empties from a list.
  • filter empties the Optional when the predicate fails.

Step 4: Add a main method

Goal: see all four cases at once.

src/main/java/com/example/tickets/Main.java

package com.example.tickets;

import java.util.List;

public class Main {
    public static void main(String[] args) {
        TicketRepository repo = new TicketRepository();
        repo.save(new Ticket(1, "Login loop", new Agent("Priya", " 555-0101 ")));
        repo.save(new Ticket(2, "Refund missing", new Agent("Marcus", null)));
        repo.save(new Ticket(3, "Typo on invoice", null));

        int[] onCallCalls = {0};
        TicketService svc = new TicketService(repo, () -> { onCallCalls[0]++; return "555-9999"; });
        Agent lead = new Agent("Dana", "555-0200");

        for (long id : new long[] {1, 2, 3, 99}) {
            System.out.printf("ticket %-2d legacy=%-9s optional=%-14s call=%s%n",
                    id, svc.assigneePhoneLegacy(id), svc.assigneePhone(id), svc.phoneToCall(id));
        }
        System.out.println("on-call supplier ran " + onCallCalls[0] + " times for 4 lookups");
        System.out.println(svc.phoneWithFallback(2, lead));
        System.out.println(svc.assigneeNames(List.of(1L, 2L, 3L, 99L, 1L)));
        System.out.println(svc.describe(1));
        System.out.println(svc.describe(3));
        svc.assigneePhone(2).ifPresentOrElse(
                p -> System.out.println("calling " + p),
                () -> System.out.println("no phone for ticket 2, paging on-call"));
        try {
            svc.requireTicket(99);
        } catch (IllegalArgumentException e) {
            System.out.println("caught: " + e.getMessage());
        }
    }
}

Checkpoint: compile and run.

javac --release 21 -d out $(find src -name '*.java')
java -cp out com.example.tickets.Main
ticket 1  legacy=555-0101  optional=Optional[555-0101] call=555-0101
ticket 2  legacy=null      optional=Optional.empty call=555-9999
ticket 3  legacy=null      optional=Optional.empty call=555-9999
ticket 99 legacy=null      optional=Optional.empty call=555-9999
on-call supplier ran 3 times for 4 lookups
Optional[555-0200]
[Priya, Marcus]
#1 Login loop -> Priya
#3 is missing or unassigned
no phone for ticket 2, paging on-call
caught: No ticket with id 99

If you see that, the hard part's done. Notice ticket 1: the stored number was " 555-0101 " with spaces, and both versions returned it stripped.

Check it works

Walk through the output once, because each line is a rule.

  • Line 5, "ran 3 times for 4 lookups": orElseGet only called the supplier for tickets 2, 3 and 99. Ticket 1 had a phone, so the fallback never ran.
  • Optional[555-0200]: ticket 2's agent has no number, so or(teamLead::phone) tried the team lead's instead.
  • [Priya, Marcus]: ids 3 and 99 produced nothing, and the repeated id 1 was removed by distinct(). That's flatMap(Optional::stream) doing the filtering.
  • #3 is missing or unassigned: filter emptied the Optional for the unassigned ticket, and orElse with a cheap string built the message.

Now prove that orElse(expression) really evaluates its argument eagerly. I'll do it in the tests.

Test it

I couldn't reach Maven Central from my environment to pull JUnit, so these checks are a plain main method that throws on the first failure. Port each check to an @Test with AssertJ's assertThat(...) if you use JUnit 5 (a dedicated article is planned: how to write unit tests with JUnit 5). The logic doesn't change.

src/test/java/com/example/tickets/TicketChecks.java

package com.example.tickets;

import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;

public class TicketChecks {
    static int passed = 0;

    static void check(String name, boolean ok) {
        if (!ok) throw new AssertionError("FAILED: " + name);
        passed++;
        System.out.println("ok  " + name);
    }

    public static void main(String[] args) {
        TicketRepository repo = new TicketRepository();
        repo.save(new Ticket(1, "Login loop", new Agent("Priya", " 555-0101 ")));
        repo.save(new Ticket(2, "Refund missing", new Agent("Marcus", null)));
        repo.save(new Ticket(3, "Typo on invoice", null));
        repo.save(new Ticket(4, "Blank phone", new Agent("Lee", "   ")));
        int[] calls = {0};
        TicketService svc = new TicketService(repo, () -> { calls[0]++; return "555-9999"; });
        Agent lead = new Agent("Dana", "555-0200");

        check("phone present and stripped", svc.assigneePhone(1).equals(Optional.of("555-0101")));
        check("null phone is empty", svc.assigneePhone(2).isEmpty());
        check("unassigned ticket is empty", svc.assigneePhone(3).isEmpty());
        check("missing ticket is empty", svc.assigneePhone(99).isEmpty());
        check("blank phone is empty", svc.assigneePhone(4).isEmpty());

        for (long id : new long[] {1, 2, 3, 4, 99}) {
            String legacy = svc.assigneePhoneLegacy(id);
            check("legacy matches optional for " + id, Optional.ofNullable(legacy).equals(svc.assigneePhone(id)));
        }

        calls[0] = 0;
        svc.phoneToCall(1);
        check("orElseGet does not call supplier when value present", calls[0] == 0);
        check("orElseGet falls back", svc.phoneToCall(3).equals("555-9999") && calls[0] == 1);

        calls[0] = 0;
        svc.assigneePhone(1).orElse(svc.phoneToCall(3));
        check("orElse(expr) evaluates expr even when value present", calls[0] == 1);

        check("or() uses lead", svc.phoneWithFallback(2, lead).equals(Optional.of("555-0200")));
        check("or() keeps assignee", svc.phoneWithFallback(1, lead).equals(Optional.of("555-0101")));
        check("distinct names via stream()", svc.assigneeNames(List.of(1L, 2L, 3L, 99L, 1L)).equals(List.of("Priya", "Marcus")));
        check("describe unassigned", svc.describe(3).equals("#3 is missing or unassigned"));

        try { svc.requireTicket(99); check("requireTicket throws", false); }
        catch (IllegalArgumentException e) { check("requireTicket message", e.getMessage().equals("No ticket with id 99")); }

        try { Optional.of(null); check("Optional.of(null) throws", false); }
        catch (NullPointerException e) { check("Optional.of(null) throws NPE", true); }

        try { Optional.empty().get(); check("get() throws", false); }
        catch (NoSuchElementException e) { check("get() on empty: " + e.getMessage(), e.getMessage().equals("No value present")); }

        check("Optional.equals compares contents", Optional.of("a").equals(Optional.of("a")) && Optional.empty().equals(Optional.empty()));
        System.out.println(passed + " checks passed");
    }
}
javac --release 21 -d out $(find src -name '*.java')
java -cp out com.example.tickets.TicketChecks
ok  phone present and stripped
ok  null phone is empty
ok  unassigned ticket is empty
ok  missing ticket is empty
ok  blank phone is empty
ok  legacy matches optional for 1
ok  legacy matches optional for 2
ok  legacy matches optional for 3
ok  legacy matches optional for 4
ok  legacy matches optional for 99
ok  orElseGet does not call supplier when value present
ok  orElseGet falls back
ok  orElse(expr) evaluates expr even when value present
ok  or() uses lead
ok  or() keeps assignee
ok  distinct names via stream()
ok  describe unassigned
ok  requireTicket message
ok  Optional.of(null) throws NPE
ok  get() on empty: No value present
ok  Optional.equals compares contents
21 checks passed

The legacy matches optional checks run all five tickets through both implementations. That's the safety net for a refactor: if you're replacing null-check code in a real codebase, write that comparison first.

How it works

Optional<T> is a final class holding either one non-null T or nothing. There's a single shared empty instance, which is why Optional.empty() == Optional.empty() printed true when I tried it. Don't lean on that: two Optional.of("x") calls give you different objects (== printed false), so compare with equals. Optional.equals compares the contents.

map and flatMap look inside the box. If the box is empty, your function never runs, and the empty result flows down the chain. That's why the chain in assigneePhone can't throw a NullPointerException: there's no point where a null is dereferenced. The orElse family is where the chain ends and you decide what "nothing" means: a default, a computed default, or an exception.

The design intent, from the Javadoc: Optional is for return types where "no result" is a normal outcome and null would likely cause errors. It isn't meant for fields, parameters, or collection elements. For the field-and-parameter reasoning in more depth, see how to create an immutable class in Java, where I model optional data without Optional fields. If you want the equals/hashCode side of it, how to implement equals() and hashCode() in Java covers it.

When it doesn't work

These are all things I forced while building this, with the exact output from my run.

Optional.of(null) throws. Use ofNullable when the value might be null.

java.lang.NullPointerException

get() on an empty Optional. The message is No value present, and it's an unchecked NoSuchElementException, so it shows up at runtime and not in review. Replace get() with orElseThrow(...) so the message says what actually went wrong (No ticket with id 99). Since Java 10 there's also a no-argument orElseThrow() that does the same as get() but says so in its name.

The old chain on a null. This is what you're moving away from. On Java 21 the helpful NPE message names the exact call:

NPE: Cannot invoke "Traps$Agent.phone()" because the return value of "Traps$Ticket.a()" is null

Optional<Optional<Agent>>. If you used map where the function returns an Optional, the output looks like Optional[Optional[a]]. Switch to flatMap.

Type errors from mixing the two worlds. These compile errors are common when you're half-migrated:

Bad.java:3: error: incompatible types: String cannot be converted to Optional<String>
  static Optional<String> f() { return "x"; }

Wrap the value with Optional.of(...) or ofNullable(...).

Optional as a field in a serialized class. Optional doesn't implement Serializable:

NotSerializable: java.util.Optional

That's by design. Keep the field nullable and expose an Optional getter, the way Ticket.assignedAgent() does. If you hit an InvalidClassException instead, that's a different problem: see java.io.InvalidClassException: local class incompatible.

Compiling for an old Java version. I tried --release 8 and --release 11. Both fail on the records, not on Optional:

Agent.java:5: error: records are not supported in -source 8

If you're stuck on Java 8, replace the records with plain classes. And note which Optional methods you can't use there: or, stream, ifPresentOrElse (Java 9), orElseThrow() with no argument (Java 10), isEmpty (Java 11).

orElse running your expensive code. Not an error, which is why it's the worst one. opt.orElse(loadFromDatabase()) calls loadFromDatabase() every time, even when opt has a value. My check orElse(expr) evaluates expr even when value present proves it. Use orElseGet(() -> loadFromDatabase()). For a constant, a literal, or a variable that's already computed, orElse is fine.

If your real problem is an NPE you can't explain, see java.lang.NullPointerException: cannot invoke because null.

Going further

Habits I'd keep:

  • Return Optional, don't accept it. A method parameter of type Optional<String> forces the caller to wrap something and still allows null to be passed. Use two overloads or a nullable parameter with a clear Javadoc.
  • Never return Optional<List<T>>. An empty list already means "nothing". I confirmed Optional.of(List.of()) is present and empty at once (true true), which makes callers check twice.
  • Don't call isPresent() then get(). That's a null check with extra steps. Use map, ifPresent, or ifPresentOrElse.
  • Skip it in hot loops and DTOs. Each Optional is an allocation, and JSON libraries handle a nullable field more predictably than an Optional field. For primitives there are OptionalInt, OptionalLong and OptionalDouble.
  • Don't wrap Optional.ofNullable(x).orElse(y) around a single null check. x != null ? x : y is clearer. Use Objects.requireNonNullElse(x, y) for the same thing without the allocation.

Version notes: Optional arrived in Java 8. or, stream, ifPresent/ifPresentOrElse (Java 9), no-arg orElseThrow() (Java 10), isEmpty (Java 11). Java 21 gives you everything above. I only ran this on 21, so check the Javadoc if you're on a newer JDK.

Next in this series: how to implement the strategy pattern in Java and how to implement the builder pattern in Java. Build time: I built this in one sitting, and the slowest part was working out a clean way to prove that orElse evaluates eagerly (a counter in the supplier did it).

Recap

  • Return Optional from methods where "no result" is normal; use ofNullable to wrap legacy null returns.
  • Chain with map for plain values and flatMap for Optional-returning functions.
  • End chains with orElseGet, orElseThrow(...) or ifPresentOrElse, never a bare get().
  • Use orElseGet whenever the fallback costs something.
  • Don't use Optional for fields, parameters, collections or serialization.
  • Before refactoring null-check code, write the old-versus-new comparison test.

Optional questions people ask

When should I use Optional in Java?
As a return type for a method that might legitimately find nothing, like a lookup by id. Avoid it for fields, method parameters and collection elements.

What's the difference between orElse and orElseGet?
orElse(x) evaluates x before the call, every time. orElseGet(supplier) runs the supplier only when the Optional is empty. In my run the supplier executed 3 times for 4 lookups, where orElse would have executed 4.

Should I ever call Optional.get()?
Rarely. It throws NoSuchElementException: No value present on empty. Prefer orElseThrow(() -> new SomeException("clear message")), or the no-argument orElseThrow() on Java 10+.

Can I use Optional in JPA entities or JSON classes?
Keep the field a plain nullable type and return Optional from the getter. Optional isn't Serializable, and persistence and JSON mapping handle plain nullable fields more predictably.

How do I turn a list of Optionals into a list of values?
list.stream().flatMap(Optional::stream).toList() on Java 9+, which is what assigneeNames does.

References

how-to-tutorialscore-javajava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments