What Is std::optional?

std::optional<T> is a class template that holds either a value of type T or nothing at all. It was added in C++17 and lives in <optional>.

It exists to answer a question every codebase runs into: how does a function report that it has no answer? A lookup asked for an identifier that does not exist has nothing to hand back, and neither does a reading taken from a sensor that is offline. The function itself has no business deciding what to do about that, so the outcome has to travel back to the caller.

The Options Before C++17

Every pre-C++17 approach has a real drawback:

Approach Problem
Return bool for success, write the result through an out parameter The result and the success flag are separate, and the caller must supply a variable up front
Return a sentinel value such as -1.0 The caller has to know the magic value, and it differs from function to function
Throw an exception Carries complications and costs that are out of proportion to an ordinary "no result"
Return a struct pairing a bool with the value The best of the four, but before C++17 you had to build it yourself, differently every time

The sentinel approach is the one that looks reasonable until it does not. Consider a lookup that returns how many items a warehouse bay is holding:

int bayContents(int bayNumber)
{
    if (bayNumber < 1 || bayNumber > 40)
        return -1;

    return 17;
}

This works only because a stock count can never legitimately be negative, which is a fact about this particular quantity rather than about the technique. Change the function to report a temperature from a sensor and it collapses immediately: readings can be negative, zero, or anything else, so no int is left over to mean "failed".

You can reach for something obscure, such as the most negative int:

#include <limits>

int sensorReading(int sensorId)
{
    if (sensorId < 1 || sensorId > 8)
        return std::numeric_limits<int>::min();

    return 21;
}

std::numeric_limits<T>::min() gives the most negative value of type T, the counterpart to max(). But now every call site has to compare against that expression to find out whether the call worked, which is verbose and easy to forget. Worse, it is a semipredicate problem: if the real answer ever happens to be that same value, success and failure become indistinguishable.

Returning an Optional

std::optional makes "no value" part of the return type, so nothing has to be smuggled through the value space:

#include <iostream>
#include <optional>

std::optional<int> sensorReading(int sensorId)
{
    if (sensorId < 1 || sensorId > 8)
        return {};

    return 18 + sensorId;
}

int main()
{
    std::optional<int> roof{ sensorReading(3) };

    if (roof)
        std::cout << "Roof sensor: " << *roof << " degrees\n";
    else
        std::cout << "Roof sensor: unavailable\n";

    std::optional<int> basement{ sensorReading(19) };

    if (basement)
        std::cout << "Basement sensor: " << *basement << " degrees\n";
    else
        std::cout << "Basement sensor: unavailable\n";

    return 0;
}

Output:

Roof sensor: 21 degrees
Basement sensor: unavailable

Returning {} produces an optional holding nothing, and returning an int produces one holding that value. The caller checks before reading. No magic constant appears anywhere, and the signature now states that failure is possible.

Using One

Creating, testing, and reading an optional:

Task Ways to do it
Create with a value std::optional<int> opt{ 10 };
Create empty std::optional<int> opt{}; or std::optional<int> opt{ std::nullopt };
Report no value from a function return {}; or return std::nullopt;
Test for a value if (opt.has_value()) or if (opt)
Read the value *opt, opt.value(), or opt.value_or(fallback)

The three ways of reading differ in what happens when the optional is empty, and the difference matters:

  • *opt is undefined behavior. Only use it once you have checked.
  • opt.value() throws std::bad_optional_access.
  • opt.value_or(fallback) returns the fallback, and never fails.
#include <iostream>
#include <optional>

int main()
{
    std::optional<int> reading{ 21 };
    std::optional<int> missing{};

    std::cout << "Dereferenced: " << *reading << '\n';
    std::cout << "value(): " << reading.value() << '\n';
    std::cout << "value_or on a reading: " << reading.value_or(-273) << '\n';
    std::cout << "value_or on a missing reading: " << missing.value_or(-273) << '\n';
    std::cout << "has_value(): " << missing.has_value() << '\n';

    return 0;
}

Output:

Dereferenced: 21
value(): 21
value_or on a reading: 21
value_or on a missing reading: -273
has_value(): 0

Why It Is Not Just a Pointer

The syntax looks like a pointer. Both can be empty, both convert to bool, and both are read by dereferencing. The semantics are entirely different, and that difference is the reason std::optional exists.

Pointer std::optional
Semantics Reference: refers to an object stored elsewhere Value: contains the object itself
Copying it copies The address The contained value
Safe to return from a function Not if it points at a local Yes, the value travels with it

A pointer to a local variable dangles the moment the function returns, because only the address came back and the object it named is gone. An optional carries the object home with it, which is what makes it usable as a return type at all.

Costs and Benefits

Returning std::optional documents in the signature that a call may not produce a value, removes the need to remember any sentinel, and reads naturally at the call site.

Two limitations are worth knowing. You must confirm a value is present before dereferencing, or you get undefined behavior. And an optional records only that there was no value, never why.

Best Practice
For a function that may fail, return a std::optional rather than a sentinel value, unless the caller needs to know why it failed.
Related Content
std::expected, added in C++23, covers the case an optional cannot: returning either a value or an error describing the failure.

Optional Function Parameters

std::optional can also express a parameter the caller may omit. The older way is a pointer defaulting to nullptr, which works but forces any real argument to be an lvalue, since you need something to take the address of. An optional parameter accepts an rvalue too, because it copies.

That copy is the catch. std::optional<T> holds a T by value, and as of C++23 it cannot hold a reference, so an expensive-to-copy type such as std::string gets copied on every call. std::reference_wrapper can work around it, at the cost of introducing two extra types into a signature that then reads worse than the pointer version it replaced.

Usually there is a better answer than either, which is to write two overloads:

#include <iostream>
#include <string>

struct Sensor
{
    std::string location{};
    int channel{};
};

void describeChannel()
{
    std::cout << "Channel: unassigned\n";
}

void describeChannel(const Sensor& sensor)
{
    std::cout << "Channel: " << sensor.channel << " at " << sensor.location << '\n';
}

int main()
{
    describeChannel();

    Sensor roof{ "roof", 3 };
    describeChannel(roof);

    describeChannel({ "cellar", 7 });

    return 0;
}

Output:

Channel: unassigned
Channel: 3 at roof
Channel: 7 at cellar

Each overload says exactly what it takes, nothing is copied unnecessarily, and rvalues work.

Best Practice
Prefer std::optional for optional return types. For optional parameters, prefer function overloading where you can. Failing that, use std::optional<T> when T would normally be passed by value, and const T* when T is expensive to copy.

Summary

What it is: std::optional<T> from <optional>, added in C++17, holds either a T or nothing.

Why it exists: sentinel return values require the caller to know a magic constant, and break entirely when every possible value is a legitimate result, which is the semipredicate problem.

Creating and reporting emptiness: {} or std::nullopt, both as an initializer and as a return.

Checking: has_value() or the implicit conversion to bool.

Reading: *opt is undefined behavior when empty, value() throws std::bad_optional_access, and value_or(fallback) substitutes the fallback.

Value semantics: unlike a pointer, an optional contains its value rather than referring to one, so returning it from a function is safe.

Limitations: it cannot say why something failed, which is what std::expected in C++23 is for, and it cannot hold a reference.

Parameters: prefer overloads, then std::optional<T> for cheap-to-copy types, then const T* for expensive ones.