AlgoMaster Logo

std::variant (C++17)

Medium Priority39 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

std::variant<Ts...> is a type-safe tagged union. It holds exactly one value, drawn from a fixed list of types declared at compile time, and the compiler tracks which alternative is currently active. The header is <variant>, the type lives in namespace std, and the entire chapter assumes C++17 or later. Compile examples with g++ -std=c++17.

The Problem It Solves

A payment method in an online store is one of card, UPI, or cash. A parse result is one of a number, a string, or an error. A search filter is a price range, a category, or a rating threshold. The shape of the problem is always the same: a value that's exactly one of a known set of types, where the set is fixed and the alternatives carry different data.

Before C++17, there were two common ways to express this, and both had real downsides.

The first is inheritance: declare a base class with virtual methods, and derive one subclass per alternative. The set of types becomes an open hierarchy, every value lives on the heap (because the base is non-copyable in any useful way without slicing), and dispatch goes through a vtable lookup. For a closed, fixed set of alternatives, all of that machinery is overkill.

Every Payment has to be a std::unique_ptr<Payment> or std::shared_ptr<Payment> to avoid object slicing. Every call site allocates. Adding a new alternative is fine, which is part of the problem: nothing in the type system stops a future developer from adding Bitcoin or Discount to the hierarchy and breaking code that assumed the set was closed.

The second approach is a hand-rolled tagged union: a struct with an enum and a C union. The compiler enforces nothing, the active member is tracked manually, and non-trivially-destructible types like std::string need placement new and explicit destructor calls.

Constructors, destructors, and copy operators all become manual exercises. The union has to be activated and deactivated by hand. Mismatched reads (reading upi.id when kind == Card) are undefined behavior with no diagnostic.

std::variant<Ts...> removes that bookkeeping. It stores its value inline, the compiler tracks the active alternative through a hidden discriminator, and reading the wrong type is a check the standard library performs.

Output:

v.index() returns the zero-based position of the active alternative within the template parameter list: 0 for int, 1 for double, 2 for std::string. Assigning a new value of a listed type switches the variant to that alternative, destroying the previously held value automatically.

Constructing a Variant

A variant has a default state, a value-initialized state from any of its alternatives, and an in-place form for direct construction.

Output:

The default constructor uses the first alternative. That works when the first type is naturally a "zero state", but not when it isn't. std::variant<int, std::string> defaults to int{} == 0, which may or may not be what the design intends.

Constructing from a value is the common case. The compiler picks the matching alternative based on the argument type. When the argument can match more than one alternative through implicit conversions, the rules are subtle, so the in-place forms exist to be explicit.

std::in_place_type<T> and std::in_place_index<N> skip the temporary that a regular constructor would build. They forward the remaining arguments straight to the alternative's constructor, which matters for expensive-to-construct types.

Modifying a Variant

Assigning a new value switches the active alternative. The old value is destroyed automatically, and the new value is constructed in place. There's no manual bookkeeping.

Output:

v.emplace<T>(args...) is the type-aware in-place form: destroy whatever is currently held, construct a T directly from args. It avoids building a temporary even when the constructor takes multiple arguments. For variants holding expensive types like long strings, emplace saves a copy and a move.

A subtle point: even though the active alternative can change, the variant's storage doesn't grow or shrink. The storage is sized for the largest alternative when the variant type is declared, and stays that size for the variant's lifetime. Switching from a std::string to an int doesn't free memory back to the heap; the string buffer's heap memory is freed by the string's destructor when the variant switches, but the variant itself is still at maximum-alternative size on the stack.

Reading a Variant

There are three core ways to read the value out of a variant. Each has a different failure mode.

OperationReturnsIf alternative isn't active
std::holds_alternative<T>(v)boolfalse
std::get<T>(v) / std::get<N>(v)reference to valuethrows std::bad_variant_access
std::get_if<T>(&v) / std::get_if<N>(&v)pointer or nullptrnullptr

std::holds_alternative<T>(v) is the type-only check. It says "is this variant currently holding a T?" without giving access to the value. std::get<T>(v) is the assertive read: it returns a reference if T is active, and throws std::bad_variant_access otherwise. std::get_if<T>(&v) is the safe read: it takes a pointer to the variant and returns a pointer to the contained T, or nullptr if T is not active.

Output:

For most code, std::get_if tests and reads in a single operation. The pointer-or-null result pairs with C++17's if-with-init: if (auto* p = std::get_if<int>(&v)) { ... }.

std::get indexed by position (std::get<0>(v)) is occasionally useful when the same type appears twice (variant allows duplicates), because there's no other way to disambiguate.

