C++ std::variant vs union: Type Safety, Overhead and When to Use Each

Key takeaways

A union saves space, but reading the wrong member is undefined behavior and non-trivial members need manual construction and destruction. The post compares both for safety, size and speed, lists common mistakes with each, and shows how to replace a tagged union with std::variant.

Two Ways to Store One of Several Types

Both std::variant and union store one of several types (sum types), but with different safety guarantees.

// std::variant (C++17, type-safe)
std::variant<int, double, std::string> value = 42;
int x = std::get<int>(value);  // Runtime type check
// union (C, unsafe)
union Data {
    int i;
    double d;
    char c;
};
Data data;
data.i = 42;
double d = data.d;  // Wrong type! UB

Key difference: std::variant tracks the active type and checks it at runtime; union doesn’t track type, requiring manual management.

Both are “sum types”: a value is exactly one of a fixed set of alternatives, and the storage is shared between them, so the size is roughly that of the largest member rather than the sum of all of them. The difference is who remembers which alternative is live. A union is raw storage with several names; the language rule is that only the member most recently written is “active”, and reading any other member is undefined behavior (C allows reading through another member as type punning; C++ does not). std::variant stores a small discriminator next to the storage and consults it on every checked access, and it also runs constructors and destructors when the alternative changes. That second job is the one people underestimate: with a std::string alternative, switching to int must destroy the string, and a raw union will not do that for you.


std::variant

Basic Usage

#include <variant>
#include <iostream>
int main() {
    std::variant<int, double, std::string> value;
    
    // Store int
    value = 42;
    cout << std::get<int>(value) << endl;  // 42
    
    // Store string
    value = std::string("hello");
    cout << std::get<std::string>(value) << endl;  // hello
    
    // Store double
    value = 3.14;
    cout << std::get<double>(value) << endl;  // 3.14
}

Output:

42
hello
3.14

Each assignment destroys the previous alternative and constructs the new one. A default-constructed std::variant holds a value-initialized first alternative — here an int of 0 — so the first type must be default-constructible; if none of your alternatives is, put std::monostate first to represent “empty”. Assignment also picks the alternative by overload resolution, which has one notorious trap: before C++20, std::variant<bool, std::string> v = "text"; chose bool, because a string literal converts to bool by a standard conversion and that beats the user-defined conversion to std::string. C++20 (P0608) fixed this for most compilers’ current standard libraries, but in code that must build as C++17, pass std::string("text") explicitly as the examples here do.

Type Checking

std::variant<int, double, std::string> value = 42;
// Check active index
cout << value.index() << endl;  // 0 (int is first type)
// holds_alternative
if (std::holds_alternative<int>(value)) {
    cout << "It's an int" << endl;
}
// Wrong type access → exception
try {
    auto s = std::get<std::string>(value);  // Throws!
} catch (const std::bad_variant_access& e) {
    cerr << "Bad access: " << e.what() << endl;
}

Output:

It's an int
Bad access: std::get: wrong index for variant

The text of e.what() is implementation-specific (this is libstdc++‘s wording; MSVC and libc++ differ), so don’t parse it. The more important point is when to use each access form. std::get with a try/catch is appropriate when the wrong type means a bug or corrupted input; when checking the type is part of normal control flow, holds_alternative or get_if (next section) avoid exceptions entirely. Type-based std::get<T> only compiles if T appears exactly once in the variant’s list — std::variant<int, int> is legal, but then you must use indexes.

Safe Access with get_if

std::variant<int, double, std::string> value = 42;
// Pointer access (returns nullptr on wrong type)
if (int* ptr = std::get_if<int>(&value)) {
    cout << "int: " << *ptr << endl;
}
if (std::string* ptr = std::get_if<std::string>(&value)) {
    cout << "string: " << *ptr << endl;
} else {
    cout << "Not a string" << endl;
}

Output:

int: 42
Not a string

std::visit (Exhaustive Handling)

std::variant<int, double, std::string> value = 3.14;
std::visit([](auto&& arg) {
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int>) {
        cout << "int: " << arg << endl;
    } else if constexpr (std::is_same_v<T, double>) {
        cout << "double: " << arg << endl;
    } else if constexpr (std::is_same_v<T, std::string>) {
        cout << "string: " << arg << endl;
    }
}, value);

