C++17 std::variant: Access, std::visit, valueless_by_exception and Conversion Traps

Key takeaways

std::variant holds exactly one of a fixed set of types and knows which one. This article covers the access functions and when each is appropriate, std::visit and the overloaded{} pattern, and the traps: implicit conversions in visitors, the old bool-from-string-literal conversion, valueless_by_exception, monostate and the size cost, all checked with g++ 10.3.

std::variant<A, B, C> is a C++17 type that holds exactly one value of type A, B or C at a time and records which one. It is a tagged union with the tag managed for you: the active alternative’s constructor and destructor run at the right times, copying and moving work, and reading the wrong alternative is an error you can detect rather than undefined behavior. A detailed side-by-side with raw union is in std::variant vs union; this article is about using variant well once you have chosen it.

All snippets were compiled with g++ 10.3 (libstdc++, MinGW-w64, x86-64). Outputs are what they printed.


Construction and the active alternative

#include <variant>
#include <string>
#include <iostream>

std::variant<int, double, std::string> v;        // holds int{} = 0, index() == 0
v = 3.5;                                          // destroys the int, now holds double
v = std::string("hello");                         // destroys the double, now holds string
v.emplace<int>(7);                                // construct in place
std::variant<int, double> w(std::in_place_type<double>, 2.0);

A default-constructed variant value-initializes its first alternative. If the first type has no default constructor, the variant has none either. The standard fix is std::monostate, an empty type meant to be placed first:

struct NoDefault { NoDefault(int) {} };
std::variant<std::monostate, NoDefault> m;   // OK, m.index() == 0

monostate also doubles as an explicit “empty” state, which is often what you actually want for things like “not loaded yet”. Just remember that every visitor now needs a monostate case.

Converting construction (v = 3.5) picks the alternative the way overload resolution would pick among imaginary functions F(int), F(double), F(std::string). That rule is the source of several surprises covered below.


Reading the value: get, get_if, holds_alternative

std::variant<int, double, std::string> w = 42;

try { std::get<std::string>(w); }
catch (const std::bad_variant_access& e) { std::cout << "caught: " << e.what() << "\n"; }

if (auto* p = std::get_if<int>(&w)) std::cout << "get_if<int>: " << *p << "\n";
std::cout << "get_if<double> null? " << (std::get_if<double>(&w) == nullptr) << "\n";
caught: std::get: wrong index for variant
get_if<int>: 42
get_if<double> null? 1

The three access styles express different intents:

  • std::get<T>(v) (or std::get<I>(v)) says “it must be T”. A mismatch throws std::bad_variant_access. That is appropriate when the active type follows from program logic and a mismatch means a bug.
  • std::get_if<T>(&v) says “it might be T”. It takes a pointer to the variant and returns a pointer to the value or nullptr. Use it in ordinary branching. Passing the variant itself instead of its address is a common first compile error.
  • std::holds_alternative<T>(v) followed by std::get<T>(v) checks the index twice. It works, but get_if does the same job in one step.

When the variant has duplicate types, e.g. std::variant<int, int> (legal, occasionally used to distinguish two meanings of the same type), type-based access is ill-formed and you must use the index:

error: static assertion failed: T must occur exactly once in alternatives

If you find yourself writing if (get_if<A>) ... else if (get_if<B>) ... else if (get_if<C>) chains, that is the signal to switch to std::visit. The chain compiles fine after someone adds alternative D and forgets to update it; a well-written visitor does not.


std::visit and the overloaded pattern

std::visit(visitor, v) calls visitor with the currently active value. The visitor must be callable with every alternative, which is checked at compile time. The most readable way to write one is to combine lambdas:

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;   // not needed in C++20

struct Circle { double r; };
struct Square { double side; };
struct Triangle { double b, h; };
using Shape = std::variant<Circle, Square, Triangle>;

double area(const Shape& s) {
    return std::visit(overloaded{
        [](const Circle& c)   { return 3.14159 * c.r * c.r; },
        [](const Square& q)   { return q.side * q.side; },
        [](const Triangle& t) { return 0.5 * t.b * t.h; },
    }, s);
}

overloaded inherits from each lambda’s closure type and pulls all their operator()s into one overload set, so visit performs ordinary overload resolution against the active type. The deduction guide lets you write overloaded{...} without template arguments; C++20’s aggregate CTAD makes it unnecessary, but it is harmless to keep for C++17 builds.

Leave out the Triangle case and the build fails, which is the whole point:

error: no matching function for call to '__invoke(overloaded<main()::<lambda(const Circle&)>, main()::<lambda(const Square&)> >, Triangle&)'

The message points into <variant>; the useful part is the last type in the parentheses, which is the alternative with no handler.

Other visit features worth knowing:

  • Return values. All handlers must return the same type (C++20 adds std::visit<R> to force a common return type). Mixed returns such as int in one lambda and double in another fail to compile.
  • Multiple variants. std::visit(f, a, b) calls f with the active values of both, so the visitor needs to handle every combination. That grows as the product of the alternative counts, so it is best kept to small variants or paired with a generic fallback.
  • Generic lambda with if constexpr. [](const auto& x) { using T = std::decay_t<decltype(x)>; if constexpr (std::is_same_v<T, Circle>) ... } works too, but it gives up exhaustiveness unless you end with a static_assert in the final else.

Trap 1: implicit conversions make visitors non-exhaustive

The compile-time check only proves that each alternative can call some handler. It does not prove that each alternative has its own handler:

