Standard Library[lib]

std::optional in C++: how to use it and when not to

Section
Standard Library [lib]
Standard
C++17
Compilers
MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2, Clang 23.1 (libc++)
Published
Length
11 min read
In this answer8 clauses
  1. 1Create a std::optional, test it, read it
  2. 2What happens when you read an empty std::optional
  3. 3std::nullopt, reset and emplace: empty it and refill it
  4. 4Does std::optional allocate? Size and storage
  5. 5Three traps: optional<bool>, pointers and comparisons
  6. 6std::optional and references: optional<T&> arrives in C++26
  7. 7C++23: and_then, transform and or_else
  8. 8When to use std::expected, a pointer or something else

Abstract

std::optional<T> (C++17) holds a T or nothing, inside the object, with no allocation. Test it with if (o) and read it with *o, which is undefined behavior on an empty optional; where the test is out of sight, use value(), which throws std::bad_optional_access, or value_or(x). Return std::expected (C++23) when the caller needs the reason, and a pointer for an object you do not own until C++26 adds std::optional<T&>.

Two std::optional objects drawn as rows of cells. Above, a hatched value cell with a flag cell holding a blue mark; below, a dashed grey empty value cell beside an empty flag cell.
Figure 1 — A std::optional is a value and a flag that says whether the value is there.

std::optional<T> (C++17, header <optional>) holds a T or nothing, inside the object, with no heap allocation: return one when “no result” is a normal outcome that needs no explanation, such as a lookup that misses. Test it with if (o), then read it with *o or o->member. On an empty optional those are undefined behavior (a hardened precondition from C++26), so where the test is out of sight, use o.value(), which throws std::bad_optional_access, or o.value_or(fallback). When the caller needs to know why there is no value, return std::expected (C++23) instead.

This answer is written for C++17, which added std::optional. Later standards changed it:

  • C++20, by a 2021 defect report (P2231R1): reset, emplace and assignment are constexpr; all four compilers below accept them in a constant expression under C++20 and reject them under C++17.
  • C++23: and_then, transform and or_else.
  • C++26: std::optional<T&>, begin()/end(), and hardened preconditions on * and ->.

The samples were built with four compilers on three standard libraries: MSVC 19.44 and Clang 19.1 on Microsoft’s STL (Windows x64), GCC 16.2 with libstdc++ and Clang 23.1 with libc++ (Compiler Explorer, Linux x86-64). Each output shown is what all four printed, unless the text or a table says otherwise. Support per compiler is in the C++17 table of Since When; every member is on cppreference’s std::optional page.

What changed: a result that may be missing is returned as a null pointer, a magic value like -1, or a bool and an out-parameter std::optional<T>.

since C++17

Create a std::optional, test it, read it

The running example reads a port number from settings: the key may be missing, and the text may not be a number (converting a string to int covers that half). Each function returns std::nullopt for no result and the value itself otherwise:

[Example 1:
using Settings = std::map<std::string, std::string, std::less<>>;

std::optional<std::string> find_setting(const Settings& s,
                                        std::string_view key) {
    auto it = s.find(key);
    if (it == s.end()) return std::nullopt;  // no such key
    return it->second;                       // a std::string
}

std::optional<int> parse_port(std::string_view text) {
    int port = 0;
    const char* last = text.data() + text.size();
    auto [end, error] = std::from_chars(text.data(), last, port);
    if (error != std::errc{} || end != last) return std::nullopt;
    if (port < 1 || port > 65535) return std::nullopt;
    return port;
}
// ...
const Settings settings{{"port", "8443"}, {"host", "db.local"}};

std::optional<std::string> text = find_setting(settings, "port");
if (text) {                               // is there a value?
    std::cout << "port text: " << *text   // read it
              << " (" << text->size() << " characters)\n";
}

std::optional<int> port = parse_port(text.value_or(""));
std::cout << "port: " << port.value_or(8080) << '\n';

std::optional<std::string> admin =
    find_setting(settings, "admin_port");  // missing key
std::cout << "admin_port: " << admin.value_or("8080") << '\n';

if (!parse_port("eighty").has_value())
    std::cout << "eighty: not a port\n";
if (!parse_port("70000"))
    std::cout << "70000: not a port\n";

— end example]

