Topic 85 of 100 Structures and Unions in C Interactive Tutorial

Unions in C: Shared Memory Members and Practical Examples

12 mins read Last updated: 22 Aug 2026 24,700 views
C Compiler 0.00%

What is Unions in C? Core Concept & Direct Summary

Core Objective & Problem Statement

What is a `union` in C, how do all union members share the exact same memory location, and how does union sizing work?

Direct Answer Summary

A structure gives every member its own storage. Add five members and you get five separate places in memory, all alive at once.

A union does the opposite. Every member starts at the same address and shares the same bytes. It holds exactly one value at a time — whichever member you last wrote. The union is as large as its largest member, not the sum of them all.

Architecture Diagram
union Data {    int   i;         /* ┐ */    float f;         /* ├─ all three start at the same address */    char  str[20];   /* ┘ */};

That property sounds like a limitation, and it is — but it's the limitation that makes unions the right tool for a specific and genuinely common job: representing data that could be one of several types, where you only ever need one of them at a time. Configuration values, protocol packets, hardware registers, and variant records all have that shape.

This page covers how unions share memory, the rules that govern reading them (including the ones that bite), and four practical patterns where unions are the correct answer rather than a clever trick.

(Suggested image: the hero graphic — a struct's five separate boxes beside a union's one box with three labels pointing at it and "same address" marked)

How Does Unions in C Work? In-Depth Mechanics & Architecture

Declaration and the Core Idea

main.c
Run Online
#include <stdio.h>
union Data {
    int i;
    float f;
    char str[20];
};
int main(void) {
    union Data d;
    printf("sizeof(union Data) = %zu bytes\n", sizeof(union Data));
    printf("&d     = %p\n",(void *)&d);
    printf("&d.i   = %p\n",(void *)&d.i);
    printf("&d.f   = %p\n",(void *)&d.f);
    printf("&d.str = %p\n",(void *)&d.str);
    return 0;
}

Output:

main.c
Run Online
sizeof(union Data) = 20 bytes&d = 000000A1F4BFF7C0&d.i = 000000A1F4BFF7C0&d.f = 000000A1F4BFF7C0&d.str = 000000A1F4BFF7C0

(Address values vary per run — that's your machine's stack layout.)

All four addresses are identical. That's the entire definition of a union in one observation. The members aren't laid out one after another; they're all anchored to the same starting byte and simply interpret what's there differently.

And sizeof is 20 — the size of str[20], the largest member. Not 4 + 4 + 20 = 28.

(Suggested image: the union drawn as a single byte-range with i, f, and str all overlapping it, against the struct drawn as three sequential non-overlapping ranges)


Unions vs Structs: The Comparison That Matters

structunion
Memory per memberSeparate — members don't overlapShared — all overlap at offset 0
Total sizeSum of members (+ padding)Size of the largest member (+ padding)
Members valid at onceAll of themOnly one — the last one written
Writing a memberLeaves others untouchedOverwrites all others
Typical useGrouping related dataRepresenting alternatives
Address of membersAll differentAll identical
Assignment =Copies all membersCopies the raw bytes
Comparison ==Not allowedNot allowed

The one-sentence distinction:a struct says "and" (this and that); a union says "or" (this or that).

That's the mental test to apply when choosing. Do you need the width and the height at the same time? Struct. Is this value either an int or a float, never both? Union.

main.c
Run Online
struct {
    int i;
    float f;
}
s;
/* you can use s.i and s.f independently */union {
    int i;
    float f;
}
u;
/* u.i and u.f are the SAME memory */

(Suggested image: the side-by-side memory map — struct showing two adjacent boxes, union showing one box with two labels — with "and" vs "or" annotated)


Writing One Member Overwrites the Others

This is the behavior that surprises people, so let's make it concrete:

main.c
Run Online
union Data d;
d.i = 10;
printf("after d.i = 10    : d.i = %d\n", d.i);
/* 10 */
d.f = 3.14f;
printf("after d.f = 3.14f : d.i = %d  (no longer 10!)\n", d.i);
printf("                    d.f = %f\n", d.f);

Output:

main.c
Run Online
after d.i = 10 : d.i = 10after d.f = 3.14f : d.i = 1085069459(no longer 10!) d.f = 3.140000

