Skip to content
C++
Library
since C++17
Intermediate

std::variant: A Type-Safe Union for C++17

Learn to use std::variant to hold exactly one of several types at runtime, visit each case with std::visit, and replace error-prone tagged unions and inheritance hierarchies.

By the end of this page you will construct and query a std::variant, use std::visit with the overload pattern to handle every case, and understand when variant is better than a class hierarchy and when it is not.

What and Why

C unions are type-unsafe: the compiler does not track which member is active, so reading the wrong one is undefined behaviour. The typical workaround β€” a struct with a kind enum and a union β€” requires discipline to keep in sync.

std::variant<Types...> (C++17, <variant>) is a type-safe discriminated union: it holds exactly one value of one of its listed types at any given time, and the compiler enforces that you check which type is active before accessing the value.

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

std::variant<int, double, std::string> v = 42;   // holds int

v = 3.14;          // now holds double
v = "hello";       // now holds std::string

Unlike std::any, the set of types is fixed at compile time, enabling exhaustive pattern matching via std::visit.

Step by Step

Construction and assignment

cpp
#include <variant>

std::variant<int, float, std::string> v;  // default: first type (int), value-initialized to 0

v = 42;           // holds int
v = 3.14f;        // holds float
v = std::string{"hi"};   // holds string

// Explicit construction when ambiguous
std::variant<int, long> w = std::in_place_type<long>, 42L;

Querying the active type

cpp
std::variant<int, std::string> v = "hello";

// Which index is active?
v.index()                               // 1 (std::string is the second type)

// Does it hold a specific type?
std::holds_alternative<std::string>(v)  // true
std::holds_alternative<int>(v)          // false

Accessing the value

cpp
std::variant<int, std::string> v = 42;

// By type β€” throws std::bad_variant_access if wrong type
std::get<int>(v)           // 42
std::get<std::string>(v)   // throws!

// By index
std::get<0>(v)             // 42

// Safe pointer access β€” returns nullptr if wrong type
int* p = std::get_if<int>(&v);     // non-null
std::string* s = std::get_if<std::string>(&v);  // nullptr

std::visit β€” the right way to access

std::visit calls a callable with the currently-active value, dispatching at runtime without you writing any if (index() == ...) chains:

cpp
#include <variant>
#include <iostream>

std::variant<int, double, std::string> v = 3.14;

std::visit([](auto&& val) {
    std::cout << val << '\n';
}, v);   // prints "3.14"

The overload pattern β€” exhaustive case dispatch

Combine std::visit with a struct that overloads operator() for each type. This is the idiomatic way to write exhaustive case analysis:

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

using Token = std::variant<int, double, std::string>;

template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };

void print_token(const Token& tok) {
    std::visit(overloaded{
        [](int x)               { std::cout << "int: " << x << '\n'; },
        [](double x)            { std::cout << "double: " << x << '\n'; },
        [](const std::string& s){ std::cout << "string: " << s << '\n'; },
    }, tok);
}

int main() {
    print_token(42);
    print_token(2.71);
    print_token("hello");
}

If you add a fourth type to Token but forget to add a case to overloaded, the compiler produces an error β€” exhaustiveness is enforced.

Common Patterns

Pattern 1 β€” Result type without exceptions

cpp
#include <variant>
#include <string>

struct ParseError { std::string message; };

using ParseResult = std::variant<int, ParseError>;

ParseResult parse_int(std::string_view s) {
    try   { return std::stoi(std::string(s)); }
    catch (...) { return ParseError{"not a valid integer"}; }
}

auto result = parse_int("abc");
std::visit(overloaded{
    [](int v)               { std::cout << "value: " << v << '\n'; },
    [](const ParseError& e) { std::cout << "error: " << e.message << '\n'; },
}, result);

(C++23 std::expected is better for binary success/error; variant shines when there are three or more alternatives.)

Pattern 2 β€” AST node types

variant composes naturally for heterogeneous tree nodes:

cpp
#include <variant>
#include <memory>
#include <vector>

struct Number { double value; };
struct BinaryOp;

using Expr = std::variant<Number, std::unique_ptr<BinaryOp>>;

struct BinaryOp {
    char op;
    Expr left, right;
};

Pattern 3 β€” State machine states

cpp
struct Idle {};
struct Connecting { std::string host; int port; };
struct Connected  { int socket_fd; };
struct Error      { std::string message; };