Output:

double: 3.14

Key: std::visit calls the visitor with whichever alternative is active, and the visitor must be callable with every alternative — but an if constexpr chain like this one compiles even when a branch is missing (see Issue 3 below).

The exhaustiveness guarantee people attribute to std::visit comes from a different visitor style: an overload set, where each alternative has its own function and a missing one is a compile error.

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

std::visit(overloaded{
    [](int i)                { cout << "int: " << i << endl; },
    [](double d)             { cout << "double: " << d << endl; },
    [](const std::string& s) { cout << "string: " << s << endl; },
}, value);

If you add bool to the variant later, this call stops compiling until a bool overload is added (or, more subtly, until an existing overload accepts bool through a conversion — int would, so watch for that). The generic-lambda-plus-if constexpr style is convenient when most alternatives share code; the overload style is safer when every alternative needs distinct handling. Performance-wise, std::visit typically compiles to a jump table indexed by the active alternative, comparable to a switch on a hand-written tag.


union

Basic Usage

union Data {
    int i;
    double d;
    char c;
};
int main() {
    Data data;
    
    data.i = 42;
    cout << data.i << endl;  // 42
    
    data.d = 3.14;
    cout << data.d << endl;  // 3.14
    
    // ❌ data.i is now garbage (d overwrote it)
    cout << data.i << endl;  // Garbage
}

Output:

42
3.14
-1717986918  // Garbage (UB)

Tagged Union (Manual Type Tracking)

enum class DataType { INT, DOUBLE, STRING };
struct TaggedData {
    DataType type;
    union {
        int i;
        double d;
        char str[32];
    } value;
};
int main() {
    TaggedData data;
    
    // Store int
    data.type = DataType::INT;
    data.value.i = 42;
    
    // Access with type check
    if (data.type == DataType::INT) {
        cout << data.value.i << endl;  // 42
    }
    
    // Store string
    data.type = DataType::STRING;
    strcpy(data.value.str, "hello");
    
    if (data.type == DataType::STRING) {
        cout << data.value.str << endl;  // hello
    }
}

Output:

42
hello

Key: Manual type tracking is error-prone—easy to forget to update type field.

Notice how much discipline this relies on. Every write must set the tag and the member together, every read must check the tag first, and nothing enforces either — the compiler is equally happy with data.value.i when the tag says STRING. strcpy into a 32-byte buffer adds a second hazard: anything longer than 31 characters overflows into whatever follows the struct. The pattern is still common in C and in code that crosses a C ABI, so it is worth recognizing, but in C++ it is usually encapsulated in a class whose constructors and accessors are the only code allowed to touch the union.

union Limitations

// ❌ union cannot hold non-trivial types
union Bad {
    int i;
    std::string s;  // Error! std::string has constructor/destructor
};
// ✅ std::variant can hold any type
std::variant<int, std::string> good = std::string("hello");

Features, Safety, and Memory Layout Compared

Feature Comparison

Featurestd::variantunion
Type safety✅ Automatic tracking❌ Manual tracking
Exception on wrong access✅ Yes❌ No (UB)
Non-trivial types✅ Yes (string, vector)❌ Only with manual placement new and destructor calls
std::visit✅ Yes❌ No
Memory overhead1 byte (type index)0 bytes
C compatibility❌ No✅ Yes
Constructors/Destructors✅ Called❌ Not called

Safety Comparison

// std::variant: Safe
std::variant<int, double> v = 42;
try {
    auto d = std::get<double>(v);  // Throws bad_variant_access
} catch (const std::bad_variant_access&) {
    cout << "Wrong type" << endl;
}
// union: Unsafe
union U { int i; double d; };
U u;
u.i = 42;
double d = u.d;  // UB! Reading inactive member

Memory Layout

// std::variant
std::variant<int, double> v;  // sizeof: 16 bytes (8 for double + 8 for alignment/index)
// union
union U { int i; double d; };  // sizeof: 8 bytes (max of members)

Key: std::variant has small overhead (1 byte + alignment) for type index.

