Standard Library[lib]

std::variant in C++: std::visit, std::get and the traps

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::bad_variant_access). 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.

A std::variant as a wide storage cell with a square index cell holding a blue mark. Inside it, three nested outlines from its left edge: two dashed grey, the smallest hatched.
Figure 1 — A variant is one block of storage shared by its alternatives, and an index that says which one is there.

std::variant<A, B, C> (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::bad_variant_access 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:

[Example 1:
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:

[Example 2:
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 the int 0.
  • 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, because int comes before bool in 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::bad_variant_access
std::get_if<T>(&v) a pointer to the value nullptr
std::holds_alternative<T>(v) 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::holds_alternative<T> need T to occur exactly once in the list, and an index past the end is ill-formed ([variant.get]).

[Example 3:
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:

[Example 4:
caught: bad variant access
text db.local
not an int
db.local

— end example]

libstdc++ words the first line std::get: wrong index for variant and libc++ std::get: wrong alternative for variant: 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:

[Example 5:
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:

[Example 6:
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::visit([](const auto& x) { std::cout << x << '\n'; }, v); 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::string). In C++20, name the result type once with std::visit<R>:

[Example 7:
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::visit<std::string>, 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::visit<std::string> 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:

[Example 8:
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::variant<int, bool, std::string, double>) 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:

[Example 9:
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, bool and double, which convert to one another;
  • a pointer, which converts to bool: a const char* alternative with no case of its own ran the bool case on all four;
  • a class whose base another case takes: a Square with no case ran the const 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::variant<Conn, int> c; does not compile when Conn has no default constructor (GCC: no matching function for call to 'std::variant<Conn, int>::variant()'). 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:

[Example 10:
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:

[Example 11:
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:

[Example 12:
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]

[Example 13:
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::string("db.local");.

The same type twice

std::variant<int, int> 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::holds_alternative<int>. Use the index forms: std::in_place_index<1> 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:

[Example 14:
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 assignment b = f both leave the variant valueless.
  • With a noexcept move constructor, b = f keeps the int, because the copy into a temporary throws before the old value is touched; emplace still leaves it valueless.

A valueless variant has index() == std::variant_npos, and std::visit and std::get on it throw std::bad_variant_access. 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::variant<int, double> 16 16 16

std::variant<char> shows the index: one byte on Microsoft’s STL and libstdc++, four on libc++.

A variant of int, bool and std::string in three rows: storage as wide as the string, hatched for the value held (a small part for int, a sliver for bool, all for the string), then the index: 0, 1, 2.
Figure 2 — The storage is sized for the largest alternative; the index records which one is alive.

What C++20 and C++26 changed

  • C++20: std::visit<R>, overloaded without a deduction guide, operator<=> on variants, and a type-changing assignment inside a constexpr function (P2231R1, a defect report against C++20). Each was rejected under -std=c++17 and accepted under -std=c++20 by all four.
  • C++26: the member v.visit(f), which forwards to std::visit (P2637R3).
  • Pattern matching is not in C++26.
[Example 15:
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::variant<int,bool,std::string>'), 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::variant<A, B, C> 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.

Read next

  1. C++ int to string: to_string, format or to_chars, not itoaStandard Library · Miriam Castellanos · written for C++17
  2. C++ string to int: std::stoi or std::from_chars, not atoiStandard Library · Miriam Castellanos · written for C++17
  3. std::span in C++20: pass a view, not a pointer and a sizeStandard Library · Miriam Castellanos · written for C++20