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.
#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::stringUnlike std::any, the set of types is fixed at compile time, enabling exhaustive pattern matching via std::visit.
Step by Step
Construction and assignment
#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
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) // falseAccessing the value
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); // nullptrstd::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:
#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:
#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
#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:
#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
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:
#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):
#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.14If 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:
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::visit | Virtual dispatch | |
|---|---|---|
| Type set | Fixed at compile time | Open β add subclasses freely |
| Storage | Stack-allocated, no pointer indirection | Heap-allocated, pointer + vtable |
| Adding a new type | Must update every visitor (exhaustive) | Just add a new subclass |
| Adding a new operation | Just add a new visitor | Must touch every subclass |
| Performance | Jump-table dispatch, cache-friendly layout | Virtual call + possible cache miss |
| Binary size | Grows with number of type combinations visited | Grows with number of virtual functions |
| Null state | Not 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
varianttypes 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:
v.valueless_by_exception() // true if in this stateAvoid 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:
std::variant<std::monostate, NonDefaultConstructible> v; // starts emptyQuick Reference
| Operation | Syntax |
|---|---|
| Construct | std::variant<A,B,C> v = val; |
| Check active type (index) | v.index() |
| Check by type | std::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 types | std::visit(callable, v) |
| Exhaustive dispatch | std::visit(overloaded{...}, v) |
| Empty state sentinel | std::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.
#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.
#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.
#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.0For 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.
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::stringfor long strings). - Always initialized. Unlike a C union, the discriminator is always valid β there is no "uninitialized" variant except the
valueless_by_exceptionedge case. std::visitdispatch 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
autooverload.
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.
| Criterion | std::variant | Virtual dispatch | union |
|---|---|---|---|
| Type set | Closed (fixed at compile time) | Open (add subclasses freely) | Closed |
| Type safety | Enforced by discriminator | Enforced by vtable | None β UB if wrong member read |
| Storage | Stack, fixed size = max type | Heap per object | Stack, fixed size = max type |
| Default construction | Requires monostate if first type isn't default-constructible | Null pointer is always valid | Always trivially constructible |
| Adding a new type | Recompile all visitors | Add a new subclass, relink | Recompile all manual checks |
| Adding a new operation | Add a new visitor (one place) | Add a method to every subclass | Add manual check everywhere |
| Null / empty state | std::monostate or optional<variant> | Null pointer | Manual flag |
| Exception on wrong access | std::get throws bad_variant_access | N/A (cast may yield UB) | Undefined behaviour |
| Polymorphism across TUs | Not without shared header | Yes β virtual ABI | Not 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.
// 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:
#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); // 84Reserve 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
overloadedhelper used withstd::visit, in depth.