Assigning d.f = 3.14f wrote the 4 bytes of the float 3.14on top of the integer. The integer is gone. Reading d.i now reinterprets those same 4 bytes as an int — which yields a large, meaningless number. That particular value (1085069459 = 0x40A3D70A-ish reinterpreted) isn't a bug in your code; it's simply the float's bit pattern read as an integer.

The rule:a union holds one value. The last member you wrote is the only one that means anything.

(Suggested image: two frames of one byte-range — first showing int 10 written, then showing float 3.14 overwriting those bytes, with the int label now crossed out)


Reading a Member You Didn't Write

Here's the precise rule, because the details matter.

When you read a union member other than the last one stored, the standard says the bytes are reinterpreted as the new type — a process it calls type punning — and adds a caveat: the result might be a trap representation (a bit pattern the type isn't allowed to hold), which is undefined behavior.

So:

  • Reading a different member is defined by your compiler's behavior, not guaranteed portably.
  • GCC and Clang explicitly document that type punning through unions works as expected.
  • The result depends on byte order (endianness) and the sizeof relationship between the members.

Where it produces a meaningful answer — a small integer read as a character on a little-endian machine:

main.c
Run Online
union Data d;
d.i = 65;
/* the ASCII code for 'A' */printf("d.str[0] = '%c'\n", d.str[0]);
/* 'A' on LITTLE-ENDIAN only  */

On a little-endian machine (x86, ARM in its usual mode), the low byte of 65 is 65, so d.str[0] is 'A'. On a big-endian machine, the first byte of the four-byte int 65 is 0, so you'd get an empty string instead. Same code, different answer, no warning.

Practical guidance:

  • For records you tagged yourself (the pattern below), punning never happens — you always read the member that matches the tag. That's the safe, idiomatic use.
  • For deliberate byte inspection, prefer memcpy — it's fully defined in standard C and immune to aliasing surprises:
main.c
Run Online
float f = 3.14f;
uint32_t bits;
memcpy(&bits, &f, sizeof bits);
/* strictly conforming */printf("0x%08X\n", bits);
/* 0x4048F5C3 */

(Suggested image: the same 4 bytes shown twice — read as an int and read as a float — with an endianness note marking which byte each member touches)


Initialization Rules

Unions have a few specific rules worth knowing.

Only the first member can be brace-initialized positionally:

main.c
Run Online
union Data d = {
    42
}
;
/* initializes d.i — the FIRST member */

Anything else needs a designated initializer (C99):

main.c
Run Online
union Data d2 = {
    .f = 3.14f
}
;
/* ✓ initializes d.f */union Data d3 = {
    .str = "hello"
}
;
/* ✓ initializes d.str */union Data d4 = {
    .i = 7, .f = 1.0f
}
;
/* ✗ ERROR — only ONE may be initialized */

That last error is the compiler enforcing the whole point: a union holds one value. Initializing two members would be contradictory.

Zeroing everything works as usual:

main.c
Run Online
union Data d = {
    0
}
;
/* all bytes zero */

Assignment is a raw copy:

main.c
Run Online
d2 = d1;
/* copies the bytes — and the active member */

Unlike arrays, unions are assignable, so d2 = d1 copies the union wholesale.

But you can't compare them:

main.c
Run Online
// if (d1 == d2) {
...
}
/* ✗ ERROR — no == for unions */

Compare the active member explicitly instead.

(Suggested image: the union with the "only one member may be initialized" rule illustrated — two designated initializers with one crossed out)


The Main Use: Tagged Unions (Variant Types)

This is why unions exist in application code: a record that can hold one of several different types, with a separate field recording which one.

The union stores the alternatives; a tag (usually an enum) records the truth.

main.c
Run Online
typedef enum {
    SHAPE_CIRCLE, SHAPE_RECTANGLE, SHAPE_TRIANGLE
}
ShapeType;
typedef struct {
    double radius;
}
Circle;
typedef struct {
    double width, height;
}
Rectangle;
typedef struct {
    double base, height;
}
Triangle;
/* All three alternatives share the same bytes */typedef union {
    Circle circle;
    Rectangle rectangle;
    Triangle triangle;
}
ShapeData;
/* A tag + the union = a "discriminated" or "variant" record */typedef struct {
    ShapeType type;
    ShapeData data;
}
Shape;