[Example 2:
port text: 8443 (4 characters)
port: 8443
admin_port: 8080
eighty: not a port
70000: not a port

— end example]

What each way of reading does:

You write It has a value It is empty
if (o), o.has_value() true false
*o, o->m the value, by reference undefined behavior; C++26: hardened precondition
o.value() the value, by reference throws std::bad_optional_access
o.value_or(x) a copy of the value x, converted to T
  • The test is an explicit conversion to bool. It works in if, while, !, && and static_cast<bool>(o), but bool found = port; does not compile on any of the four.
  • return {}; returns an empty optional, the same as return std::nullopt;.

What happens when you read an empty std::optional

The standard does not require *o and o->m to check. It calls reading an empty optional through them undefined behavior until C++26, which makes has_value() a hardened precondition of both ([optional.observe], from P3471R4). A hardened implementation treats an empty read as a contract violation, which in the current draft ends the program; a non-hardened one leaves it undefined.

Every library here already has a switch that checks it, under C++17 too. What *port on an empty std::optional<int> did:

Library Without a flag Switch With the switch
Microsoft’s STL not checked (release build) _MSVC_STL_HARDENING=1 nothing printed; exit code 0xC0000409
libstdc++ checked at -O0, not at -Og and up _GLIBCXX_ASSERTIONS Assertion 'this->_M_is_engaged()' failed. on stderr; SIGABRT
libc++ not checked _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST nothing printed; SIGILL

The surprise is libstdc++. Since GCC 15, libstdc++ defines _GLIBCXX_ASSERTIONS by itself when nothing is optimized (GCC 15 release notes), so a -O0 build checks *o with no flag and a build at -Og or higher does not. -D_GLIBCXX_NO_ASSERTIONS turns that default off. The other cells are observations on the releases named.

value() checks and throws std::bad_optional_access, whose what() reads Bad optional access on Microsoft’s STL, bad optional access on libstdc++ and bad_optional_access on libc++: catch the type, never compare the text.

value_or(x) checks too, but x is an argument, so it is evaluated whether or not it is needed, and the result is a T by value:

[Example 3:
int calls = 0;

int default_port() {
    ++calls;
    return 8080;
}
// ...
std::optional<int> port = parse_port("8443");   // has a value

int a = port.value_or(default_port());   // calls it anyway
std::cout << a << ", default_port() calls: " << calls << '\n';

calls = 0;
int b = port ? *port : default_port();   // calls it only if empty
std::cout << b << ", default_port() calls: " << calls << '\n';

— end example]

[Example 4:
8443, default_port() calls: 1
8443, default_port() calls: 0

— end example]

Optimized builds printed the same. For a fallback that is expensive to compute, write o ? *o : compute(), or or_else in C++23.

clang-tidy’s bugprone-unchecked-optional-access check looks for a *o, o-> or value() that no test guards. On Microsoft’s library (the other two were not tried), clang-tidy 19.1.5 flagged an unguarded text->size() and port.value() (“warning: unchecked access to optional value”) and did not flag an unguarded return *port;.

That library declares operator* in a base class and brings it into optional with a using-declaration, which clang-tidy 19.1.5 (the version bundled with the Visual Studio used here) does not follow. So a clean run of that version there does not prove every *o is guarded: plant one unguarded *o and see what yours reports. clang-tidy and the other analyzers are listed on Toolchain, under analyzers.

std::nullopt, reset and emplace: empty it and refill it

