Unions let multiple C types occupy the same chunk of memory, with every member starting at the same address. The storage is sized for the largest member and follows the strictest alignment requirement among them, so writing one member overwrites the bytes for all the others. That shared layout is why unions work well for tagged values, compact message payloads, controlled byte level views, and other cases where you want one storage slot to represent different forms at different times.
Unions in C and What Shared Storage Means
Simply put, unions are in the same family as struct, but they behave very differently in memory. With a struct, each member gets its own storage and the compiler lays those members out in order with padding as needed. With a union, every member overlaps the same storage, so the type is really a single chunk of memory that can be viewed in more than one way. That one idea drives everything else you see with unions, including why their size can feel surprising the first time you print it.
What are Unions?
You can think of a union as one storage slot that can hold one of several types at different times. Every member starts at the same address, so writing to one member writes bytes into that shared slot. Reading the member you last wrote is the normal, intended use.
We can see how the overwrite behavior works directly. Notice in this next example how the same object gets assigned as an integer, then assigned as a floating value. The second store replaces the bytes from the first store, because there is only one storage region.
#include <stdint.h>
#include <stdio.h>
#include <string.h>
union NumberSlot {
uint32_t u32;
float f;
};
int main(void) {
union NumberSlot slot;
_Static_assert(sizeof(uint32_t) == sizeof(float), "unexpected float size on this target");
slot.u32 = 0x3f800000u; /* 1.0f bit pattern on IEEE-754 binary32 targets */
printf("after slot.u32 write, slot.u32 = 0x%08x\n", slot.u32);
slot.f = 1.0f;
printf("after slot.f write, slot.f = %f\n", slot.f);
/* Portable bit view of the float representation bytes */
uint32_t bits = 0;
memcpy(&bits, &slot.f, sizeof bits);
printf("after slot.f write, float bits = 0x%08x\n", bits);
return 0;
}Standard C does not promise a meaningful value when you write one union member and then read back a different non character member, so that style of bit reinterpretation should not be treated as portable. Copy the representation bytes of slot.f into an integer with memcpy to print the bit pattern without reading an inactive union member. That shared storage means the union holds one active member value at a time, and every other member overlaps the same bytes.
Unions also show up as nested members inside a struct, which is a common way to group related alternatives while keeping a single outer type. This stays within the language rules as long as you store and read through the same member that is meant to be active.
#include <stdio.h>
struct SensorReading {
unsigned int kind;
union {
int temp_c;
unsigned int rpm;
} value;
};
int main(void) {
struct SensorReading r;
r.kind = 1;
r.value.temp_c = 27;
printf("temp_c = %d\n", r.value.temp_c);
r.kind = 2;
r.value.rpm = 3500;
printf("rpm = %u\n", r.value.rpm);
return 0;
}The union does not track which member is active. That is the caller’s job. A union gives you shared storage, and your code decides what that storage currently represents.
Shared Storage Rules and Size Placement
There are a few measurable consequences of shared storage. Each member begins at offset zero inside the union, so the address of the union object matches the address of any member. The compiler also chooses a size large enough to hold the biggest member, then rounds that size up to satisfy alignment rules. Alignment matters because the union itself must be valid storage for every member type it can contain, and some types require stricter alignment than others.
You can print addresses, sizes, and alignment to see what the compiler picked on your current target like this:
#include <stdio.h>
#include <stdalign.h>
union LayoutCheck {
unsigned char bytes[3];
unsigned int u32;
double d64;
};
int main(void) {
union LayoutCheck u;
printf("sizeof(union LayoutCheck) = %zu\n", sizeof(union LayoutCheck));
printf("alignof(union LayoutCheck) = %zu\n", (size_t)alignof(union LayoutCheck));
printf("&u = %p\n", (void*)&u);
printf("&u.bytes = %p\n", (void*)&u.bytes);
printf("&u.u32 = %p\n", (void*)&u.u32);
printf("&u.d64 = %p\n", (void*)&u.d64);
return 0;
}On a lot of 64 bit targets, double tends to drive alignment, so the union size often ends up as 8, even though the byte array member is only 3 bytes. On targets where the ABI requires stricter alignment for double, the union can be larger than 8. That is not wasted work by the compiler. It is how the union stays valid storage for a double placed at the start of the union.
One subtle point that becomes important later is that unions do not have per member offsets the way structs do. Struct layout can place b after a with padding, so offsetof(struct S, b) can be meaningful. For a union, every member is at offset zero by definition, so there is no layout map to walk through. Something else worth knowing is that sizeof answers a storage question, not an active value question. Union storage always reserves enough room for its largest member, even if you only store a smaller member most of the time.
Alignment also affects where a union can live. If a union needs 8 byte alignment, then arrays of that union type, fields inside a struct, and dynamically allocated instances must all satisfy that alignment. The compiler handles that placement automatically, and the cost shows up as padding when a union sits next to smaller fields in a struct.
Practical Union Patterns and Safe Byte Work
Work with unions usually ends up in two areas. One area is compact storage, where one field can hold different value shapes at different times without reserving separate space for each shape. The other area is byte oriented work, where you need a controlled way to look at representation bytes or move bits around without treating those bytes as a different type.
Tagged Variants
Tagged variants pair a union with a small tag that records which member is currently active. The union gives shared storage for the payload, and the tag gives a reliable way to interpret that storage later. Without the tag, the bytes do not explain themselves, and the union does not track which member was last written. Parsing and message handling are common spots for this. Parser output can be an integer value in one case, a floating value in another, and a short string in a third. Storing those alternatives as separate fields wastes space and invites bugs, while a tagged variant keeps the type story explicit.
We can see the tag drive the read logic in this:
#include <stdio.h>
#include <string.h>
enum TokenKind {
TOKEN_INT,
TOKEN_FLOAT,
TOKEN_WORD
};
struct Token {
enum TokenKind kind;
union {
long i;
double f;
char word[24];
} data;
};
static struct Token token_int(long v) {
struct Token t;
t.kind = TOKEN_INT;
t.data.i = v;
return t;
}
static struct Token token_float(double v) {
struct Token t;
t.kind = TOKEN_FLOAT;
t.data.f = v;
return t;
}
static struct Token token_word(const char *s) {
struct Token t;
t.kind = TOKEN_WORD;
size_t n = strlen(s);
if (n >= sizeof(t.data.word)) n = sizeof(t.data.word) - 1;
memcpy(t.data.word, s, n);
t.data.word[n] = '\0';
return t;
}
static void print_token(const struct Token *t) {
switch (t->kind) {
case TOKEN_INT:
printf("int %ld\n", t->data.i);
break;
case TOKEN_FLOAT:
printf("float %f\n", t->data.f);
break;
case TOKEN_WORD:
printf("word %s\n", t->data.word);
break;
}
}
int main(void) {
struct Token a = token_int(17);
struct Token b = token_float(2.5);
struct Token c = token_word("Eau Claire");
print_token(&a);
print_token(&b);
print_token(&c);
printf("sizeof(struct Token) = %zu\n", sizeof(struct Token));
return 0;
}The switch protects the read so the code only reads the union member that matches the active tag. That is the practical guardrail that keeps union storage meaningful.
Sometimes a tag is a small integer stored in a byte, which is handy when you have lots of values flowing through a buffer. Same idea, smaller container.
#include <stdio.h>
#include <stdint.h>
enum MsgType {
MSG_TEMP = 1,
MSG_RPM = 2
};
struct Msg {
uint8_t type;
union {
int16_t temp_c;
uint16_t rpm;
} payload;
};
static void print_msg(const struct Msg *m) {
if (m->type == MSG_TEMP) {
printf("temp %d\n", (int)m->payload.temp_c);
return;
}
if (m->type == MSG_RPM) {
printf("rpm %u\n", (unsigned)m->payload.rpm);
return;
}
printf("unknown\n");
}
int main(void) {
struct Msg t;
t.type = MSG_TEMP;
t.payload.temp_c = 28;
struct Msg r;
r.type = MSG_RPM;
r.payload.rpm = 3500;
print_msg(&t);
print_msg(&r);
return 0;
}The union does not buy safety on its own. Safety comes from recording the active member and then respecting that tag every time the payload is read.
Manual Serialization
Serialization is one place where unions show up a lot, and it helps to keep two goals separate.
First goal is a byte format that stays stable across machines, compilers, and build settings. That means you do not rely on in memory layout, padding, native endianness, or type sizes like int. Second goal is a local byte buffer inside one process, where you store bytes and interpret them later on the same target. Unions do not make native layout portable across targets. Writing sizeof(union) bytes to disk or a network connection can break when padding differs, when endianness differs, or when the sizes of types differ. Portable serialization works better when you choose fixed width integer types and write bytes in a known order.
This is a compact, portable write and read pair for a 32 bit unsigned integer in big endian form. It does not depend on union layout:
#include <stdint.h>
#include <stdio.h>
static void write_u32_be(uint8_t out[4], uint32_t v) {
out[0] = (uint8_t)((v >> 24) & 0xffu);
out[1] = (uint8_t)((v >> 16) & 0xffu);
out[2] = (uint8_t)((v >> 8) & 0xffu);
out[3] = (uint8_t)(v & 0xffu);
}
static uint32_t read_u32_be(const uint8_t in[4]) {
return ((uint32_t)in[0] << 24)
| ((uint32_t)in[1] << 16)
| ((uint32_t)in[2] << 8)
| (uint32_t)in[3];
}
int main(void) {
uint8_t buf[4];
write_u32_be(buf, 0x01020304u);
printf("%02x %02x %02x %02x\n", buf[0], buf[1], buf[2], buf[3]);
printf("0x%08x\n", read_u32_be(buf));
return 0;
}Unions still fit into serialization work when your message can carry different payload shapes. The union stores the payload alternatives, and the serializer branches on a type tag and writes a defined byte sequence for that payload. That keeps the external format stable while keeping the in memory representation compact.
Peeking at Raw Bytes
Byte inspection is a common debugging move, and C gives you a standard way to do it. Reading an object’s representation bytes through unsigned char is allowed. Copying bytes with memcpy is also allowed and is a common way to move representation bytes around. A union that includes an unsigned char array member can be a convenient container for that byte view, but the important part is the byte type. Byte access stays in bounds of the language rules because unsigned char is the designated type for object representation bytes.
This helper prints bytes for any object:
#include <stdio.h>
#include <stddef.h>
static void dump_bytes(const void *p, size_t n) {
const unsigned char *b = (const unsigned char *)p;
for (size_t i = 0; i < n; i++) {
printf("%02x", b[i]);
if (i + 1 != n) printf(" ");
}
printf("\n");
}Calling it with a value like uint64_t gives a quick view of byte order and how the bytes sit in memory:
#include <stdint.h>
#include <stdio.h>
int main(void) {
uint64_t v = 0x1122334455667788ull;
dump_bytes(&v, sizeof v);
return 0;
}Union based byte views are also common, and they can be nice when you want a named object that carries both views. Keeping the byte member as unsigned char is what makes this safe for inspection.
#include <stdio.h>
#include <stdint.h>
union U64View {
uint64_t v;
unsigned char b[sizeof(uint64_t)];
};
int main(void) {
union U64View u;
u.v = 0x0a0b0c0d0e0f1011ull;
for (size_t i = 0; i < sizeof(u.b); i++) {
printf("%02x", u.b[i]);
if (i + 1 != sizeof(u.b)) printf(" ");
}
printf("\n");
return 0;
}Byte inspection through unsigned char stays well defined. Treating those bytes as a different non character type by reading a different union member is where portability limits appear, which leads into aliasing and effective type rules.
Portability Rules You Should Know
Compilers optimize aggressively, and part of that relies on type rules about what can alias what. C describes an object’s effective type, and it restricts reading an object through an unrelated type. These rules exist so the compiler can assume, in many cases, that an int * and a float * do not point at the same storage.
Unions sit near that boundary, so it helps to stick to one reliable rule of thumb. Store a value into a union member, then read it back through that same member. For byte inspection, read the representation bytes through unsigned char, and when you need bit reinterpretation across types, memcpy is the standard tool for moving those raw bits without stepping outside the language rules. Float bit access comes up a lot in practice, and copying the bytes of a float into a uint32_t stays within those rules while still giving you the exact bit pattern to print or analyze across compilers.
#include <stdint.h>
#include <stdio.h>
#include <string.h>
int main(void) {
float f = 1.0f;
uint32_t bits = 0;
_Static_assert(sizeof f == sizeof bits, "float is not 32-bit on this target");
memcpy(&bits, &f, sizeof f);
printf("0x%08x\n", bits);
return 0;
}Tagged variants fit into this rule set because the code reads the union member that matches the stored form, instead of relying on reading a different type out of the same bytes.
Conclusion
Unions work because every member begins at the same address, so one block of storage can represent different types at different times, with size and alignment set by the largest and most strictly aligned member. That shared layout explains why a later write overwrites earlier bytes, why tagged variants pair a union with a discriminator, and why byte inspection stays safest through unsigned char and bit moves stay safest through memcpy when you need an exact representation.