Every operation then follows one discipline: switch on the tag, touch the matching member.

main.c
Run Online
double shapeArea(const Shape *s) {
    switch (s->type) {
        case SHAPE_CIRCLE: return 3.141592653589793 * s->data.circle.radius * s->data.circle.radius;
        case SHAPE_RECTANGLE: return s->data.rectangle.width * s->data.rectangle.height;
        case SHAPE_TRIANGLE: return 0.5 * s->data.triangle.base * s->data.triangle.height;
        default: return 0.0;
    }
}

Why the tag is mandatory: nothing inside the union tells you what it contains. s->data is just bytes; you have to know whether to read it as a circle, a rectangle, or a triangle. The type field is that knowledge, stored alongside the data.

And the payoff — one array holding different kinds of records:

main.c
Run Online
Shape shapes[4];
shapes[0] = makeCircle(2.0);
shapes[1] = makeRectangle(3.0, 4.0);
shapes[2] = makeTriangle(6.0, 5.0);
shapes[3] = makeCircle(1.0);

Four elements of one array type, holding circles, rectangles, and triangles. Without a union you'd need four separate arrays (and lose the ordering); with void * you'd lose all type safety. The tagged union gives you both.

(Suggested image: one array of 4 Shape records where each record shows the same tag slot but different payload layouts — a circle payload, rectangle payload, triangle payload)


📋 Complete Program: A Mixed Shape List

A verified, complete implementation of the tagged union.

main.c
Run Online
#include <stdio.h>
#include <string.h>
/* ---- The tag: which alternative is currently meaningful ---- */typedef enum {
    SHAPE_CIRCLE, SHAPE_RECTANGLE, SHAPE_TRIANGLE
}
ShapeType;
/* ---- The alternatives ---- */typedef struct {
    double radius;
}
Circle;
typedef struct {
    double width, height;
}
Rectangle;
typedef struct {
    double base, height;
}
Triangle;
/* ---- The shared storage: all three reuse the same bytes ---- */typedef union {
    Circle circle;
    Rectangle rectangle;
    Triangle triangle;
}
ShapeData;
/* ---- One record = a tag PLUS the union ---- */typedef struct {
    ShapeType type;
    ShapeData data;
}
Shape;
const char *typeName(ShapeType t) {
    switch (t) {
        case SHAPE_CIRCLE: return "Circle";
        case SHAPE_RECTANGLE: return "Rectangle";
        case SHAPE_TRIANGLE: return "Triangle";
        default: return "Unknown";
    }
}
double shapeArea(const Shape *s) {
    switch (s->type) {
        case SHAPE_CIRCLE: return 3.141592653589793 * s->data.circle.radius * s->data.circle.radius;
        case SHAPE_RECTANGLE: return s->data.rectangle.width * s->data.rectangle.height;
        case SHAPE_TRIANGLE: return 0.5 * s->data.triangle.base * s->data.triangle.height;
        default: return 0.0;
    }
}
void shapePrint(const Shape *s) {
    switch (s->type) {
        case SHAPE_CIRCLE: printf("%-10s r=%.1f", typeName(s->type), s->data.circle.radius);
        break;
        case SHAPE_RECTANGLE: printf("%-10s %.1f x %.1f", typeName(s->type), s->data.rectangle.width, s->data.rectangle.height);
        break;
        case SHAPE_TRIANGLE: printf("%-10s base=%.1f h=%.1f", typeName(s->type), s->data.triangle.base, s->data.triangle.height);
        break;
        default: printf("Unknown");
    }
    printf("  -> area %8.2f\n", shapeArea(s));
}
Shape makeCircle(double r) {
    Shape s;
    s.type = SHAPE_CIRCLE;
    s.data.circle.radius = r;
    /* only THIS alternative is written */ return s;
}
Shape makeRectangle(double w, double h) {
    Shape s;
    s.type = SHAPE_RECTANGLE;
    s.data.rectangle.width = w;
    s.data.rectangle.height = h;
    return s;
}
Shape makeTriangle(double b, double h) {
    Shape s;
    s.type = SHAPE_TRIANGLE;
    s.data.triangle.base = b;
    s.data.triangle.height = h;
    return s;
}
int main(void) {
    /* ONE array holding records of different kinds */ Shape shapes[4];
    shapes[0] = makeCircle(2.0);
    shapes[1] = makeRectangle(3.0, 4.0);
    shapes[2] = makeTriangle(6.0, 5.0);
    shapes[3] = makeCircle(1.0);
    puts("===== SHAPE LIST =====");
    for (int i = 0; i < 4; i++) {
        shapePrint(&shapes[i]);
    }
    double total = 0;
    for (int i = 0; i < 4; i++) {
        total += shapeArea(&shapes[i]);
        /* dispatch on the tag */
    }
    printf("\nTotal area       : %.2f\n", total);
    printf("sizeof(Shape)    : %zu bytes (typically)\n", sizeof(Shape));
    printf("sizeof(ShapeData): %zu bytes (largest alternative)\n", sizeof(ShapeData));
    return 0;
}