using ConnectionState = std::variant<Idle, Connecting, Connected, Error>;

ConnectionState state = Idle{};

// Transition
state = Connecting{"example.com", 443};

// Dispatch
std::visit(overloaded{
    [](Idle)              { /* ... */ },
    [](const Connecting&) { /* ... */ },
    [](const Connected&)  { /* ... */ },
    [](const Error&)      { /* ... */ },
}, state);

Pattern 4 β€” Visiting multiple variants simultaneously

std::visit accepts multiple variants and dispatches on all of them in parallel (Cartesian product). Use a generic lambda as a "catch-all" default:

cpp
#include <variant>
#include <iostream>

struct Circle    {};
struct Rectangle {};
struct Red  {};
struct Blue {};

using Shape = std::variant<Circle, Rectangle>;
using Color = std::variant<Red, Blue>;

template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };

Shape shape = Circle{};
Color color = Red{};

// Visitor for 2 variants: 2x2 = 4 type combinations
std::visit(overloaded{
    [](Circle,    Red)  { std::cout << "red circle\n"; },
    [](Rectangle, Blue) { std::cout << "blue rectangle\n"; },
    [](auto,      auto) { std::cout << "other combination\n"; }, // catch-all
}, shape, color);

The generic auto, auto overload handles all unspecified combinations. Without it, you must list all NΓ—M combinations explicitly β€” which becomes impractical once you have three or more variants.

When visiting N variants with sizes S₁, Sβ‚‚, … Sβ‚™, the compiler generates a dispatch table with S₁ Γ— Sβ‚‚ Γ— … Γ— Sβ‚™ entries. Keep the type lists small to avoid code-size blowup.

Pattern 5 β€” std::visit with a return value

std::visit returns the value produced by the selected overload. All overloads must return the same type (or types that are implicitly convertible to a common type):

cpp
#include <variant>
#include <string>

std::variant<int, double, std::string> v = 3.14;

double as_double = std::visit(overloaded{
    [](int x)               { return static_cast<double>(x); },
    [](double x)            { return x; },
    [](const std::string& s){ return std::stod(s); },
}, v);   // 3.14

If overloads return different, incompatible types the compiler errors with a message about deduction failure. When you need type-specific paths that all produce the same logical result, use if constexpr inside a single generic lambda:

cpp
double as_double = std::visit([](auto&& val) -> double {
    using T = std::decay_t<decltype(val)>;
    if constexpr (std::is_same_v<T, std::string>)
        return std::stod(val);
    else
        return static_cast<double>(val);
}, v);

-> double is the explicit return type that unifies the branches; without it the compiler still deduces a single type, but being explicit avoids surprises.

std::variant vs. virtual dispatch

Both std::variant and class hierarchies with virtual functions model "one of several types at runtime." The right choice depends on whether the type set is open or closed and whether operations or types change more often.

std::variant + std::visitVirtual dispatch
Type setFixed at compile timeOpen β€” add subclasses freely
StorageStack-allocated, no pointer indirectionHeap-allocated, pointer + vtable
Adding a new typeMust update every visitor (exhaustive)Just add a new subclass
Adding a new operationJust add a new visitorMust touch every subclass
PerformanceJump-table dispatch, cache-friendly layoutVirtual call + possible cache miss
Binary sizeGrows with number of type combinations visitedGrows with number of virtual functions
Null stateNot possible by default (std::monostate opt-in)Null pointer is always possible

Prefer std::variant when:

  • The set of types is known and closed (you control all alternatives).
  • You add new operations (visitors) frequently.
  • You want guaranteed exhaustive handling β€” the compiler rejects unhandled cases.
  • Stack allocation and cache locality matter (e.g., inner loops, embedded targets).

Prefer virtual dispatch when:

  • The type set must be open β€” third-party code will add new subtypes.
  • Types need to carry their own behaviour and you add new types more often than new operations.
  • You need runtime polymorphism across translation-unit or library boundaries where variant types cannot be shared.

The two approaches are also called the expression problem: variant makes adding operations easy; inheritance makes adding types easy. Neither solves both without extra machinery.

What Can Go Wrong

valueless_by_exception

If an assignment or emplace throws while changing the active type, the variant enters a valueless_by_exception state β€” it holds nothing. This is rare but worth knowing:

cpp
v.valueless_by_exception()   // true if in this state

Avoid valueless_by_exception by preferring std::get_if + assignment from a value you've already constructed.