o = std::nullopt, o.reset() and o = {} each destroy the value and leave the optional empty. std::nullopt is a constant of type std::nullopt_t that exists so “empty” can be spelled. A Conn type that prints each construction, move and destruction shows what each statement does:

Statement Conn printed
o = Conn("db"); (o empty) construct db, move db, destroy (moved-from)
o.reset(); destroy db
o.emplace("db"); construct db
o.emplace("cache"); destroy db, construct cache
o = Conn("db"); (o has a value) construct db, move-assign db, destroy (moved-from)
o = std::nullopt; destroy db
o = {}; destroy db
  • Assigning a temporary T (o = Conn("db");) moves it in: by move construction into an empty optional, by move assignment into the value already there.
  • emplace builds the value where it lives, with no temporary, and returns a reference to it (Conn& c = o.emplace("cache");).
  • To build it in place from the start, write std::optional<Conn> o{std::in_place, "db"}; or auto o = std::make_optional<Conn>("db");. Each printed one construct and no move.
  • o = {} empties an optional<int> too; it does not store 0.

Does std::optional allocate? Size and storage

No. The standard says the contained value is “nested within” the optional object ([optional.optional.general]), so there is no pointer and no allocation; the price is a flag and padding. The standard fixes no size. On x64, the three libraries agree on every row but std::string:

T sizeof(T) sizeof(std::optional<T>)
bool, char 1 2
int 4 8
double, int* 8 16
std::string, Microsoft’s STL and libstdc++ 32 40
std::string, libc++ 24 32
a 1 KB byte array (alignment 1) 1024 1025

The flag is padded to T’s alignment: one byte more for 1-byte types, four for int, eight for 8-byte types. On all three, a global operator new that counts calls saw no allocation for an optional<int> or an optional of the 1 KB array, and one for std::make_unique<int>:

[Example 5:
allocations: optional 0, unique_ptr 1

— end example]

Top: a std::optional of int as one 8-byte block of three cells, an int of 4 bytes, a flag of 1 byte and 3 bytes of grey padding. Bottom: a std::unique_ptr of int, an 8-byte pointer with an arrow to a separate int on the heap.
Figure 2 — The value of a std::optional lives inside it: no pointer, no allocation, one flag and its padding.

An optional copies, moves and destroys its value by itself, so a class with optional members still follows the rule of zero. It is also trivially copyable and trivially destructible when its T is: optional<int> is both, optional<std::string> neither.

Three traps: optional<bool>, pointers and comparisons

[Example 6:
std::optional<bool> verbose = false;   // has a value: false
if (verbose) std::cout << "if (verbose): true\n";
std::cout << "verbose == true: " << (verbose == true) << '\n';
std::cout << "verbose.value_or(false): "
          << verbose.value_or(false) << '\n';

std::optional<const Settings*> overrides = nullptr;
std::cout << "has_value(): " << overrides.has_value()
          << ", holds nullptr: " << (*overrides == nullptr) << '\n';

std::optional<int> port = 8080;
std::optional<int> none;
std::cout << "port == 8080: " << (port == 8080) << '\n';
std::cout << "none == std::nullopt: " << (none == std::nullopt)
          << '\n';
std::cout << "none < port: " << (none < port) << '\n';
std::cout << "none < -1: " << (none < -1) << '\n';
std::cout << "none == std::optional<int>{}: "
          << (none == std::optional<int>{}) << '\n';

— end example]

[Example 7:
if (verbose): true
verbose == true: false
verbose.value_or(false): false
has_value(): true, holds nullptr: true
port == 8080: true
none == std::nullopt: true
none < port: true
none < -1: true
none == std::optional<int>{}: true

— end example]

  • if (verbose) asks whether there is a value, not what it is. Compare (verbose == true) or read it with value_or(false).
  • An optional of a pointer has two ways to say nothing: empty, and a null pointer inside, which has_value() counts as a value.
  • Comparisons are defined, and an optional compares directly with a T (port == 8080). An empty one equals std::nullopt and any other empty one, and is less than every value, even -1. So std::sort on {8443, std::nullopt, 80, std::nullopt, 8080} put the two empty ones first, then 80, 8080 and 8443. Ordering in general is in how to write comparators for std::sort and std::map.