Output:

Duplicate alternatives are rarely useful, but index-based access is the only way to distinguish them.

std::visit: Dispatching on the Active Alternative

Reading a variant by checking each holds_alternative in turn works for small variants, but scales poorly. Three alternatives mean three branches. Adding a fourth alternative produces no compiler warning at call sites that missed it, so code drifts out of sync without diagnostics.

std::visit solves this. Pass a callable that can handle every alternative, and the language dispatches to the right overload based on the active alternative. The compiler verifies the visitor covers every type.

Output:

A generic lambda accepts any type, so it handles all three alternatives uniformly. The downside is that running different code per alternative isn't easy: every branch sees the same auto parameter. For per-alternative behavior, use an overload set. The standard pattern uses a small helper called Overloaded:

Output:

Overloaded is a small utility (two lines of code) that turns a brace-list of lambdas into one callable with all of their operator()s. The deduction guide on the second line lets the compiler infer the lambda types automatically. std::visit picks the matching overload from that callable at runtime.

std::visit enforces compile-time exhaustiveness. Forgetting the string overload makes the call fail to compile. Adding a fourth alternative to the variant later breaks every visit site that doesn't handle it at compile time, not at runtime. That is the closed-set property: the compiler keeps the dispatch sites and the set of alternatives in sync.

The variant's discriminator (the hidden index) acts as the input to a jump table that std::visit uses internally. The dispatch cost is comparable to a switch statement in a hand-written tagged union, and usually faster than virtual function dispatch because there's no vtable indirection.

std::visit can also return a value. The return type is deduced from the visitor's return types, and all alternatives must produce the same type (or types that can be unified).

Output:

Every visitor returns a double, so the return type of std::visit is also double. When the visitor returns mixed types that can't be unified, the call won't compile.

std::monostate for Default Construction

The default-constructed variant holds the first alternative, value-initialized. That's fine for types like int or double, but it breaks when the first alternative has no default constructor.

The fix is std::monostate: an empty type that's default-constructible and exists solely to serve as a placeholder. Putting it first as an alternative makes the default-constructed variant explicitly hold "nothing meaningful yet".

Output:

std::monostate is comparable, hashable, and otherwise blank. Its only role is to be a valid "no value chosen" alternative. When a variant needs to distinguish "uninitialized" from any of its real alternatives, place std::monostate first.

An E-Commerce Variant Example

A checkout flow needs a payment method. The set of allowed methods is closed: card, UPI, or cash. Each carries different data. Variant captures the structure exactly, without inheritance or heap allocation.

Output:

The Payment type carries exactly the information each method needs. There's no base class, no virtual function table, and no heap allocation for the payment itself. Adding a new alternative such as Crypto { std::string wallet; double amount; } makes every existing std::visit site stop compiling unless the new overload is added, which is the closed-set property in action. The compiler keeps the type and its consumers in sync.

Variant vs Inheritance vs Union

When a value can be one of several types, three options compete: a std::variant, an inheritance hierarchy with std::unique_ptr<Base>, or a hand-rolled union. They make different trade-offs.

Propertystd::variant<Ts...>Inheritance (unique_ptr<Base>)C-style union
Set of typesClosed, fixed at compile timeOpen, extendableClosed, fixed
StorageInline, on stackHeap-allocatedInline, no tracking
Active alternative trackingAutomatic (hidden index)Via vtable pointerManual (you maintain a tag)
Active-type checkholds_alternative, get_ifdynamic_cast (with RTTI cost)None; reading wrong arm is UB
Dispatchstd::visit, compile-time exhaustiveVirtual functions, runtimeManual switch
Adding a new typeUpdates every visit siteDoesn't update existing call sitesUpdates every read site
Non-trivial destructorsHandled automaticallyHandled automaticallyManual placement new / explicit destructor

The choice depends on whether the set of alternatives is closed (variant), open (inheritance), or operating at the C ABI level (union). For internal domain types, variant usually fits. For plugin systems or polymorphic protocols (extensibility from outside), inheritance fits. For interop with C code or memory-mapped I/O, the raw union remains.

Inheritance is sometimes described as "more flexible" than variant, but that framing hides the trade-off. Variant trades extensibility for compile-time exhaustiveness. Inheritance trades exhaustiveness for extensibility. For a payment method, currency code, or HTTP status family, the set genuinely doesn't change at runtime, and exhaustiveness is the more valuable property.

Memory Layout and Cost

