A union of an int, a double and a 10-character array is usually described as taking the size of its largest member: 10 bytes. Measured with sizeof, it takes 16. That gap is a small example of why unions are worth learning properly. They are the one C type where the rules about memory, alignment and reading bytes you did not write all show up at once.
This guide covers how a union is laid out, how to declare and initialize one, what the language actually allows when you read a member you did not write (C and C++ answer differently), the pointer-cast shortcut that the optimizer breaks, and the tagged-union pattern, finishing with how C++’s std::variant does the same job with checks C cannot make. Every C program was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 and Clang 18.1.3 under both -std=c11 and -std=c17, with -Wall -Wextra -pedantic, and the C++ programs with g++ and clang++ under -std=c++17 and -std=c++20 (std::bit_cast under C++20 only). The working examples produce zero warnings and run clean under AddressSanitizer and UndefinedBehaviorSanitizer; the deliberately broken ones are shown with the diagnostics they actually produced. All output is captured verbatim.
What Is a Union in C?
A union in C is a user-defined type whose members all share the same block of memory, starting at the same address. It is large enough to hold its largest member, rounded up to the strictest alignment among its members, and it holds one member’s value at a time: writing any member overwrites the bytes of the others.
The syntax is identical to a structure’s except for the keyword, and the difference is entirely in the layout. A structure gives each member its own storage, laid out one after another; a union overlaps them. The cppreference union page states the formal rules, and our guide to structures in C covers the struct side of the comparison, including padding, in depth.
How Big Is a Union? Measuring the Layout
The same three members, declared once as a struct and once as a union:
/* layout.c - the same three members as a struct and as a union */
#include <stddef.h>
#include <stdio.h>
struct S { int i; double d; char c[10]; };
union U { int i; double d; char c[10]; };
int main(void)
{
printf("struct S: sizeof = %zu\n", sizeof(struct S));
printf(" offsets: i=%zu d=%zu c=%zu\n",
offsetof(struct S, i), offsetof(struct S, d), offsetof(struct S, c));
printf("union U: sizeof = %zu\n", sizeof(union U));
printf(" offsets: i=%zu d=%zu c=%zu\n",
offsetof(union U, i), offsetof(union U, d), offsetof(union U, c));
printf("largest member: %zu bytes, alignment: %zu\n",
sizeof ((union U *)0)->c, _Alignof(union U));
return 0;
}
Output:
struct S: sizeof = 32
offsets: i=0 d=8 c=16
union U: sizeof = 16
offsets: i=0 d=0 c=0
largest member: 10 bytes, alignment: 8
Every member of the union sits at offset 0. The union is 16 bytes, not 10, because a union must be usable in an array. If union U were 10 bytes, the second element of union U arr[2] would start at byte 10, and its double would not be on an 8-byte boundary. So the compiler adds 6 bytes of trailing padding to make the size a multiple of the strictest alignment, which here is the 8 bytes a double needs on x86-64 (the guide to data types in C lists typical sizes). The struct, with each member in its own bytes and padding before d and after c, is exactly twice as large.
(sizeof ((union U *)0)->c is not a null dereference: sizeof never evaluates its operand, so it only asks the compiler for the member’s type.)
Declaring, Initializing and Accessing a Union
A union is declared like a struct, and in C the union keyword is part of the type’s name. Writing the tag alone does not compile:
union Employee {
int age;
long salary;
};
Employee employee; /* not valid C */
error: unknown type name 'Employee'; use 'union' keyword to refer to the type (GCC 13.3)
error: must use 'union' tag to refer to type 'Employee' (Clang 18.1.3)
Write union Employee employee;, or give the type a shorter name with typedef union { … } Employee;, the same idiom used for structs.
Members are accessed with . on a union object and -> through a pointer, which must of course point at a real union object. A pointer declared at file scope and never assigned is a null pointer, and employee->age through it crashed with a segmentation fault in testing.
Initialization has one rule worth memorizing: a brace initializer without a designator initializes the first member. This program shows it, along with the byte view a union gives of a value:
/* bytes.c - the byte order a union exposes, and the first-member rule */
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
union Word {
uint32_t value;
unsigned char bytes[4];
};
union Mixed {
int i;
float f;
};
int main(void)
{
union Word w = { .value = 0x12345678 };
printf("0x%08" PRIX32 " in memory, byte by byte:", w.value);
for (int k = 0; k < 4; k++)
printf(" %02X", w.bytes[k]);
printf("\n");
printf("0x%08" PRIX32 " by shifting, low first: ", w.value); /* same on every CPU */
for (int k = 0; k < 4; k++)
printf(" %02X", (unsigned)((w.value >> (8 * k)) & 0xFFu));
printf("\n");
union Mixed a = { 3.75 }; /* initializes the FIRST member, i */
union Mixed b = { .f = 3.75f }; /* designated: initializes f */
printf("a.i = %d, b.f = %g\n", a.i, b.f);
return 0;
}
Output:
0x12345678 in memory, byte by byte: 78 56 34 12
0x12345678 by shifting, low first: 78 56 34 12
a.i = 3, b.f = 3.75
{ 3.75 } went into the int member and was truncated to 3. The two compilers disagreed about whether that deserved a warning:
warning: implicit conversion from 'double' to 'int' changes value from 3.75 to 3 [-Wliteral-conversion] (Clang, default)
GCC 13.3 said nothing under -Wall -Wextra -pedantic. It warns only when -Wconversion is added. Use designated initializers (.f = 3.75f) for unions and the question never comes up.
The first two output lines show something the union exposes that the value itself does not: byte order. On x86-64 the least significant byte, 78, is stored first. A big-endian machine would print 12 34 56 78 on the first line. The second line uses shifts and bitwise operators, which work on values rather than memory, so it prints the same on every CPU. Code that uses a union to split a value into bytes or into two halves (a common tutorial suggestion) is therefore tied to the byte order of the machine it was written on.
Union vs Struct in C
| Aspect | Struct | Union |
|---|---|---|
| Member storage | Each member has its own bytes | All members share the same bytes |
| Offset of every member | Increasing, in declaration order | 0 |
| Size (measured above) | Sum of members plus padding: 32 | Largest member, rounded up to alignment: 16 |
| Members holding a value at once | All | One: the last one written |
| Brace initializer without designators | Initializes members in order | Initializes the first member only |
| Typical use | A record with several attributes | A value that can be one of several types |
The last row is the practical difference. A struct describes something that has an ID and a name and a price. A union describes something that is an integer or a float or a string, and it needs something else to record which. That is the tagged union covered below.
Reading a Member You Did Not Write: Type Punning Rules
Writing one member and reading another is called type punning. The rules differ between C and C++, and it is easy to learn one language’s rule and assume it holds in the other.
In C it is defined behavior. Since C99 Technical Corrigendum 3, the standard says the bytes of the stored value are reinterpreted as the type of the member being read. They are not “garbage”: reading the uint32_t member after storing 3.14f in the float member gives 0x4048F5C3 every time, which is the IEEE 754 encoding of 3.14f. There are two caveats:
- If the member you read is larger than the one last written, the extra bytes are unspecified.
- Some bit patterns are not valid values of the type you read. These are called trap representations; common types on x86-64 have none, but the standard allows them.
In C++ it is undefined behavior. Only the member most recently written, the active member, may be read. Compilers usually generate the same code as C, so the difference is invisible at run time. It becomes visible when the compiler evaluates the code itself. Here is the same union read inside a constexpr function:
// cxx_union.cpp - the same union punning, evaluated at compile time
#include <cstdint>
union FloatBits {
float f;
std::uint32_t u;
};
constexpr std::uint32_t bits_of(float x)
{
FloatBits fb{};
fb.f = x;
return fb.u; // reads the member that was not written last
}
constexpr std::uint32_t pi_bits = bits_of(3.14f);
int main() { return static_cast<int>(pi_bits & 1u); }
Both compilers reject it, because constant evaluation is required to diagnose undefined behavior:
error: accessing 'FloatBits::u' member instead of initialized 'FloatBits::f' member in constant expression (g++)
note: read of member 'u' of union with active member 'f' is not allowed in a constant expression (clang++)
The portable way to read an object’s bits, valid in both languages, is memcpy into an object of the target type. C++20 adds std::bit_cast, which does the same thing and also works at compile time:
// bitcast.cpp - the C++20 way to read an object's bits
#include <bit>
#include <cstdint>
#include <cstdio>
int main()
{
constexpr std::uint32_t pi_bits = std::bit_cast<std::uint32_t>(3.14f);
std::printf("bit_cast: 0x%08X\n", static_cast<unsigned>(pi_bits));
}
Output:
bit_cast: 0x4048F5C3
The Pointer Cast Is Not a Union: Strict Aliasing
A common shortcut for reading a float’s bits skips the union and casts the pointer instead: *(uint32_t *)&f. This breaks C’s strict aliasing rule, which lets the compiler assume that pointers to unrelated types (here int32_t * and float *) never refer to the same memory. The program below reads the same bits three ways and then calls a function that writes through both kinds of pointer to one object:
/* punning.c - reading a float's bits: three ways */
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
union FloatBits {
float f;
uint32_t u;
};
/* Pointer cast: breaks the strict aliasing rule when *i and *f overlap. */
__attribute__((noinline))
static int32_t store_both(int32_t *i, float *f)
{
*i = 1;
*f = 0.0f;
return *i; /* the compiler may assume *f did not change *i */
}
int main(void)
{
float x = 3.14f;
union FloatBits fb = { .f = x }; /* 1. union: defined in C */
printf("union : 0x%08" PRIX32 "\n", fb.u);
uint32_t bits; /* 2. memcpy: defined in C and C++ */
memcpy(&bits, &x, sizeof bits);
printf("memcpy: 0x%08" PRIX32 "\n", bits);
int32_t word; /* 3. pointer cast: undefined */
printf("alias : %" PRId32 "\n", store_both(&word, (float *)&word));
return 0;
}
(__attribute__((noinline)) is a GCC and Clang extension that keeps the function separate, so the optimizer cannot see that both arguments are the same address.)
store_both writes 1, then writes 0.0f over the same four bytes, then reads them back. 0.0f is all zero bits, so the correct answer is 0:
| Build | union | memcpy | alias |
|---|---|---|---|
GCC 13.3 -O0 | 0x4048F5C3 | 0x4048F5C3 | 0 |
GCC 13.3 -O2 | 0x4048F5C3 | 0x4048F5C3 | 1 |
Clang 18.1.3 -O0 | 0x4048F5C3 | 0x4048F5C3 | 0 |
Clang 18.1.3 -O2 | 0x4048F5C3 | 0x4048F5C3 | 1 |
GCC 13.3 -O2 -fno-strict-aliasing | 0x4048F5C3 | 0x4048F5C3 | 0 |
With optimization on, both compilers returned 1: they assumed the float store could not change an int32_t, and returned the 1 they had just written without reading memory again. The union and memcpy lines are correct in every build. No build produced a warning. Neither sanitizer build caught the bug either:
- GCC with AddressSanitizer and UndefinedBehaviorSanitizer still printed 1 and reported nothing.
- Clang with UndefinedBehaviorSanitizer printed 0, also without a report; the instrumentation changed the generated code enough to hide the bug.
A bug that disappears under the sanitizer is the hardest kind to find. The rule of thumb that follows: to reinterpret bytes, use a union (C only), memcpy (C and C++) or std::bit_cast (C++20). Never cast the pointer.
Tagged Unions: Knowing Which Member Is Valid
A union does not record which member was written last. Real code therefore pairs it with a tag, an enum saying which member currently holds a value. This is the pattern behind interpreter values, JSON nodes, message types and event queues:
/* tagged.c - a tagged union: one value that is an int, a double or a string */
#include <stdio.h>
enum Kind { VAL_INT, VAL_DOUBLE, VAL_STRING };
struct Value {
enum Kind kind; /* the tag: which member is valid */
union { /* anonymous union (C11) */
long i;
double d;
const char *s;
};
};
struct AllFields { /* the same data without a union */
enum Kind kind;
long i;
double d;
const char *s;
};
static void print_value(const struct Value *v)
{
switch (v->kind) {
case VAL_INT: printf("int %ld\n", v->i); break;
case VAL_DOUBLE: printf("double %g\n", v->d); break;
case VAL_STRING: printf("string %s\n", v->s); break;
}
}
int main(void)
{
struct Value vals[] = {
{ .kind = VAL_INT, .i = 42 },
{ .kind = VAL_DOUBLE, .d = 2.5 },
{ .kind = VAL_STRING, .s = "hello" },
};
for (size_t k = 0; k < sizeof vals / sizeof vals[0]; k++)
print_value(&vals[k]);
printf("sizeof(struct Value) = %zu\n", sizeof(struct Value));
printf("sizeof(struct AllFields) = %zu\n", sizeof(struct AllFields));
const size_t n = 1000000;
printf("for %zu values: %zu MB vs %zu MB\n", n,
n * sizeof(struct Value) / 1000000,
n * sizeof(struct AllFields) / 1000000);
struct Value wrong = { .kind = VAL_INT, .d = 2.5 }; /* tag and data disagree */
print_value(&wrong);
return 0;
}
Output:
int 42
double 2.5
string hello
sizeof(struct Value) = 16
sizeof(struct AllFields) = 32
for 1000000 values: 16 MB vs 32 MB
int 4612811918334230528
The union halves the memory: 16 bytes per value against 32 when every possible member gets its own field. The anonymous union (C11) lets you write v->i instead of v->as.i.
The last line shows what the pattern does not protect against. The tag says “int” while the union holds a double, and the program prints the bits of 2.5 read as a long: 4612811918334230528 is 0x4004000000000000, the IEEE 754 encoding of 2.5. Keeping the tag and the data in agreement is entirely the programmer’s job. The usual defense is to create values only through small constructor functions (make_int(42)) that set both at once.
The compiler does help with one mistake. Add a fourth kind, VAL_BOOL, to the enum without handling it, and both compilers warn under -Wall:
warning: enumeration value 'VAL_BOOL' not handled in switch [-Wswitch]
That warning disappears if the switch has a default: label, which is a good reason to leave default out of switches over a tag.
C++: std::variant Instead of a Tagged Union
C++ unions have an extra restriction: a member with a non-trivial constructor or destructor, such as std::string, deletes the union’s own default constructor:
error: use of deleted function 'Value::Value()' (g++)
error: call to implicitly-deleted default constructor of 'Value' (clang++)
You would then have to construct and destroy the std::string member by hand with placement new and explicit destructor calls. C++17’s std::variant does that, and keeps the tag, for you:
// variant.cpp - the tagged union from C, written with std::variant
#include <iostream>
#include <string>
#include <variant>
#include <vector>
using Value = std::variant<long, double, std::string>;
struct Printer {
void operator()(long i) const { std::cout << "int " << i << '\n'; }
void operator()(double d) const { std::cout << "double " << d << '\n'; }
void operator()(const std::string& s) const { std::cout << "string " << s << '\n'; }
};
int main()
{
std::vector<Value> vals{42L, 2.5, std::string("hello")};
for (const Value& v : vals)
std::visit(Printer{}, v);
std::cout << "sizeof(Value) = " << sizeof(Value) << '\n';
Value v = 2.5; // holds a double
try {
std::cout << std::get<long>(v) << '\n'; // ask for the wrong type
} catch (const std::bad_variant_access& e) {
std::cout << "bad_variant_access: " << e.what() << '\n';
}
if (const long* p = std::get_if<long>(&v)) // non-throwing check
std::cout << *p << '\n';
else
std::cout << "v does not hold a long; index() = " << v.index() << '\n';
}
Output (g++ and clang++, identical):
int 42
double 2.5
string hello
sizeof(Value) = 40
bad_variant_access: std::get: wrong index for variant
v does not hold a long; index() = 1
Compared with the C version, std::variant makes three guarantees:
- The tag cannot drift from the data. Assigning a
doublesets the index; there is no separatekindfield to forget. - The wrong-type read is caught. Asking for a
longwhen the variant holds adoublethrowsstd::bad_variant_accessinstead of printing reinterpreted bits.std::get_ifoffers a non-throwing check. - A missing case is a compile error, not a warning. Delete the
std::stringoverload fromPrinterandstd::visitrefuses to compile, witherror: no type named 'type' in 'std::invoke_result<Printer, const std::basic_string<char> &>'from clang++ (g++ reports the same failure in its own words).
The cost is size and some convenience. This variant is 40 bytes because std::string is 32 bytes in libstdc++ and the index needs room of its own. The C tagged union, holding a const char * instead of an owning string, is 16. The comparison is not like for like, since the variant owns its string, but the gap is worth knowing about for large arrays.
When to Use a Union in C
| Situation | Use | Why |
|---|---|---|
| A value that can be one of several types | Tagged union (struct + enum + union) | Half the memory of separate fields in the measured example; one type to pass around |
| Viewing a value’s bytes in C | Union or memcpy | Both are defined in C; memcpy also ports to C++ |
| Viewing a value’s bytes in C++ | std::bit_cast (C++20) or memcpy | Union punning is undefined in C++ |
| Splitting a value into bytes portably | Shifts and masks | Independent of byte order |
| Saving memory by sharing a field between unrelated uses | Usually neither: two fields | The saving is rarely worth the lost clarity |
| A variant type in C++ | std::variant | Tag kept in sync, checked access, exhaustive visitation |
| Any pointer cast between unrelated types | Never | Violates strict aliasing; optimized builds returned the wrong value |
Key Takeaways
- All members of a union start at offset 0 and share the same bytes. Writing one member overwrites the others.
- A union’s size is its largest member rounded up to its strictest alignment, not simply the largest member: 16 bytes for an
int, adoubleandchar[10], where the largest member is 10. - An undesignated brace initializer sets the first member.
{ 3.75 }stored 3 in anint, and only Clang warned by default. Use designated initializers. - Type punning through a union is defined in C and undefined in C++. C++ compilers reject it during constant evaluation; use
memcpyorstd::bit_cast. - Pointer casts are not unions. Both compilers returned 1 instead of 0 at
-O2, with no warning and no sanitizer report. - A tagged union halves memory in the measured case, but keeping the tag in agreement with the data is your job; the
-Wswitchwarning catches only missing cases. - In C++,
std::variantis the tagged union with the checks built in: wrong-type access throws, and a missing visitor case fails to compile.
Frequently Asked Questions
Conclusion
The union is often taught as a memory-saving trick and then set aside, which undersells it and hides its risks. It is also the one place where C lets you look directly at the bytes of a value, and that brings in alignment, byte order, and the difference between what C and C++ allow. Understand those and the rest is straightforward: tag your unions, initialize them with designators, and never try to get the same effect with a pointer cast.
For values that genuinely vary in type, the tagged union is still the standard C answer. C++’s std::variant shows what the same idea looks like when the language can enforce it. The rest of the C programming tutorials cover the structures, pointers and memory management that unions are usually built alongside.