“1 byte” is the index itself; the real cost is set by alignment. The index follows the storage, and the whole object is padded to the alignment of the most-aligned alternative, so a variant of 8-byte-aligned types grows by 8 bytes, not 1. In an array of a million elements, that is 50% more memory for variant<int, double> than for the bare union — which is the one scenario where the raw union’s size advantage is real. A hand-written tagged union pays the same padding for its tag unless you pack the tag somewhere clever (spare bits of a pointer, a separate array of tags), so for most comparisons between tagged unions and variants, the size is the same.

There is one more state a union does not have: a variant can become valueless_by_exception(). If assigning a new alternative throws partway through (for example, a std::string copy that fails to allocate after the old alternative was destroyed), the variant holds nothing, index() returns std::variant_npos, and std::visit throws bad_variant_access. It is rare and mostly relevant for types whose move constructors can throw, but it explains an otherwise mysterious exception.


When to Use Each

Use std::variant When:

  • Type safety is important
  • Storing non-trivial types (string, vector)
  • Building modern C++ APIs
  • Need std::visit for exhaustive handling
// Result type
std::variant<int, std::string> parseValue(const std::string& input) {
    if (isNumber(input)) {
        return std::stoi(input);
    }
    return input;
}

Use union When:

  • C API interop
  • Legacy code maintenance
  • Extreme memory constraints
  • Only trivial types (int, float, char)
// C API
struct Packet {
    enum { INT, FLOAT } type;
    union {
        int i;
        float f;
    } data;
};

Inactive Members, Stale Tags, and Wrong get Indices

Reading Inactive union Member

// ❌ Undefined behavior
union U { int i; double d; };
U u;
u.i = 42;
cout << u.d << endl;  // UB! Reading inactive member

Fix: Use std::variant or track active type manually.

Forgetting to Update Type Tag

// ❌ Type tag out of sync
struct TaggedData {
    enum { INT, DOUBLE } type;
    union { int i; double d; } value;
};
TaggedData data;
data.type = INT;
data.value.d = 3.14;  // Forgot to update type!
if (data.type == INT) {
    cout << data.value.i << endl;  // Garbage!
}

Fix: Use std::variant to avoid manual tracking.

Wrong std::get Index

// ❌ Wrong index
std::variant<int, double, std::string> v = 3.14;
auto x = std::get<0>(v);  // Throws! (0 is int, but v holds double)
// ✅ Correct index or type
auto x1 = std::get<1>(v);  // OK (1 is double)
auto y = std::get<double>(v);  // OK (type-based)

Index-based access couples code to the order of the type list, so inserting a new alternative in the middle silently shifts every index. Prefer type-based access or visitors, and reserve indexes for variants with repeated types. An out-of-range index (std::get<5> on a three-alternative variant) is at least a compile error; the dangerous case is an index that is valid but no longer means what the code assumed.


Result Types, State Machines, and JSON Values

Result Type

template<typename T, typename E>
using Result = std::variant<T, E>;
Result<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::string("Division by zero");
    }
    return a / b;
}
int main() {
    auto result = divide(10, 2);
    
    std::visit([](auto&& value) {
        using T = std::decay_t<decltype(value)>;
        if constexpr (std::is_same_v<T, int>) {
            cout << "Success: " << value << endl;
        } else {
            cout << "Error: " << value << endl;
        }
    }, result);
}

Output:

Success: 5

std::variant<T, E> works as a result type, but it has a weakness this example hides: if T and E are the same type — Result<std::string, std::string> — type-based access and the if constexpr dispatch both break, because you can no longer tell success from failure by type. C++23’s std::expected<T, E> is the purpose-built version: it names the two cases (has_value(), value(), error()), works when the types coincide, and offers and_then/transform for chaining. Use it where available; the variant form remains a reasonable fallback for C++17 codebases, ideally with a wrapper type for the error.

State Machine