A std::variant<Ts...> reserves storage equal to the largest alternative, plus a small discriminator (usually 4 to 8 bytes). The whole thing lives inline, on the stack or wherever the variant is stored. No heap allocation happens just because a variant exists.

Switching alternatives runs the old destructor and the new constructor inside that storage. For std::variant<int, double, std::string>, the storage is big enough to hold a std::string (typically 24 to 32 bytes on a 64-bit platform with small-string optimization), even when the active alternative is the int. The trade is space for predictability: no allocation when the variant is created, switched, or destroyed (except for the alternatives' own internal allocations, such as the string's heap buffer for long strings).

std::visit compiles to a jump table on the discriminator. For two or three alternatives, compilers often generate code as fast as a hand-written switch. For larger variants, the jump table is constant-time regardless of how many alternatives there are.

Variant is cheap, predictable, and stack-allocated. The cost is that the variant is always the size of its largest alternative, so mixing tiny types with huge ones in one variant wastes space.

Pitfalls

A few common mistakes are worth calling out.

Default construction uses the first alternative. std::variant<int, std::string> defaults to int{} == 0, not "empty". When that's not the intended state, put std::monostate first as an explicit "no value chosen" alternative.

Implicit conversions can pick the wrong alternative. When the argument can match more than one alternative through conversion, the rules are subtle. A common case is bool and int:

Output:

The literal "Wireless Headphones" is const char*, which converts to both std::string and bool. C++17's rules pick bool (because pointer-to-bool is a standard conversion and pointer-to-std::string is a user-defined conversion). The variant ends up holding true instead of the string. The fix is to be explicit: v = std::string("Wireless Headphones") or v.emplace<std::string>("Wireless Headphones").

This particular case was tightened in C++20, where the conversion to bool is rejected when a non-bool alternative also matches. In C++17, the behavior applies.

`std::get<T>(v)` throws on type mismatch. Code that assumes the active alternative without checking is one wrong assignment away from a runtime exception. Use std::get_if or holds_alternative when the active alternative isn't known with certainty.

Variants can hold the same type twice. std::variant<int, int> is legal, with two distinct alternatives at indexes 0 and 1. std::get<int>(v) doesn't compile because the type is ambiguous; the index form is required, std::get<0>(v) or std::get<1>(v). Most code doesn't need this feature, but the compile error when introducing it by accident is unexpected.

`std::visit` requires the visitor to handle every alternative. The compile error from a missing overload is verbose and can be hard to read. The error usually says something about "no matching function for call to '(Overloaded) (alternative)'" with a long template instantiation stack. Read it carefully: the missing overload is the one mentioned in the call site.

A variant can become "valueless by exception". If an exception is thrown while a variant is in the middle of switching alternatives (between destroying the old one and constructing the new one), the variant ends up in a special invalid state where v.valueless_by_exception() returns true and any get throws. This is rare in practice because most assignments don't throw, but the state exists.

Output:

The variant tried to switch to Throws, the constructor threw, and the variant was left in the valueless state. Production code that uses variants with throwing constructors should consider whether the throwing alternative is a fit, or whether a std::monostate first alternative would let the variant recover gracefully.

When to Use std::variant

Use std::variant when a value is exactly one of a closed set of types, where the set is known at compile time and the alternatives carry different data. The clearest signs:

  • The set of alternatives is genuinely fixed (payment method, parse result, message kind).
  • Each alternative has its own associated data, not just a discriminator.
  • Code that handles the value should be exhaustive: every alternative must be covered.
  • Heap allocation per value is undesirable.

For an open hierarchy (plugin system, protocol with future extensions, polymorphic visitor patterns where the set really does grow), inheritance fits better. For "value or nothing" where the type is fixed, use std::optional<T>. For truly type-erased values where the set isn't known at compile time, use std::any.

A switch on an enum class Kind immediately followed by a union access for each case is a variant in disguise. Expressing the design as std::variant<Cases...> removes the bookkeeping.

Interview Questions

Q1: When would you choose `std::variant` over an inheritance hierarchy with `std::unique_ptr<Base>`?

Choose std::variant when the set of alternatives is closed at compile time and you want exhaustiveness checking. The compiler verifies that every std::visit handles every alternative, so adding a new alternative later forces every consumer to be updated. Inheritance is open by design: a new subclass doesn't force existing code to recognize it, which is good for plugins but bad for domain modeling. Variant also stores its value inline, avoiding the heap allocation that polymorphic ownership requires.

Q2: What's the difference between `std::get<T>(v)` and `std::get_if<T>(&v)`?