std::visit requires handling every type

If your visitor does not handle all alternatives, it fails to compile. This is a feature β€” it prevents you from silently ignoring a case β€” but surprises people coming from std::any.

Variant of a single type β€” prefer optional

std::variant<T> with one type works but is awkward. If you want "T or nothing," use std::optional<T>.

Default construction picks the first type

std::variant<std::string, int>{} constructs a default std::string, not an int. If the first type is not default-constructible, the variant is not default-constructible β€” use std::monostate as the first alternative:

cpp
std::variant<std::monostate, NonDefaultConstructible> v;   // starts empty

Quick Reference

OperationSyntax
Constructstd::variant<A,B,C> v = val;
Check active type (index)v.index()
Check by typestd::holds_alternative<T>(v)
Access (throws if wrong)std::get<T>(v) or std::get<0>(v)
Access (nullptr if wrong)std::get_if<T>(&v)
Visit all typesstd::visit(callable, v)
Exhaustive dispatchstd::visit(overloaded{...}, v)
Empty state sentinelstd::monostate as first type
Header<variant>

std::get_if β€” Safe Pointer-Based Access

std::get_if returns a pointer to the active value, or nullptr if the requested type is not active β€” making it the safe alternative to std::get when you want to check without catching an exception. Because it returns a raw pointer rather than throwing, it integrates naturally into if statements and avoids the overhead of exception handling in hot paths. The pointer is valid for as long as the variant remains in scope and unchanged.

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

std::variant<int, double, std::string> v = 3.14;

// Returns double* β€” non-null because variant holds double
if (double* d = std::get_if<double>(&v)) {
    std::cout << "double: " << *d << '\n';
}

// Returns int* β€” null because variant holds double, not int
if (int* i = std::get_if<int>(&v)) {
    std::cout << "int: " << *i << '\n';
} else {
    std::cout << "not an int\n";
}

Note that std::get_if takes a pointer to the variant (&v), not the variant itself. Forgetting the address-of is a common beginner mistake that produces a compilation error.

std::monostate β€” A "Nothing" Sentinel

std::monostate is an empty, default-constructible type from <variant> that acts as a placeholder when you need a variant to be default-constructible or to model an "unset" state. Without it, a variant whose first type lacks a default constructor cannot be default-constructed either, which makes it awkward to use in member variables or containers that need value-initialization. Placing std::monostate first makes the variant default to the "empty" state.

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

struct Config { std::string path; };   // no default constructor

// Without monostate: variant<Config> v;  // ERROR β€” Config not default-constructible
using MaybeConfig = std::variant<std::monostate, Config>;

MaybeConfig mc;                         // OK: holds monostate (empty)
mc = Config{"settings.toml"};           // transition to Config

std::visit(overloaded{
    [](std::monostate)       { std::cout << "not configured\n"; },
    [](const Config& cfg)    { std::cout << "config: " << cfg.path << '\n'; },
}, mc);

std::monostate is also useful as the "none" branch in a state machine, replacing a separate boolean flag that tracks whether the variant has ever been set.

Recursive Variants for AST Nodes

A variant can represent a recursive data structure β€” such as an expression tree β€” by wrapping recursive branches in std::unique_ptr, because std::variant requires all types to be complete at the point of instantiation. A direct self-reference like variant<Leaf, Node> where Node contains a variant<Leaf, Node> member is illegal (incomplete type), but unique_ptr<Node> breaks the cycle because a pointer to an incomplete type is valid.

cpp
#include <variant>
#include <memory>
#include <string>

// Forward declaration lets unique_ptr compile
struct BinOp;

struct Number   { double value; };
struct Variable { std::string name; };

using Expr = std::variant<Number, Variable, std::unique_ptr<BinOp>>;

struct BinOp {
    char op;          // '+', '-', '*', '/'
    Expr left;
    Expr right;
};

// Recursive visitor
struct Eval {
    double operator()(const Number& n)   const { return n.value; }
    double operator()(const Variable&)   const { return 0.0; }   // stub
    double operator()(const std::unique_ptr<BinOp>& b) const {
        double l = std::visit(*this, b->left);
        double r = std::visit(*this, b->right);
        switch (b->op) {
            case '+': return l + r;
            case '-': return l - r;
            case '*': return l * r;
            case '/': return l / r;
        }
        return 0.0;
    }
};