struct Idle {};
struct Running { int progress; };
struct Completed { std::string result; };
using State = std::variant<Idle, Running, Completed>;
void processState(const State& state) {
    std::visit([](auto&& s) {
        using T = std::decay_t<decltype(s)>;
        if constexpr (std::is_same_v<T, Idle>) {
            cout << "Idle" << endl;
        } else if constexpr (std::is_same_v<T, Running>) {
            cout << "Running: " << s.progress << "%" << endl;
        } else if constexpr (std::is_same_v<T, Completed>) {
            cout << "Completed: " << s.result << endl;
        }
    }, state);
}
int main() {
    State s1 = Idle{};
    State s2 = Running{50};
    State s3 = Completed{"Done"};
    
    processState(s1);
    processState(s2);
    processState(s3);
}

Output:

Idle
Running: 50%
Completed: Done

The state machine is where variants shine most, in my experience. With an enum plus separate fields, progress and result exist in every state and it is easy to read result while still Running; with a variant, each state carries only its own data, so an invalid combination cannot be represented at all. Transitions become assignments (state = Completed{"Done"};), which also destroy the old state’s data automatically. Pairing this with the overload-set visitor turns “added a new state, forgot to handle it somewhere” into a compile error — the bug class that enum-and-switch code usually catches only through -Wswitch warnings.

JSON Value

struct JsonNull {};
struct JsonValue;  // a type alias cannot refer to itself, so wrap the variant in a struct
using JsonArray  = std::vector<JsonValue>;
using JsonObject = std::map<std::string, JsonValue>;
struct JsonValue : std::variant<JsonNull, bool, int, double,
                                std::string, JsonArray, JsonObject> {
    using variant::variant;  // inherit the converting constructors
};
void printJson(const JsonValue& value) {
    std::visit([](auto&& v) {
        using T = std::decay_t<decltype(v)>;
        if constexpr (std::is_same_v<T, JsonNull>) {
            cout << "null";
        } else if constexpr (std::is_same_v<T, bool>) {
            cout << (v ? "true" : "false");
        } else if constexpr (std::is_same_v<T, int>) {
            cout << v;
        } else if constexpr (std::is_same_v<T, double>) {
            cout << v;
        } else if constexpr (std::is_same_v<T, std::string>) {
            cout << "\"" << v << "\"";
        }
        // ... handle array/object ...
    }, value);
}
int main() {
    JsonValue v1 = 42;
    JsonValue v2 = std::string("hello");
    JsonValue v3 = true;
    
    printJson(v1);  // 42
    cout << ", ";
    printJson(v2);  // "hello"
    cout << ", ";
    printJson(v3);  // true
}

Output:

42, "hello", true

A JSON value is recursive — arrays and objects contain JSON values — and that is exactly what a plain using alias cannot express: the alias is not declared until its own definition ends, so writing std::vector<JsonValue> inside it fails with 'JsonValue' was not declared in this scope. The usual workaround, used above, is to forward-declare a struct and derive it from the variant. It relies on two things: std::vector of an incomplete type is allowed since C++17 (std::map works in practice with the major standard libraries but is not formally guaranteed), and std::visit accepting a class derived from std::variant, which was standardized as a defect fix (P2162) and is supported by current GCC, Clang, and MSVC. Libraries such as nlohmann/json avoid the question by using their own tagged storage. Note too that JsonValue v3 = true; is correct here only because bool is listed before int and the argument is already a bool; JsonValue v = "hello"; would have selected bool before C++20, as described earlier.


Error Handling, Commands, and Protocol Messages

Error Handling

template<typename T>
using Result = std::variant<T, std::string>;
Result<int> parseInt(const std::string& str) {
    try {
        return std::stoi(str);
    } catch (...) {
        return std::string("Invalid integer");
    }
}
int main() {
    auto result = parseInt("123");
    
    if (int* value = std::get_if<int>(&result)) {
        cout << "Parsed: " << *value << endl;
    } else {
        cout << "Error: " << std::get<std::string>(result) << endl;
    }
}

Output:

Parsed: 123

std::stoi has two failure modes that this catch-all hides: std::invalid_argument for input like "abc" and std::out_of_range for values beyond int. It also accepts partial input — std::stoi("12abc") returns 12 without complaint. For parsing that must reject trailing garbage, std::from_chars (C++17) reports exactly how many characters it consumed and does not throw, which fits the result-type style better than exceptions converted into strings.

Command Pattern