std::get<T>(v) is the assertive form: it returns a reference to the contained value if T is the active alternative, and throws std::bad_variant_access if not. std::get_if<T>(&v) is the safe form: it takes a pointer to the variant and returns a pointer to the value if T is active, or nullptr otherwise. Use get_if when the active alternative isn't known with certainty, especially with if-with-init: if (auto* p = std::get_if<T>(&v)).

Q3: Why is `std::monostate` useful, and when would you add it as an alternative?

std::monostate is an empty placeholder type used to give a variant a default-constructible "no value chosen" state. When the first natural alternative of a variant isn't default-constructible (or its default value is meaningful and shouldn't be the initial state), put std::monostate first as an alternative. The default-constructed variant then holds monostate, which explicitly represents "nothing meaningful yet" rather than accidentally holding a real value.

Q4: How does `std::visit` enforce exhaustiveness, and why is that valuable?

std::visit requires the visitor to be callable for every alternative of the variant. If even one alternative isn't handled, the call fails to compile. This means that adding a new alternative to a variant later breaks every visit site that doesn't handle the new case, at compile time. The value is that the type system keeps the set of alternatives and the code that dispatches on them in lockstep, which prevents the silent drift you get with switch statements on enums (where adding a new enumerator doesn't force existing switches to be updated).

Q5: What does it mean for a variant to be "valueless by exception", and how does it happen?

A variant becomes valueless by exception when an exception is thrown during alternative switching, after the old value was destroyed but before the new one was fully constructed. In that state, v.valueless_by_exception() is true, v.index() returns std::variant_npos, and any get operation throws. It's rare in practice because most type constructors don't throw, but variants holding types with throwing constructors can hit this state. The standard chose this design over alternatives like reverting to the old value to keep variant lightweight and predictable.

Exercises

Exercise 1: Declare a std::variant<int, double, std::string> and assign each of the three alternative types to it in turn. After each assignment, print the result of v.index().

Expected Output:

<details> <summary>Solution</summary>

</details>

Exercise 2: What does this code print?

Expected Output:

<details> <summary>Solution</summary>

The default-constructed variant holds the first alternative, which is int, value-initialized to 0. v.index() is 0 (the position of int in the template parameter list). std::get<int>(v) returns the contained 0.

</details>

Exercise 3: Use std::get_if to safely read the integer from a std::variant<int, std::string>. If it doesn't hold an integer, print "not an int". Test with two variants: one holding an int and one holding a std::string.

Expected Output:

<details> <summary>Solution</summary>

</details>

Exercise 4: Write a std::visit call that prints a different message based on which alternative is active. The variant should be std::variant<int, double, std::string>. Use the Overloaded helper. Print:

  • For int n: "int: <n>"
  • For double d: "double: <d>"
  • For std::string s: "string: <s>"

Test with one value of each type.

Expected Output:

<details> <summary>Solution</summary>

</details>

Exercise 5: Fix the bug. This code is supposed to print the contained value, but it crashes for the second variant.

Expected Output:

<details> <summary>Solution</summary>

The original code called std::get<int> on a variant holding a std::string, which throws std::bad_variant_access. Using std::visit with an overload set dispatches correctly to the right handler for each alternative.

</details>

Exercise 6: Predict the output, then run it.

Expected Output:

<details> <summary>Solution</summary>

The literal "USB Cable" is const char*. In C++17, const char* converts to both bool (standard conversion) and std::string (user-defined conversion). The variant prefers the standard conversion, so it picks bool and stores true (printed as 1). The fix is v = std::string("USB Cable") to be explicit.

</details>

Exercise 7: Write a Token variant for a small calculator that can hold either a double (a number) or a char (an operator: +, -, *, /). Then write a function evaluate(std::vector<Token>) that takes a sequence like [1.0, '+', 2.0, '*', 3.0] and evaluates it left-to-right (no precedence). Print the result.

Expected Output:

<details> <summary>Solution</summary>

Left-to-right evaluation gives (1 + 2) * 3 = 9. Real calculators apply precedence, which would change the answer to 7.

</details>

Exercise 8: Add a fourth payment method, Crypto { std::string wallet; double amount; }, to the e-commerce variant example earlier in this chapter. Update the describe function so it compiles and prints a line like "Crypto wallet 0x1234, $50 worth" when called with a Crypto value.

Expected Output:

<details> <summary>Solution</summary>

The exercise demonstrates the closed-set property: adding Crypto to the Payment variant forced the describe overload set to be updated, otherwise the call wouldn't compile.

</details>

Quiz

std::variant Quiz

10 quizzes