std::optional and references: optional<T&> arrives in C++26

Until C++26, an optional cannot hold a reference: std::optional<T&> is ill-formed, and all four compilers reject this under C++17 and C++23:

[Example 8:
std::optional<std::string&> find_setting(Settings& s,
                                         std::string_view key) {
    auto it = s.find(key);
    if (it == s.end()) return std::nullopt;
    return it->second;  // error before C++26
}

— end example]

Each says so in its own words; libc++’s is the plainest: instantiation of optional with a reference type is ill-formed.

Before C++26, return a pointer, or an optional of std::reference_wrapper:

[Example 9:
std::string* find_setting_ptr(Settings& s, std::string_view key) {
    auto it = s.find(key);
    return it == s.end() ? nullptr : &it->second;
}

std::optional<std::reference_wrapper<std::string>>
find_setting_ref(Settings& s, std::string_view key) {
    auto it = s.find(key);
    if (it == s.end()) return std::nullopt;
    return std::ref(it->second);
}
// ...
if (std::string* port = find_setting_ptr(settings, "port"))
    *port = "9443";                    // changes the map's value
if (auto host = find_setting_ref(settings, "host"))
    host->get() = "db.internal";       // so does this

— end example]

Both changed the map; the pointer is shorter.

C++26 adds std::optional<T&> (P2988R12). Under -std=c++26, GCC 16.2 and Clang 23.1 with libc++ compile the same find_setting, and assigning another string rebinds the optional instead of writing through it:

[Example 10:
Settings settings{{"port", "8443"}};

std::optional<std::string&> port = find_setting(settings, "port");
if (port) *port = "9443";        // writes into the map

std::string fallback = "8080";
port = fallback;                 // rebinds: now refers to fallback
*port += "/tcp";                 // so this changes fallback

std::cout << "settings: " << settings.at("port") << '\n';
std::cout << "fallback: " << fallback << '\n';

— end example]

[Example 11:
settings: 9443
fallback: 8080/tcp

— end example]

On both, *port is a std::string&, value_or returns a std::string copy, and the optional is 8 bytes and trivially copyable. Since When’s C++26 table gives GCC 16.1 and Clang 22 as the first releases with it. Where each library stands in C++26 mode (-std=c++26, /std:c++latest):

Library, C++26 mode __cpp_lib_optional optional<T&> for (int p : port)
Microsoft’s STL 202110 rejected does not compile
libstdc++ (GCC 16.2) 202506 compiles compiles
libc++ (Clang 23.1) 202506 compiles only with -fexperimental-library

C++23: and_then, transform and or_else

Three members from P0798R8 replace the if (!o) return std::nullopt; ladder:

Member o has a value o is empty f returns
and_then(f) f(*o) an empty optional<U> an optional<U>
transform(f) f(*o), wrapped in an optional an empty optional<U> a plain U
or_else(f) o f() an optional<T>

The running example, in C++17. to_url(int) formats http://localhost:<port>, and default_url() prints a note and returns the URL for 8080:

[Example 12:
std::optional<std::string> server_url(const Settings& s) {
    std::optional<std::string> text = find_setting(s, "port");
    if (!text) return std::nullopt;
    std::optional<int> port = parse_port(*text);
    if (!port) return std::nullopt;
    return to_url(*port);
}
// ...
std::optional<std::string> url = server_url(s);
if (!url) url = default_url();
std::cout << *url << '\n';

— end example]

The same in C++23:

