Standard Library[lib]
std::variant in C++: std::visit, std::get and the traps
- 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
- 9 min read
In this answer9 clauses
- 1Create a std::variant, assign to it, ask what it holds
- 2Read it: std::get, std::get_if or std::visit
- 3std::visit and the overloaded visitor
- 3.1Every case must return the same type
- 4A missing case is a compile error, unless a conversion hides it
- 5std::monostate: a variant that holds nothing yet
- 6Three traps: the converting constructor, duplicate types, valueless
- 6.1Which alternative does
Value v = "8080";hold? - 6.2The same type twice
- 6.3valueless_by_exception: when the variant holds nothing
- 7Does std::variant allocate? Size and storage
- 8What C++20 and C++26 changed
- 9std::variant or something else
Abstract
std::variant (C++17) holds one value of a fixed list of types, inside the object, with no allocation. Read it with std::visit, with std::get_if (a null pointer when it holds another type) or with std::get (which throws std::). A visitor must be callable with every alternative, but a forgotten case still compiles when overload resolution picks another case for it through a conversion, as it picks an int lambda for a bool; C++20 lambdas constrained with std::same_as make the check exact.

std:: (C++17, header <variant>) holds one value of one of the listed types, inside the object, and knows which. Read it with std::visit and a visitor callable with every alternative, with std::get_if<T>(&v) for one type (null if it holds another), or with std::get<T>(v) when sure (it throws std:: otherwise). A case missing from a visitor still compiles when overload resolution picks another case for that type through a conversion: an int lambda silently takes a bool. Use it for a closed set of types; for a set other code extends, use virtual functions.
This answer is written for C++17, which added std::variant; C++20 and C++26 added to it, as listed after the traps. 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 says otherwise.
GCC 7.1, Clang 4 and Visual Studio 2017 15.0 were the first to ship it, per the C++17 table of Since When; every member is on cppreference’s std::variant page.
What changed: a value that is one of several types is kept in a union and a tag you keep in step by hand std::variant.
since C++17
Create a std::variant, assign to it, ask what it holds
The running example is a setting’s value, which is an int, a bool or a string:
using Value = std::variant<int, bool, std::string>;
// ...
std::cout << std::boolalpha;
Value port = 8080; // holds an int
Value verbose = true; // holds a bool
Value host = std::string("db.local"); // holds a std::string
std::cout << port.index() << ' ' << verbose.index() << ' '
<< host.index() << '\n'; // 0 1 2
port = std::string("auto"); // now holds a std::string
std::cout << port.index() << ' '
<< std::holds_alternative<std::string>(port) << ' '
<< std::holds_alternative<int>(port) << '\n';
Value unset; // holds the int 0
std::cout << unset.index() << ' ' << std::get<int>(unset) << '\n';— end example]
It printed:
0 1 2
2 true false
0 0— end example]
index()counts from 0, in the order of the template arguments.- Assigning the type already held assigns to the value; assigning another type destroys the old value and constructs the new one in its place.
- A default-constructed variant holds a value-initialized first alternative:
Value unset;holds theint0. - An alternative cannot be a reference, an array or
void. - Two variants compare by index first, then by value:
Value(8080) < Value(false)was true on all four, becauseintcomes beforeboolin the list.
Read it: std::get, std::get_if or std::visit
| You write | It holds T |
It holds another type |
|---|---|---|
std::get<T>(v), std::get<I>(v) |
a reference to the value | throws std:: |
std::get_if<T>(&v) |
a pointer to the value | nullptr |
std:: |
true |
false |
std::visit(f, v) |
calls f(value) |
calls f(value) |
std::get_if takes a pointer to the variant and returns a pointer, so it fits in an if. std::get<T>, std::get_if<T> and std:: need T to occur exactly once in the list, and an index past the end is ill-formed ([variant.get]).
Value v = std::string("db.local");
try {
int port = std::get<int>(v); // v holds a std::string: throws
std::cout << "port " << port << '\n';
} catch (const std::bad_variant_access& e) {
std::cout << "caught: " << e.what() << '\n';
}
if (auto* text = std::get_if<std::string>(&v)) { // a pointer: &v
std::cout << "text " << *text << '\n';
}
if (std::get_if<int>(&v) == nullptr) {
std::cout << "not an int\n";
}
std::cout << std::get<2>(v) << '\n'; // by index: the std::string— end example]
On Microsoft’s STL it printed:
caught: bad variant access
text db.local
not an int
db.local— end example]
libstdc++ words the first line std:: and libc++ std::: catch the type, never compare the text.
std::visit and the overloaded visitor
std::visit(f, v) calls f with the value v holds. The usual f is one lambda per alternative, merged into one object by overloaded, a helper the standard library does not provide, so you write it once:
template <class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template <class... Ts>
overloaded(Ts...) -> overloaded<Ts...>; // C++17 needs this guide
std::string describe(const Value& v) {
return std::visit(
overloaded{
[](int n) { return "int " + std::to_string(n); },
[](bool b) {
return std::string(b ? "bool true" : "bool false");
},
[](const std::string& s) { return "text " + s; },
},
v);
}
// ...
const Value settings[] = {8080, true, std::string("db.local")};
for (const Value& v : settings) {
std::cout << describe(v) << '\n';
}— end example]
It printed:
int 8080
bool true
text db.local— end example]
The deduction guide lets overloaded{...} deduce its template arguments from the lambdas. Without it, all four reject the call under -std=c++17 (both Clangs: no viable constructor or deduction guide for deduction of template arguments of 'overloaded'). C++20 deduces template arguments for aggregates, so all four accept the helper without the guide under -std=c++20. Clang implements that from 17, GCC from 10 and Visual Studio from 2019 16.7: keep the guide if your code is also built with an older Clang.
When every alternative is handled the same way, one generic lambda is the whole visitor: std:: printed 8080, true and db.local (with std::boolalpha set, as in the first sample).
Every case must return the same type
The bool case above wraps its result in std::string for a reason. Return b ? "bool true" : "bool false" bare, a const char* beside two std::string results, and all four stop at a static_assert; Microsoft’s reads visit() requires the result of all potential invocations to have the same type and value category. In C++17, spell the return type on that lambda ([](bool b) -> std::). In C++20, name the result type once with std::visit<R>:
std::string describe(const Value& v) {
return std::visit<std::string>( // C++20: visit<R>
overloaded{
[](int n) { return "int " + std::to_string(n); },
[](bool b) { return b ? "bool true" : "bool false"; },
[](const std::string& s) { return "text " + s; },
},
v); // each result is converted to std::string
}— end example]
Under -std=c++17 all four reject std::, taking the explicit argument as the visitor’s type. It converts results, so it cannot rescue a case whose type does not convert to R: an int result with std:: fails on all four.
A missing case is a compile error, unless a conversion hides it
std::visit requires the visitor to be callable with every alternative ([variant.visit]), and a call can succeed through a conversion. Leave the bool case out of describe and it still compiles on all four:
std::string describe(const Value& v) {
return std::visit(
overloaded{
[](int n) { return "int " + std::to_string(n); },
[](const std::string& s) { return "text " + s; },
},
v); // no bool case, and it compiles: bool converts to int
}
// ...
std::cout << describe(Value(true)) << '\n'; // the int branch ran— end example]
It printed int 1. A missing case is rejected only when no other case can take that alternative, or when two can take it equally well.
Add double to Value instead (std::) and the three-case describe stops compiling on all four, for the second reason. Called directly with 0.5, the same visitor is ambiguous, because a double converts to int and to bool equally well (MSVC: error C3066: call to an object of this type is ambiguous). Through std::visit, no compiler says “ambiguous” or names the missing case: each reports that the visitor cannot be called with a const double&, in 29 lines of output on MSVC and 152 on GCC.
To make the check exact, let each case accept its own type and nothing else: constrain its parameter with std::same_as, one of the C++20 concepts. Then the missing bool case is an error on all four:
std::string describe(const Value& v) {
return std::visit(
overloaded{
[](std::same_as<int> auto n) { // an int and nothing else
return "int " + std::to_string(n);
},
[](const std::string& s) { return "text " + s; },
},
v); // error: no case for bool, and no conversion to hide it
}— end example]
The trap is any alternative that converts to the parameter of another case:
- arithmetic types such as
int,boolanddouble, which convert to one another; - a pointer, which converts to
bool: aconst char*alternative with no case of its own ran theboolcase on all four; - a class whose base another case takes: a
Squarewith no case ran theconst Shape&case on all four.
Only cases that accept their own type and nothing else, like the std::same_as lambda, make the check exact. A chain of get_if tests has no check at all: a type it forgets falls through at run time.
std::monostate: a variant that holds nothing yet
A variant default-constructs its first alternative, so std:: does not compile when Conn has no default constructor (GCC: no matching function for call to 'std::). Put std::monostate first, an empty type whose values all compare equal, and the variant starts in a “nothing yet” state that a visitor handles like any other alternative:
struct Conn { // no default constructor
explicit Conn(std::string h) : host(std::move(h)) {}
std::string host;
};
using Connection = std::variant<std::monostate, Conn>;
std::string status(const Connection& c) {
return std::visit(
overloaded{
[](std::monostate) { return std::string("not connected"); },
[](const Conn& conn) {
return "connected to " + conn.host;
},
},
c);
}
// ...
Connection c; // holds std::monostate: nothing yet
std::cout << c.index() << ' '
<< std::holds_alternative<std::monostate>(c) << '\n';
std::cout << status(c) << '\n';
c = Conn("db.local");
std::cout << status(c) << '\n';
c = std::monostate{}; // back to nothing
std::cout << status(c) << '\n';— end example]
It printed:
0 true
not connected
connected to db.local
not connected— end example]
std::monostate is declared in <variant>. C++26 declares it in <utility> too: libstdc++ and libc++ do under -std=c++26, and Microsoft’s STL from Visual Studio 2026 18.0 (the VS 2022 build used here rejects it).
Three traps: the converting constructor, duplicate types, valueless
Which alternative does Value v = "8080"; hold?
The std::string, on all four under C++17 and C++20:
Value v = "8080"; // a const char*: bool or std::string?
std::cout << "Value v = \"8080\": index " << v.index() << '\n';
std::variant<float, long, double> z = 0; // an int: which one?
std::cout << "variant<float, long, double> z = 0: index "
<< z.index() << '\n';— end example]
Value v = "8080": index 2
variant<float, long, double> z = 0: index 1— end example]
The original C++17 rule picked bool for a string literal. Since P0608R3, applied to C++17 as a defect report, an alternative counts only if it can be initialized from the value without narrowing, and a pointer to bool counts as narrowing (P1957R2, also a defect report). In that test the argument is an int variable, not the constant 0, and an int to float or double narrows, so z holds the long.
The libraries carry P0608R3 from GCC 10.1 (libstdc++), libc++ 9 and Visual Studio 2022 17.12 (Microsoft’s STL in C++17 mode). Where it matters, spell the type: Value host = std::.
The same type twice
std:: is allowed: say, a port from the file at index 0 and one from the command line at index 1. std::get<int> on it is ill-formed, since int must occur exactly once (all four stop at a static_assert; GCC: T must occur exactly once in alternatives), and so are std::get_if<int> and std::. Use the index forms: std:: to construct, std::get<1>(port) to read, port.emplace<0>(8080) to replace.
valueless_by_exception: when the variant holds nothing
If constructing the new value throws during a type-changing assignment or emplace, the variant may be left holding nothing: the standard allows it, and the libraries differ on when. Here a Value holding 8080 is given a string whose constructor throws std::length_error:
Value v = 8080;
try {
// constructing the string throws: the count is too large
v.emplace<std::string>(std::string::npos, 'x');
} catch (const std::length_error& e) {
std::cout << "length_error: " << e.what() << '\n';
}
if (v.valueless_by_exception())
std::cout << "valueless\n";
else
std::cout << "still holds int " << std::get<int>(v) << '\n';— end example]
| Library | After the throw |
|---|---|
| Microsoft’s STL | valueless |
| libstdc++ | still holds int 8080 |
| libc++ | valueless |
All three agree on a type Fragile whose copy constructor throws:
- With no move constructor,
a.emplace<Fragile>(f)and the assignmentb = fboth leave the variant valueless. - With a
noexceptmove constructor,b = fkeeps theint, because the copy into a temporary throws before the old value is touched;emplacestill leaves it valueless.
A valueless variant has index() == std::, and std::visit and std::get on it throw std::. After catching an exception from a type-changing assignment or emplace, test valueless_by_exception() before reading the variant again.
Does std::variant allocate? Size and storage
No. The standard says the value is “nested within” the variant object ([variant.variant.general]), so there is no pointer to it. A counting operator new saw no allocation while a Value held 8080, true, a short "db.local" and 8443 in turn, on all three libraries. The standard fixes no layout; on all three, the size came out as the largest alternative plus an index, padded to the alignment:
| Type | Microsoft’s STL | libstdc++ | libc++ |
|---|---|---|---|
std::string |
32 | 32 | 24 |
Value |
40 | 40 | 32 |
std::variant<char> |
2 | 2 | 8 |
std:: |
16 | 16 | 16 |
std::variant<char> shows the index: one byte on Microsoft’s STL and libstdc++, four on libc++.