struct CreateCommand { std::string name; };
struct UpdateCommand { int id; std::string data; };
struct DeleteCommand { int id; };
using Command = std::variant<CreateCommand, UpdateCommand, DeleteCommand>;
void executeCommand(const Command& cmd) {
    std::visit([](auto&& c) {
        using T = std::decay_t<decltype(c)>;
        if constexpr (std::is_same_v<T, CreateCommand>) {
            cout << "Creating: " << c.name << endl;
        } else if constexpr (std::is_same_v<T, UpdateCommand>) {
            cout << "Updating: " << c.id << endl;
        } else if constexpr (std::is_same_v<T, DeleteCommand>) {
            cout << "Deleting: " << c.id << endl;
        }
    }, cmd);
}
int main() {
    executeCommand(CreateCommand{"user"});
    executeCommand(UpdateCommand{1, "new_data"});
    executeCommand(DeleteCommand{2});
}

Output:

Creating: user
Updating: 1
Deleting: 2

Network Protocol

struct ConnectPacket { std::string host; int port; };
struct DataPacket { std::vector<uint8_t> payload; };
struct DisconnectPacket { int reason; };
using Packet = std::variant<ConnectPacket, DataPacket, DisconnectPacket>;
void handlePacket(const Packet& packet) {
    std::visit([](auto&& p) {
        using T = std::decay_t<decltype(p)>;
        if constexpr (std::is_same_v<T, ConnectPacket>) {
            cout << "Connect to " << p.host << ":" << p.port << endl;
        } else if constexpr (std::is_same_v<T, DataPacket>) {
            cout << "Data: " << p.payload.size() << " bytes" << endl;
        } else if constexpr (std::is_same_v<T, DisconnectPacket>) {
            cout << "Disconnect: reason " << p.reason << endl;
        }
    }, packet);
}
int main() {
    handlePacket(ConnectPacket{"localhost", 8080});
    handlePacket(DataPacket{{0x01, 0x02, 0x03}});
    handlePacket(DisconnectPacket{0});
}

Output:

Connect to localhost:8080
Data: 3 bytes
Disconnect: reason 0

A variant is an in-memory representation, not a wire format. Its layout is implementation-defined, so you cannot memcpy a Packet onto a socket or read one from a file; decoding bytes into the right alternative (usually by a type byte at the start of the frame) and encoding them back is still your job. This is precisely where legacy code tends to use a raw union over a packed struct — and where a variant plus explicit parse/serialize functions is safer, because the union version silently depends on compiler padding and byte order.


A Hand-Written Tagged Union in Full

Basic Tagged Union

enum class ValueType { INT, DOUBLE, STRING };
struct Value {
    ValueType type;
    union {
        int i;
        double d;
        char str[32];
    } data;
    
    // Helper methods
    static Value makeInt(int value) {
        Value v;
        v.type = ValueType::INT;
        v.data.i = value;
        return v;
    }
    
    static Value makeDouble(double value) {
        Value v;
        v.type = ValueType::DOUBLE;
        v.data.d = value;
        return v;
    }
    
    void print() const {
        switch (type) {
            case ValueType::INT:
                cout << "int: " << data.i << endl;
                break;
            case ValueType::DOUBLE:
                cout << "double: " << data.d << endl;
                break;
            case ValueType::STRING:
                cout << "string: " << data.str << endl;
                break;
        }
    }
};
int main() {
    Value v1 = Value::makeInt(42);
    Value v2 = Value::makeDouble(3.14);
    
    v1.print();
    v2.print();
}

Output:

int: 42
double: 3.14

Key: Manual type tracking is verbose and error-prone.


Non-Trivial Members, Stale Tags, and Missing visit Cases

union with Non-Trivial Types

// ❌ Error: union cannot hold std::string
union Bad {
    int i;
    std::string s;  // Compile error!
};
// ✅ std::variant can hold any type
std::variant<int, std::string> good = std::string("hello");

Forgetting Type Tag

Same as the stale type tag mistake above: the tag and the active member drift apart, and nothing detects it. The fix is either std::variant or a class that keeps the union private and sets tag and member in one place.

Missing std::visit Case