Sample Run (numeric output verified)

main.c
Run Online
===== SHAPE LIST =====Circle r=2.0 -> area 12.57Rectangle 3.0 x 4.0 -> area 12.00Triangle base=6.0 h=5.0 -> area 15.00Circle r=1.0 -> area 3.14
Total area : 42.71sizeof(Shape) : 24 bytes(typically)sizeof(ShapeData): 16 bytes(largest alternative)

Reading the design:

  • ShapeData is 16 bytes — the size of Rectangle and Triangle (two doubles each). Circle is only 8 bytes, but all three share the same 16, so that's the union's size.
  • Shape is 24 bytes — 4 for the enum tag, 4 bytes of alignment padding so the union can start on an 8-byte boundary, then 16 for the union. The tag costs 8 bytes, which is the price of knowing what's inside.
  • The switch in shapeArea is the dispatch. Every alternative is handled, and the default case guards against an uninitialized tag. This pattern — switch on tag, read the matching member — is the only correct way to read a tagged union.
  • The maker functions set the tag and one alternative. They deliberately leave the other alternatives untouched, because those bytes don't belong to this shape.
  • One array, mixed types, one loop.for (int i = 0; i < 4; i++) { total += shapeArea(&shapes[i]); } — the loop doesn't know or care which kind each record is. That polymorphism is what the tag buys you.

(Suggested image: the four Shape records with their tags highlighted, showing that the payload area holds a circle payload in rows 0 and 3 and different layouts in rows 1 and 2)


Use 2: Hardware Registers and Protocol Fields

Unions combine naturally with bit fields — a raw access view and a named-field view of the same register:

main.c
Run Online
#include <stdio.h>
#include <stdint.h>
typedef union {
    uint16_t raw;
    /* whole-register access */ struct {
        uint16_t mode : 3;
        /* named-bit access    */ uint16_t enable : 1;
        uint16_t channel : 4;
        uint16_t code : 8;
    }
    bits;
}
ControlRegister;
int main(void) {
    ControlRegister reg;
    reg.raw = 0;
    /* zero the register first */
    reg.bits.mode = 5;
    reg.bits.enable = 1;
    reg.bits.channel = 12;
    reg.bits.code = 0xAB;
    printf("Raw value  : 0x%04X\n", reg.raw);
    /* one read, whole register */ printf("mode=%u enable=%u channel=%u code=0x%02X\n", reg.bits.mode, reg.bits.enable, reg.bits.channel, reg.bits.code);
    /* Or go the other way: a raw word, no field assignment at all */ reg.raw = 0x1234;
    printf("\nraw=0x%04X -> mode=%u enable=%u channel=%u code=0x%02X\n", reg.raw, reg.bits.mode, reg.bits.enable, reg.bits.channel, reg.bits.code);
    printf("\nsizeof(ControlRegister) = %zu bytes\n", sizeof(ControlRegister));
    return 0;
}

Why this is the right shape: a hardware register is one 16-bit word that a datasheet describes field by field. The union lets you write reg.bits.enable = 1 (readable) and also reg.raw = incoming_word (one instruction, whole register) — the same memory, two views.

⚠️ Bit-field layout is implementation-defined (bit ordering within the storage unit, padding). So this pattern is excellent for on one fixed platform — embedded targets, device drivers — and unsuitable for wire formats read by another compiler. For cross-platform formats, use explicit shifts and masks.

(Suggested image: one 16-bit register drawn twice — as a raw hex value and as four labeled bit fields — with an arrow labeled "same memory")


Use 3: Detecting Byte Order (Endianness)