What C++20 and C++26 changed
- C++20:
std::visit<R>,overloadedwithout a deduction guide,operator<=>on variants, and a type-changing assignment inside aconstexprfunction (P2231R1, a defect report against C++20). Each was rejected under-std=c++17and accepted under-std=c++20by all four. - C++26: the member
v.visit(f), which forwards tostd::visit(P2637R3). - Pattern matching is not in C++26.
Value v = std::string("db.local");
std::string text = v.visit(overloaded{ // C++26: member visit
[](int n) { return "int " + std::to_string(n); },
[](bool b) {
return std::string(b ? "bool true" : "bool false");
},
[](const std::string& s) { return "text " + s; },
});
std::cout << text << '\n';— end example]
Under -std=c++26, GCC 16.2 and Clang 23.1 with libc++ printed text db.local; under -std=c++23 both reject it. On Microsoft’s STL both compilers reject it in C++26 mode (MSVC: error C2039: 'visit': is not a member of 'std::), which matches the C++26 table of Since When: GCC 15.1, Clang 19, MSVC not yet.
std::variant or something else
| Situation | Use | Since |
|---|---|---|
| One type, or nothing | std::optional<T> |
C++17 |
| A value, or the reason there is none | std::expected<T, E> |
C++23 |
| A closed set of types, each with its data | std:: |
C++17 |
| A set of types other code will extend | a base class and virtual functions | C++98 |
| A fixed set of names with no data | enum class |
C++11 |
More answers about the library’s types are in Standard library.
Questions
What is the difference between std::variant and a union?
A union holds one of its members but does not record which, and leaves destroying it to you. std::variant keeps an index of the alternative it holds, checks every std::get against it and destroys the value for you. The C++ Core Guidelines (C.181) say to avoid naked unions and name the C++17 variant as the type that wraps a union and a type field for you.
How does boost::variant differ from std::variant?
boost::variant is never empty: on a type-changing assignment it may keep a temporary backup on the heap, so it may allocate, which std::variant is never permitted to do. std::variant accepts the valueless state instead. Boost also has recursive_wrapper for recursive types, and Boost.Variant2’s variant is never valueless either (it doubles its storage when an alternative’s move can throw) and gives the strong guarantee on assignment and emplace.
Can I visit two variants at once?
Yes: std::visit(f, a, b) calls f with the values a and b hold, so f must accept every combination, nine for two variants of three types. A generic lambda with two const auto& parameters covers them all: for a Value holding 8080 and one holding the string “8443″, it printed 8080 overridden by 8443 on all four compilers.
How do I write a recursive std::variant?
Through an indirection such as std::unique_ptr or std::vector, because a variant stores its alternatives inside itself. An expression type that is a variant of int and an Add holding two expressions does not compile with Add held by value (both Clangs: incomplete type ‘Add’ used in type trait expression). Held through std::unique_ptr, it builds on all four, and the tree for 8000 + 4 * 20 evaluated to 8080.
Sources
- std::variantcppreference.com
- std::visitcppreference.com
- [variant.visit] VisitationC++ standard working draft
- [variant.get] Value accessC++ standard working draft
- P0608R3: A sane variant converting constructorWG21
- P2637R3: Member visitWG21
- C.181: Avoid “naked” unionsC++ Core Guidelines
- Boost.Variant2Boost