// ❌ Forgot to handle string case
std::variant<int, double, std::string> v = std::string("hello");
std::visit([](auto&& arg) {
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int>) {
        cout << "int: " << arg << endl;
    } else if constexpr (std::is_same_v<T, double>) {
        cout << "double: " << arg << endl;
    }
    // Missing string case!
}, v);

Fix: Use exhaustive if constexpr or overloaded visitor.

This compiles cleanly and prints nothing for the string — the lambda is instantiated for std::string, every if constexpr condition is false, and the body is empty. A common defensive idiom is a final else that fails compilation for unhandled types: else static_assert(always_false_v<T>, "unhandled alternative");, where always_false_v is a dependent false (a plain static_assert(false) only became allowed in this position with C++23). The overload-set visitor shown in the std::visit section gets the same effect with less ceremony.


Size and Access Speed

Memory Size

// std::variant
std::variant<int, double> v;  // 16 bytes (8 for double + 8 for alignment/index)
// union
union U { int i; double d; };  // 8 bytes (max of members)

Key: std::variant adds 1 byte for type index (plus alignment padding).

Access Speed

// Illustrative only (not a reproducible benchmark): 10,000,000 accesses
// std::variant: 25ms (includes type check)
// union: 20ms (no check)

Key: std::variant is slightly slower due to type checking, but difference is negligible.

The fair comparison is with a tagged union, because an untagged union access that skips the check is only “faster” when the program already knows the type some other way. A tagged union also branches on its tag, just as std::get_if or std::visit branches on the index, and optimizers generate very similar code for both. Historically, some standard library implementations produced slower std::visit code for small variants than a hand-written switch, which is the source of older benchmark posts claiming large gaps; current compilers have largely closed that. If a hot loop matters, measure with your compiler — and consider whether a data layout change (separate arrays per type) would help more than either representation.


Migration from union to std::variant

Before (union)

enum class Type { INT, STRING };
struct Data {
    Type type;
    union {
        int i;
        char str[32];
    } value;
};
Data data;
data.type = Type::INT;
data.value.i = 42;
if (data.type == Type::INT) {
    cout << data.value.i << endl;
}

After (std::variant)

using Data = std::variant<int, std::string>;
Data data = 42;
if (int* ptr = std::get_if<int>(&data)) {
    cout << *ptr << endl;
}
// Or use std::visit
std::visit([](auto&& value) {
    cout << value << endl;
}, data);

Benefits:

  • Type-safe
  • No manual type tracking
  • Supports non-trivial types
  • Exhaustive handling with std::visit

Migration is rarely a single search-and-replace, because the union’s users depend on the tag field directly. A path that works incrementally is: first wrap the tagged union in a class with accessor functions and route all reads and writes through them; then change the class’s internals to a std::variant, keeping the accessor interface; and finally replace switch (x.type) sites with visitors one at a time. The step that catches real bugs is the first one — centralizing access tends to reveal the places where code read a member without checking the tag. Keep the union only at the boundary where a C API demands that exact layout, converting to and from the variant right there.


Choosing Between variant and union

  1. std::variant: Type-safe sum type with automatic type tracking
  2. union: Unsafe sum type requiring manual type tracking
  3. Use std::variant: For modern C++ code
  4. Use union: For C API interop, legacy code
  5. std::visit: Exhaustive handling for std::variant
  6. Performance: union slightly smaller/faster, but std::variant overhead is minimal

Decision Matrix

ScenarioRecommendation
Modern C++ APIstd::variant
C API interopunion
Non-trivial typesstd::variant (a union needs manual lifetime management)
Type safety criticalstd::variant
Legacy codebaseunion (migrate to std::variant)
Extreme memory constraintsunion (measure first)

Migrating a union to variant

  • Replace union with std::variant
  • Remove manual type tag
  • Use std::visit for exhaustive handling
  • Use std::get_if for safe access
  • Test all code paths

Frequently Asked Questions (FAQ)

Q. Why won’t my union with a std::string member compile?

A. Since C++11 a union may hold non-trivial members, but then its default constructor, destructor and copy operations are implicitly deleted. You have to construct the active member with placement new, call its destructor explicitly before switching members, and track which member is active yourself. std::variant does all of that bookkeeping for you, which is the main reason to prefer it for anything beyond trivial types.