[Example 13:
std::optional<std::string> server_url(const Settings& s) {
    return find_setting(s, "port")
        .and_then(parse_port)   // C++23: and_then
        .transform(to_url);     // C++23: transform
}
// ...
std::optional<std::string> url =
    server_url(s).or_else(default_url);  // C++23: or_else
std::cout << *url << '\n';

— end example]

Both printed this for a good port, a missing key and eighty:

[Example 14:
http://localhost:8443
(no usable port, using 8080) http://localhost:8080
(no usable port, using 8080) http://localhost:8080

— end example]

When a plain default will do, end the chain in value_or. This printed 8443:

[Example 15:
std::cout << find_setting(good, "port")
                 .and_then(parse_port)   // C++23: and_then
                 .value_or(8080)
          << '\n';

— end example]

Three boxes in a row: find_setting(s, “port”), and_then(parse_port) outlined in magenta, and value_or(8080), which outputs an int. Grey lines drop away after the first two, labelled key missing and not a number, pass under and_then and rise into value_or.
Figure 3 — Once a step has no result, the later steps are skipped and the fallback is used.

Under C++20 all four compilers reject the C++23 version: and_then is not a member there. GCC 12.1, Clang 14 and Visual Studio 2022 17.2 have the three members, per the C++23 table of Since When.

When to use std::expected, a pointer or something else

Situation Use Since
No result is normal and needs no reason std::optional<T> C++17
The caller needs the reason std::expected<T, E> C++23
One of several types std::variant C++17
Maybe an object you do not own T*, or std::optional<T&> C++26 for T&
Maybe an owned object, polymorphic or expensive to move std::unique_ptr<T> C++11
A container that may have no elements the container, empty C++98
An argument the caller may leave out an overload, a default argument or a const T* C++98

An optional parameter costs a copy when the caller has a plain T: a Host passed to connect(const std::optional<Host>&) was copied once into a temporary optional, and &proxy passed to connect(const Host*) copied nothing. The rules for choosing a parameter type are in pass by value or const reference.

On C++23, returning the reason takes no more code than returning nothing. GCC 12, Clang 16 and MSVC 19.33 support std::expected, per cppreference:

[Example 16:
std::expected<int, std::string> parse_port(std::string_view text) {
    int port = 0;
    const char* last = text.data() + text.size();
    auto [end, error] = std::from_chars(text.data(), last, port);
    if (error != std::errc{} || end != last)
        return std::unexpected("not a number");      // C++23
    if (port < 1 || port > 65535)
        return std::unexpected("out of range");      // C++23
    return port;
}

— end example]

Called with “8443”, “eighty” and “70000”, printing the value or error():

[Example 17:
8443: 8443
eighty: not a number
70000: out of range

— end example]

More answers about the library’s types are in Standard library.

Questions

What is std::nullopt?

A constant of type std::nullopt_t that means "no value". Return it or assign it to make an optional empty (o = std::nullopt does what o.reset() does), and compare with it to test (o == std::nullopt is true exactly when o is empty). In most places {} does the same: return {}; and o = {}; both leave an optional empty, even an optional<int>.

Does moving the value out of a std::optional leave it empty?

No. After std::string taken = std::move(*host); the optional still has a value: host.has_value() printed true on all four compilers, and what it holds is a moved-from string. Call host.reset() when it should be empty.

Can I use std::optional in C++14 or C++11?

No: std::optional and its header <optional> are C++17. Before that, boost::optional is the type with nearly the same interface. The differences that matter when you migrate: empty is boost::none, converting constructors are explicit in Boost, Boost has no constexpr interface and no or_else, its map and flat_map are std's transform and and_then, and Boost supports optional references, which std gets in C++26.

Read next

  1. std::expected in C++23: return a value or an errorStandard Library · Miriam Castellanos · written for C++23
  2. C++ string to int: std::stoi or std::from_chars, not atoiStandard Library · Miriam Castellanos · written for C++17
  3. C++ map: std::map find, insert, erase and iterateStandard Library · Miriam Castellanos · written for C++17