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
- 1Create a std::optional, test it, read it
- 2What happens when you read an empty std::optional
- 3std::nullopt, reset and emplace: empty it and refill it
- 4Does std::optional allocate? Size and storage
- 5Three traps:
optional<bool>, pointers and comparisons - 6std::optional and references:
optional<T&>arrives in C++26 - 7C++23: and_then, transform and or_else
- 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::, 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&>.

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::, 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,emplaceand assignment areconstexpr; all four compilers below accept them in a constant expression under C++20 and reject them under C++17. - C++23:
and_then,transformandor_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:
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]
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:: |
o.value_or(x) |
a copy of the value | x, converted to T |
- The test is an explicit conversion to
bool. It works inif,while,!,&&andstatic_cast<bool>(o), butbool found = port;does not compile on any of the four. return {};returns an empty optional, the same asreturn 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= |
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::, 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:
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]
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. emplacebuilds 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::oroptional<Conn> o{std:: in_place, "db"}; auto o = std::. Each printed onemake_optional<Conn>("db"); constructand no move. o = {}empties anoptional<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:: |
|---|---|---|
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:::
allocations: optional 0, unique_ptr 1— end example]

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:: neither.
Three traps: optional<bool>, pointers and comparisons
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]
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 withvalue_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 equalsstd::nulloptand any other empty one, and is less than every value, even-1. Sostd::sorton{8443, std::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.nullopt, 80, std:: nullopt, 8080}
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:
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:::
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:
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]
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:: 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:
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:
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:
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:
std::cout << find_setting(good, "port")
.and_then(parse_port) // C++23: and_then
.value_or(8080)
<< '\n';— end example]

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:: 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:
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():
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.
Sources
- std::optionalcppreference.com
- [optional.observe] ObserversC++ standard working draft
- P3471R4: Standard library hardeningWG21
- P0798R8: Monadic operations for std::optionalWG21
- P2988R12: std::optional<T&>WG21
- bugprone-unchecked-optional-accessclang-tidy documentation
- STL HardeningMicrosoft STL wiki
- Comparison with std::optionalBoost.Optional documentation