std::variant<int, double, std::string> v = 2.5;
std::visit(overloaded{
    [](int i)                { std::cout << "int " << i << "\n"; },
    [](const std::string& s) { std::cout << "str " << s << "\n"; },
}, v);
int 2

There is no double handler, double converts to int, so 2.5 is truncated to 2 and printed by the int branch. This compiles cleanly even with -Wall -Wconversion, because the conversion happens inside a system header where warnings are suppressed. The same thing happens with bool handlers (pointers and numbers convert to bool) and with any handler taking a type that has a converting constructor.

A catch-all [](auto&&) {} has the same effect in a more visible way: it compiles for every future alternative, so adding a type to the variant never forces anyone to write handling code for it.

This is the bug I have seen most with variant-based message or event types. Someone adds a new alternative, the build is green because a double or auto handler quietly absorbs it, and the new message type is processed as something else. If exhaustiveness matters, write handlers that take the exact alternatives by const&, avoid catch-alls, and consider a static_assert-based fallback:

template<class> inline constexpr bool always_false = false;
[](const auto& x) { static_assert(always_false<decltype(x)>, "unhandled alternative"); }

Placed last in overloaded, this is only selected when no exact handler exists. Note that it is less preferred than an exact match but more preferred than a converting one, so it also catches the double-into-int case above.


Trap 2: a string literal choosing bool

std::variant<bool, std::string> v = "abc";
std::cout << v.index() << "\n";

On g++ 10.3 this prints 1 (it holds std::string) with both -std=c++17 and -std=c++20. It did not always: under the original C++17 wording, "abc" decays to const char*, pointer-to-bool is a standard conversion, and pointer-to-std::string is a user-defined conversion, so the imaginary F(bool) won and the variant held true. Proposal P0608 (adopted for C++20 and applied by libstdc++ as a fix in C++17 mode too, starting with GCC 10) changed converting construction to ignore narrowing and boolean conversions. Older standard libraries still pick bool, so code that must build on older toolchains should be explicit: v = std::string("abc") or using namespace std::literals; v = "abc"s;.

P0608 also means some conversions that used to compile no longer do. std::variant<float, std::string> b = 3.14; is now an error on g++ 10.3, because double to float is narrowing:

error: conversion from 'double' to non-scalar type 'std::variant<float, std::__cxx11::basic_string<char> >' requested

(The real message spells out the full basic_string template arguments.) The fix is the same: say what you mean, 3.14f or std::in_place_type<float>.


Trap 3: valueless_by_exception

A variant can end up holding nothing. It happens when changing the active alternative destroys the old value and the construction of the new one throws:

struct Thrower {
    Thrower() = default;
    Thrower(const Thrower&) { throw std::runtime_error("copy failed"); }
};

std::variant<std::string, Thrower> t = std::string("hello");
Thrower th;
try { t = th; } catch (const std::exception& e) { std::cout << "assign threw: " << e.what() << "\n"; }
std::cout << "valueless: " << t.valueless_by_exception()
          << ", index == npos: " << (t.index() == std::variant_npos) << "\n";
try { std::visit([](auto&&) {}, t); }
catch (const std::bad_variant_access& e) { std::cout << "visit threw: " << e.what() << "\n"; }
assign threw: copy failed
valueless: 1, index == npos: 1
visit threw: std::visit: variant is valueless

Why doesn’t the variant keep the old string? Its storage is a single buffer shared by all alternatives, so the old value must be destroyed before the new one can be built in the same place. Keeping a backup would require extra storage or a heap allocation, which std::variant deliberately avoids. Implementations do reduce the window: libstdc++ builds a temporary first and then moves it in when the new type’s move is noexcept, and constructs directly when construction itself cannot throw. Thrower has a throwing copy constructor and no move constructor, so neither shortcut applies.

In practice valueless variants are rare, since it takes a throwing constructor during a type switch, typically a std::bad_alloc. But code that catches the exception and carries on must not assume the variant still holds something. Check valueless_by_exception() in recovery paths, or reassign the variant before using it again.


Size and cost

sizeof(std::string)                     // 32
sizeof(std::variant<int, std::string>)  // 40
sizeof(std::variant<char, int>)         // 8

A variant is its largest alternative plus a small index, padded to the strictest alignment. There is no heap allocation, and get_if / index() are a comparison. std::visit dispatches through a table of function pointers generated at compile time (or, for small variants, code the optimizer can turn into a switch), which is comparable to a virtual call.

The size rule has a practical consequence: one large alternative makes every value large. A std::variant<int, std::array<char, 4096>> is over 4 KB even when it holds an int, and a std::vector of them is mostly padding. When one alternative is much bigger than the rest, store it behind a std::unique_ptr so the variant stays small.


Where variant fits

  • Closed sets of types known at compile time: parse results, protocol messages, AST nodes, state machines (std::variant<Idle, Running, Done>). Adding an alternative forces every exact-handler visitor to be updated.
  • Result or error: std::variant<T, Error> works, but C++23’s std::expected expresses it more clearly if you can use it.
  • “Value or nothing”: use std::optional instead of std::variant<std::monostate, T>.
  • Open sets of types that callers can extend: that is inheritance with virtual functions, or std::any for truly arbitrary values. Variant trades that extensibility for value semantics and no allocation.

Variants cannot hold references, arrays or void directly; use std::reference_wrapper<T> or a pointer when you need to refer to an existing object.