A clean, portable-ish use of byte-overlapping:

main.c
Run Online
#include <stdio.h>
#include <stdint.h>
int main(void) {
    union {
        uint32_t word;
        uint8_t bytes[4];
    }
    probe;
    probe.word = 0x01020304;
    printf("word  = 0x%08X\n", probe.word);
    printf("bytes = %02X %02X %02X %02X\n", probe.bytes[0], probe.bytes[1], probe.bytes[2], probe.bytes[3]);
    if (probe.bytes[0] == 0x04) {
        puts("=> little-endian (least significant byte first)");
    } else if (probe.bytes[0] == 0x01) {
        puts("=> big-endian (most significant byte first)");
    } else {
        puts("=> unusual byte order");
    }
    return 0;
}

Output on a typical x86-64 machine:

main.c
Run Online
word = 0x01020304bytes = 04 03 02 01=> little-endian(least significant byte first)

The uint32_t and the uint8_t[4] occupy the same 4 bytes. Writing the word and reading the bytes reveals the machine's byte order — useful when you're serializing data for a network (which is big-endian, by convention) and need to know whether to swap.

(Suggested image: the 4-byte range with the word label showing 0x01020304 and the byte labels showing 04 03 02 01, illustrating little-endian storage)


Use 4: Memory Saving in Large Arrays

When every record has a field that's sometimes a small number and sometimes a big one, a union saves real memory across a big table.

main.c
Run Online
typedef struct {
    int id;
    int isLarge;
    /* tag */ union {
        int32_t smallValue;
        /* 4 bytes */ double largeValue;
        /* 8 bytes */
    }
    amount;
    /* union is 8 bytes total */
}
Measurement;

Each Measurement costs 4 (id) + 4 (isLarge) + 8 (union) = 16 bytes, instead of 4 + 4 + 4 + 8 = 20 bytes for two separate fields. Over a million records, that's ~4 MB saved.

The honest caveat: a union only saves memory when the alternatives would otherwise sit unused. If you find yourself needing two members at once, you've picked the wrong tool — that's a struct. And a few bytes per record usually isn't worth added complexity unless the table is genuinely large or the memory genuinely tight (embedded systems, huge datasets, fixed-size buffers).


Anonymous Unions (C11)

C11 lets you declare a union inside a struct without a name, which promotes its members to the struct's own namespace:

main.c
Run Online
typedef struct {
    ShapeType type;
    union {
        /* ← no name for the union */ Circle circle;
        Rectangle rectangle;
        Triangle triangle;
    }
;
    /* ← just a semicolon */
}
Shape2;

Now the members are reachable directly:

main.c
Run Online
Shape2 s;
s.type = SHAPE_CIRCLE;
s.circle.radius = 2.0;
/* instead of s.data.circle.radius */

Less typing, flatter paths — at the cost of clarity about which fields belong together. Most codebases keep the named data member for exactly that reason: s.data.circle.radius spells out the hierarchy, and the compiler-error messages are clearer. Use whichever your team finds readable. (GCC and Clang accept this in C99 mode as an extension; MSVC supports it too — but for strict portability, specify C11: -std=c11.)


When to Use a Union — and When Not To

Good fits:

SituationWhy a union fits
Variant/tagged data — one field that's one of several typesTrue "or" semantics; the tag records which
Hardware registers / protocol fieldsRaw-word access and named-bit access to the same bytes
Endianness probes and byte inspectionDeliberate reinterpretation is the point
Large tables with mutually exclusive heavy fieldsGenuine memory savings
Message/event payloadsEach message type carries one specific payload

Poor fits:

SituationWhy not
You need two members at onceThat's literally a struct. Use one.
The memory saving is trivialA few bytes rarely justifies the risk
You need portable byte-level formatsLayout and bit order are implementation-defined — use serialization functions
You'd need a tag anyway and it costs 8 bytesSometimes the union saves nothing net (as in Shape: 16-byte union + 8 bytes of tag padding vs 16 either way)
You're tempted to skip the tagWithout a tag you must remember the active member yourself — a guaranteed future bug

That fourth row is worth internalizing from the verified example above: Shape is 24 bytes, while a plain struct of all three alternatives would be 4 + 8 + 16 + 16 = 44 bytes. So the union does win — combining the tag with shared storage still saves 20 bytes per record. But it's a design calculation, not an automatic win.