// Build: (1 + 2) * 3
auto tree = std::make_unique<BinOp>('*',
    Expr{std::make_unique<BinOp>('+', Expr{Number{1}}, Expr{Number{2}})},
    Expr{Number{3}});

double result = std::visit(Eval{}, Expr{std::move(tree)});  // 9.0

For deeply recursive trees, watch stack depth β€” std::visit is not tail-recursive.

Performance Characteristics

std::variant stores its value in a fixed-size internal buffer equal to the size of the largest alternative, plus one discriminator word to record the active type index. There is no heap allocation; the whole object lives in whatever storage holds the variant variable. This makes variant cache-friendly and predictable in performance, unlike a virtual-dispatch hierarchy where each node is a separate heap object.

cpp
sizeof(std::variant<char, double, std::string>)
  = sizeof(std::string)    // largest member (typically 24–32 bytes)
  + discriminator (usually padded to alignment)

Key performance notes:

  • No dynamic allocation. The variant itself never calls new; the contained types may, of course (e.g., std::string for long strings).
  • Always initialized. Unlike a C union, the discriminator is always valid β€” there is no "uninitialized" variant except the valueless_by_exception edge case.
  • std::visit dispatch cost. The compiler typically generates a jump table or a switch over the index. For a small number of types (≀ 8) this is a single indirect branch, comparable to a virtual call but without pointer chasing.
  • Multi-variant visit cost. Visiting N variants with sizes S₁…Sβ‚™ generates a dispatch table with S₁ Γ— Sβ‚‚ Γ— … Γ— Sβ‚™ entries. Keep lists short or use a catch-all auto overload.

Variant vs Inheritance vs Union β€” Trade-off Table

Choosing between std::variant, virtual dispatch, and raw union depends on whether your type set is open or closed, where you need the allocation, and how much type safety you require.

Criterionstd::variantVirtual dispatchunion
Type setClosed (fixed at compile time)Open (add subclasses freely)Closed
Type safetyEnforced by discriminatorEnforced by vtableNone β€” UB if wrong member read
StorageStack, fixed size = max typeHeap per objectStack, fixed size = max type
Default constructionRequires monostate if first type isn't default-constructibleNull pointer is always validAlways trivially constructible
Adding a new typeRecompile all visitorsAdd a new subclass, relinkRecompile all manual checks
Adding a new operationAdd a new visitor (one place)Add a method to every subclassAdd manual check everywhere
Null / empty statestd::monostate or optional<variant>Null pointerManual flag
Exception on wrong accessstd::get throws bad_variant_accessN/A (cast may yield UB)Undefined behaviour
Polymorphism across TUsNot without shared headerYes β€” virtual ABINot without shared header

Use union only in performance-critical or embedded contexts where you can guarantee correct tag management manually and need to avoid the <variant> header cost.

C++23 Improvements

C++23 tidied up two long-standing rough edges in the variant API: std::visit can now return a reference type without wrapping it in std::reference_wrapper, and the interaction with std::expected provides a cleaner binary success/error vocabulary. Prior to C++23, std::visit always returned a value β€” returning a reference from every overload could silently decay to a copy. C++23 lifts this restriction.

cpp
// C++23: std::visit returning a reference
std::variant<int, double> v = 42;

// All overloads return int& or double& β€” both are references
auto& ref = std::visit(overloaded{
    [](int&    x) -> int&    { return x; },
    [](double& x) -> double& { return x; },   // won't compile pre-C++23
}, v);

For binary success/failure, prefer std::expected<T, E> (C++23, <expected>) over std::variant<T, E>. It has a dedicated API (value(), error(), monadic and_then/or_else) that makes error propagation chains readable, and it does not require a visitor for the common "unwrap or propagate" pattern:

cpp
#include <expected>
#include <string>

std::expected<int, std::string> parse(std::string_view s) {
    try   { return std::stoi(std::string(s)); }
    catch (...) { return std::unexpected{"not a number"}; }
}

auto result = parse("42")
    .and_then([](int n) -> std::expected<int, std::string> { return n * 2; })
    .value_or(0);  // 84

Reserve std::variant for cases with three or more alternatives, or when you want exhaustive visitor dispatch rather than monadic chaining.

What's Next

  • std::optional β€” the simpler "T or nothing" alternative.
  • std::expected β€” C++23 "value or error" β€” better than variant<T, Error> for binary success/failure.
  • Overload Pattern β€” the overloaded helper used with std::visit, in depth.