(Suggested image: the memory budget comparison — plain struct with all fields (44 bytes) vs tag + union (24 bytes), with the saved bytes highlighted)


Common Mistakes

  1. Writing one member and reading another — the #1 union bug. The bytes get reinterpreted, giving meaningless values. Always pair reads with the tag.
  2. Treating a union like a struct — expecting all members to hold values simultaneously. A union holds one value.
  3. Skipping the tag — using a bare union and "remembering" which member is active. It works until the code grows, then fails silently. Always add a tag.
  4. Assuming sizeof(union) is the sum of members — it's the largest member, plus alignment padding.
  5. Forgetting that the union is sized by the largest member — a char[20] member makes every union 20+ bytes even if you only ever use the int.
  6. Initializing two members — { .i = 1, .f = 2.0 } is a compile error. Only one may be initialized.
  7. Positional initialization of a non-first member — { 3.14 } initializes the first member, whatever its type. Use designated initializers.
  8. Comparing unions with == — not allowed. Compare the tag, then the relevant member.
  9. Relying on punning portably — endianness and trap representations make it non-portable. Use memcpy for defined byte copying.
  10. Writing unions to files or sockets directly — padding, alignment, and bit order vary between compilers and platforms. Serialize field by field.
  11. Forgetting to zero a union before use — then the tag or bytes may be garbage. Start with = {0}.
  12. A union member of its own type — not allowed (incomplete type). A pointer to its own type is fine — that's how self-referential structures work.
  13. Using anonymous unions without C11 — works as an extension in GCC/Clang, but compile with -std=c11 if you want it guaranteed.
  14. Reading the union's "other" member to test the type — there's no way to tell from the bytes. That's what the tag is for.

(Suggested image: "14 Union Mistakes" checklist — with #1, #3, and #10 highlighted as the three that produce silent wrong answers)


Final Thoughts

A union is C's "one of these" type. Where a struct allocates space for everything, a union allocates space for the largest alternative and lets the members share it — which means it holds exactly one value at a time, and the last member written is the only one that means anything.

That constraint defines both the danger and the value. The danger is reading a member you didn't write: the bytes get reinterpreted, and you get plausible-looking nonsense with no warning. The value is that alternatives become expressible — a record that's a circle or a rectangle or a triangle, a register that's a raw word or four named fields, a value that's an int or a double or a string.

The discipline that makes unions safe fits in one line: whenever you use a union for data that can vary, pair it with a tag and switch on that tag before you touch a member. Add a = {0} before you start, keep sizeof out of your mental arithmetic (the compiler knows), and reach for memcpy instead of punning when portability matters. Do that, and unions stop being a risky feature and become a precise, well-fitting tool.

Next in this cluster: typedef and type naming, enum in depth, or structure padding and layout — where the alignment rules that shaped this page's sizes get their full treatment.

Runnable C Code Example & Practical Implementation

main.c
Run Online
#include <stdio.h>
union DataValue {
    int intVal;
    float floatVal;
    char charVal;
};
int main() {
    union DataValue data;
    data.intVal = 100;
    printf("Stored intVal:   %d\
", data.intVal);
    data.floatVal = 3.1415f;
    // Overwrites intVal in the same memory location
    printf("Stored floatVal: %.4f\
", data.floatVal);
    printf("Corrupted intVal:%d (Memory was overwritten)\
", data.intVal);
    printf("Size of union:   %zu bytes\
", sizeof(union DataValue));
    return 0;
}

Key Takeaways, Best Practices & Memory Safety Rules

Best Practice & Key Takeaways
Always wrap unions inside a 'Tagged Union' struct with an enum type_tag to track which member is currently active.

Frequently Asked Questions & Technical Interview Insights

Technical Interview & Viva Questions

Frequently asked C programming interview questions
Q

Q: What is the primary purpose of a union in systems programming?

A: Memory conservation in embedded systems, implementing polymorphic dynamic variant types (Tagged Unions), and low-level hardware register byte/word packing (type punning).
Q

Q: How is `sizeof(union)` determined?

A: By the byte size of its largest member rounded up to the strictest member alignment requirement.

Related Tools, Tutorials & Authoritative References

Recommended Interactive Tools

Authoritative Standards & Citations