Skip to content
Home C / C++ Structures and Unions in C Explained — Memory, Use Cases and Pitfalls

Structures and Unions in C Explained — Memory, Use Cases and Pitfalls

Where developers are forged. · Structured learning · Free forever.
📍 Part of: C Basics → Topic 10 of 17
Master C structs and unions: understand memory alignment, padding optimization, and the tagged union pattern for type-safe systems programming.
⚙️ Intermediate — basic C / C++ knowledge assumed
In this tutorial, you'll learn
Master C structs and unions: understand memory alignment, padding optimization, and the tagged union pattern for type-safe systems programming.
  • A struct allocates independent memory for every member — all fields coexist. A union allocates memory for only its largest member — all fields overlap. This single difference defines every use case for each.
  • The compiler inserts silent padding bytes between struct members for CPU alignment. Reordering fields largest-to-smallest typically reduces or eliminates padding, which matters at scale and in embedded systems.
  • A bare union is almost always a bug waiting to happen. Always pair a union with an enum tag inside a struct — this creates a tagged union (discriminated union) that's the only safe pattern for using unions in application code.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer

Think of a struct like a passport — it holds your name, date of birth, nationality, and photo all in one booklet, each piece of information living in its own dedicated slot. A union is more like a whiteboard that only one person can write on at a time — the same physical space gets reused for different types of information depending on who needs it. The passport always has room for every field; the whiteboard only ever holds the most recent thing written on it. That single difference in 'shared vs dedicated memory' is the entire story of structs vs unions.

Every real-world program deals with grouped data. A game needs to track a player's name, health, score, and position together. A network driver needs to interpret the same 4 bytes as either an IPv4 address, a 32-bit integer, or four individual octets depending on context. Trying to manage all of that with loose individual variables is like trying to run a hospital with sticky notes instead of patient records — technically possible, catastrophically unmanageable. Structures and unions are C's answer to that chaos.

The problem they solve is fundamentally about organisation and memory semantics. A struct gives you a custom data type that bundles related variables under one name, each with its own guaranteed memory slot. A union takes that idea and flips the memory model — all members share the same block of memory, which means you get type-reinterpretation and memory efficiency at the cost of only being able to use one member at a time. These aren't just syntax features; they're tools that let you model the real world accurately in code.

By the end of this article you'll understand exactly how struct and union memory layouts work, when each is the right tool, how to combine them for practical patterns like tagged unions, and the exact mistakes that trip up even experienced C developers. You'll also be able to confidently answer the interview questions that separate candidates who've read about C from those who've actually used it.

A struct (short for structure) lets you define a composite data type — a single named container that holds multiple members, each with its own type. The compiler allocates memory for every member independently, so all fields exist simultaneously and can be read or written in any order.

The real power isn't just convenience — it's that a struct becomes a first-class type. You can pass it to functions, return it, put it in arrays, and point to it. This lets you model domain concepts directly. A 'Player' struct isn't just three variables that happen to be related; it's a single coherent entity your code can reason about.

Under the hood, struct members are laid out sequentially in memory, but the compiler is allowed to insert padding bytes between members to satisfy alignment requirements of the target CPU. This means sizeof(struct Player) might be larger than you expect, and it's the first thing you need to internalise before you do anything serious with structs in systems programming or binary file I/O.

Use structs whenever you have data that naturally belongs together and needs all its fields present at the same time — think database records, configuration objects, game entities, or network packet headers.

player_struct.c · C
1234567891011121314151617181920212223242526272829303132333435363738394041424344
#include <stdio.h>
#include <string.h>

/**
 * io.thecodeforge demonstration: Professional Struct Usage
 */
typedef struct {
    char  username[32];  
    int   health;        
    float position_x;   
    float position_y;   
    int   score;         
} Player;

void print_player_status(Player p) {
    printf("--- Player Status ---\n");
    printf("Username : %s\n",  p.username);
    printf("Health   : %d\n",  p.health);
    printf("Position : (%.1f, %.1f)\n", p.position_x, p.position_y);
    printf("Score    : %d\n",  p.score);
}

void apply_damage(Player *p, int damage_amount) {
    if (p == NULL) return;
    p->health -= damage_amount;
    if (p->health < 0) p->health = 0;
}

int main(void) {
    Player hero = {
        .username   = "Aria_Stormblade",
        .health     = 100,
        .position_x = 12.5f,
        .position_y = 7.0f,
        .score      = 0
    };

    apply_damage(&hero, 35);
    hero.score += 500;
    print_player_status(hero);

    printf("\nTotal struct footprint: %zu bytes\n", sizeof(Player));
    return 0;
}
▶ Output
--- Player Status ---
Username : Aria_Stormblade
Health : 65
Position : (12.5, 7.0)
Score : 500

Total struct footprint: 48 bytes
💡Pro Tip: Prefer Designated Initialisers
Using .fieldname = value syntax (C99+) instead of positional initialisation means adding a new field to your struct won't silently corrupt all your existing initialisers. It also makes the code self-documenting — you can see exactly which field each value maps to without counting commas.

Memory Layout and Padding — Why sizeof Surprises You

This is the section most tutorials skip, and it's the one that causes the most real-world bugs. CPUs are picky about alignment — a 4-byte int wants to live at a memory address that's divisible by 4. A double wants an address divisible by 8. When the compiler lays out struct members sequentially, it inserts invisible padding bytes to honour these constraints.

Consider a struct with a char (1 byte) followed by an int (4 bytes). The char sits at offset 0, but the int needs to start at offset 4 — so 3 bytes of padding are inserted silently. The struct's total size also gets padded at the end so that arrays of the struct keep every element aligned.

This matters enormously in three situations: serialising structs to binary files or network packets (padding bytes contain garbage), computing offsets manually, and squeezing memory in embedded systems. The fix in the first two cases is either reordering your members largest-to-smallest (which often eliminates padding naturally) or using __attribute__((packed)) / #pragma pack — but only when you truly need it, because unaligned access is slower on most architectures and outright illegal on some.

struct_padding_demo.c · C
123456789101112131415161718192021222324252627
#include <stdio.h>
#include <stddef.h> 

typedef struct {
    char   is_active;    // 1 byte
    // 3 bytes padding
    int    user_id;      // 4 bytes
    char   grade;        // 1 byte
    // 7 bytes padding
    double score;        // 8 bytes
} StudentUnoptimised;    // 24 bytes total

typedef struct {
    double score;        // 8 bytes
    int    user_id;      // 4 bytes
    char   is_active;    // 1 byte
    char   grade;        // 1 byte
    // 2 bytes padding at end
} StudentOptimised;      // 16 bytes total

int main(void) {
    printf("Unoptimised size: %zu bytes\n", sizeof(StudentUnoptimised));
    printf("Optimised size:   %zu bytes\n", sizeof(StudentOptimised));
    printf("Memory saved:     %zu bytes per instance\n", 
           sizeof(StudentUnoptimised) - sizeof(StudentOptimised));
    return 0;
}
▶ Output
Unoptimised size: 24 bytes
Optimised size: 16 bytes
Memory saved: 8 bytes per instance
⚠ Watch Out: Never memcpy a padded struct over a network
Padding bytes are uninitialised — they hold whatever garbage was in memory. If you send a struct directly over a socket or write it to a binary file, the receiver may read different values depending on the platform. Always serialise field-by-field, or use a packed struct only for the wire/file format and copy into a normal struct for local processing.

Unions: One Memory Location, Many Interpretations

A union looks syntactically identical to a struct but operates on a completely different principle: all members share the same starting address and the same block of memory. The union's size equals the size of its largest member. Writing to one member and reading from a different one reinterprets the raw bytes — which is either a powerful tool or a disaster, depending on whether you do it intentionally.

The classic legitimate use cases are: type-punning (reinterpreting the raw bytes of a float as a uint32_t, for example), memory-mapped hardware registers where the same address has different meanings, and building tagged unions (also called discriminated unions) where a type tag tells you which member is currently valid.

The illegitimate use — writing member A and reading member B expecting a meaningful 'conversion' — is undefined behaviour in C for most type combinations. The exception is char/unsigned char, which you're always allowed to use to inspect raw bytes.

network_packet_union.c · C
123456789101112131415161718192021222324252627282930
#include <stdio.h>
#include <stdint.h>

typedef union {
    uint32_t as_integer;
    uint8_t  octets[4];
} IPv4Address;

typedef enum { TEMP, PRESS, HUMID } SensorType;

typedef struct {
    SensorType type;
    union {
        float   celsius;
        float   hpa;
        uint8_t percent;
    } reading;
} SensorReading;

int main(void) {
    IPv4Address addr;
    addr.as_integer = 0xC0A80101; // 192.168.1.1

    printf("IP: %u.%u.%u.%u\n", addr.octets[3], addr.octets[2], addr.octets[1], addr.octets[0]);
    
    SensorReading s = { .type = TEMP, .reading.celsius = 25.5f };
    printf("Reading: %.1f C\n", s.reading.celsius);
    
    return 0;
}
▶ Output
IP: 192.168.1.1
Reading: 25.5 C
🔥Interview Gold: The Tagged Union Pattern
A tagged union (struct containing an enum tag + a union) is C's version of a type-safe variant. Interviewers love asking how you'd implement a value that can be one of several types — this is the answer. It's also the foundation of how compilers represent AST nodes and how SQLite stores its dynamic column types internally.

Combining Structs and Unions — Building Real Data Structures

In production C code, structs and unions almost always appear together. A pure union with no tag is hard to use safely. A struct with no unions is sometimes wasteful. Combine them and you get expressive, memory-efficient data models.

A common real-world pattern is a variant record — a struct that represents one of several possible entity types, where the correct interpretation depends on a discriminator field. This pattern powers everything from protocol buffer implementations to expression trees in compilers.

Another key pattern is bit fields inside structs, which let you pack boolean flags and small integers into individual bits rather than full bytes. This is critical in embedded systems where a microcontroller might have only 2KB of RAM.

expression_tree.c · C
12345678910111213141516171819202122232425262728293031323334353637383940414243
#include <stdio.h>
#include <stdlib.h>

typedef enum { NODE_NUM, NODE_OP } NodeType;

typedef struct ExprNode ExprNode;
struct ExprNode {
    NodeType type;
    union {
        int val;
        struct {
            char op;
            ExprNode *left;
            ExprNode *right;
        } bin;
    } data;
};

ExprNode* make_num(int n) {
    ExprNode *node = malloc(sizeof(ExprNode));
    node->type = NODE_NUM; node->data.val = n;
    return node;
}

int eval(ExprNode *n) {
    if (n->type == NODE_NUM) return n->data.val;
    int l = eval(n->data.bin.left), r = eval(n->data.bin.right);
    return (n->data.bin.op == '+') ? l + r : l * r;
}

int main(void) {
    // (2 + 3) * 4
    ExprNode *add = malloc(sizeof(ExprNode));
    add->type = NODE_OP; add->data.bin.op = '+';
    add->data.bin.left = make_num(2); add->data.bin.right = make_num(3);

    ExprNode *root = malloc(sizeof(ExprNode));
    root->type = NODE_OP; root->data.bin.op = '*';
    root->data.bin.left = add; root->data.bin.right = make_num(4);

    printf("Result: %d\n", eval(root));
    return 0;
}
▶ Output
Result: 20
💡Pro Tip: Hide Union Complexity Behind Constructor Functions
Never let callers manually set a union member without also setting the tag — that's how you get type confusion bugs that don't surface until 3 months later in production. Wrap every union-containing struct in small constructor functions (like make_number and make_binary above) that set both the tag and the member together atomically. This pattern is called an opaque factory and it's how every serious C codebase handles variants.
Feature / Aspectstructunion
Memory allocationEach member gets its own dedicated memory slotAll members share a single memory block
Total sizeSum of all member sizes + padding bytesSize of the largest single member
Simultaneous membersAll members are valid and accessible at all timesOnly the last-written member is valid
Primary use caseGrouping related data that all needs to coexistType-punning, variant types, memory-mapped registers
SafetyInherently safe — no conflicts between membersUnsafe unless paired with a type tag (discriminator)
Padding behaviourPadding inserted between members for alignmentPadding added only at the end to round up to largest member's alignment
Array of elementsCommon and straightforward — each element is independentPossible but unusual — all elements share the same size
Nested usageCan contain unions as members (tagged union pattern)Can contain structs as members (anonymous struct inside union)
Typical domainsApplication data models, protocol headers, game entitiesEmbedded systems, compilers, network protocol parsers

🎯 Key Takeaways

  • A struct allocates independent memory for every member — all fields coexist. A union allocates memory for only its largest member — all fields overlap. This single difference defines every use case for each.
  • The compiler inserts silent padding bytes between struct members for CPU alignment. Reordering fields largest-to-smallest typically reduces or eliminates padding, which matters at scale and in embedded systems.
  • A bare union is almost always a bug waiting to happen. Always pair a union with an enum tag inside a struct — this creates a tagged union (discriminated union) that's the only safe pattern for using unions in application code.
  • Never memcpy or memcmp raw structs across a network boundary or to a binary file — padding bytes hold uninitialised garbage. Serialise field-by-field or zero-initialise the entire struct with = {0} before populating it.

⚠ Common Mistakes to Avoid

    Reading a union member you didn't write to — If you write to union.float_value and then read union.int_value expecting an implicit conversion, you'll get the raw bit reinterpretation of the float, not a converted integer. This is undefined behaviour for most type pairs and produces wildly wrong results. Fix: always track which member is active using a tag enum alongside the union, and only read the member that matches the current tag.
    Fix

    always track which member is active using a tag enum alongside the union, and only read the member that matches the current tag.

    Using memcmp or memcpy on padded structs for equality checks or serialisation — Padding bytes contain whatever garbage was in memory at the time the struct was allocated. Two structs with identical field values but different padding garbage will fail a memcmp equality check. Sending the raw struct over a network or to a file transmits that garbage too. Fix: either use a designated initialiser with = {0} to zero-initialise the entire struct including padding, or write field-by-field serialisation functions that never touch padding.
    Fix

    either use a designated initialiser with = {0} to zero-initialise the entire struct including padding, or write field-by-field serialisation functions that never touch padding.

    Assuming a pointer to a struct and a pointer to its first member are always the same — While the C standard guarantees that a pointer to a struct and a pointer to its first member have the same address value, casting between unrelated struct pointer types and reading through the wrong type is undefined behaviour. This bites people who try to implement polymorphism by casting between struct types that merely happen to share a common first field. Fix: use a proper tagged union or an explicit void* with a type tag rather than relying on undefined pointer casting behaviour.
    Fix

    use a proper tagged union or an explicit void* with a type tag rather than relying on undefined pointer casting behaviour.

Interview Questions on This Topic

  • QExplain memory alignment and padding. Why might a struct containing a char and a double occupy 16 bytes instead of 9?
  • QImplement a 'Tagged Union' to represent a generic Shape that can be either a Circle (radius) or a Rectangle (width, height). Write an area() function for it.
  • QHow do you minimize memory usage in a struct without using bit-fields or compiler-specific pragmas?
  • QWhat is the difference between a 'packed' struct and a standard struct, and what are the performance trade-offs of using 'packed'?
  • QWhat is the output of sizeof(U) if union U { int a; double b; char c[10]; }? Explain the logic involving alignment requirements.

Frequently Asked Questions

What is the difference between a struct and a union in C?

A struct allocates separate memory for each member, so all fields exist simultaneously and can be read or written independently. A union allocates one shared block of memory sized for its largest member, meaning only one member holds a valid value at any given time. Structs model entities with multiple concurrent properties; unions model a single value that can be interpreted as different types.

Why is sizeof(struct) larger than the sum of its members?

The compiler inserts padding bytes between struct members to satisfy CPU alignment requirements — for example, a 4-byte int must start at an address divisible by 4. There may also be trailing padding at the end so that arrays of the struct keep each element correctly aligned. You can see exact offsets using the offsetof macro from stddef.h.

Can I use a union to convert between types, like writing a float and reading an int?

This is called type-punning and the rules are nuanced. In C, reading a union member that wasn't the last one written is technically undefined behaviour for most type combinations, meaning the compiler is not required to give you a predictable result. The one guaranteed exception is reading through an unsigned char array, which always gives you the raw bytes. For deliberate type-punning (like inspecting the bit pattern of a float), use memcpy into an unsigned char buffer instead — it's always defined behaviour and modern compilers optimise it to zero overhead.

How do bit-fields work within a C struct?

Bit-fields allow you to specify the exact number of bits each member should occupy. For example, 'int flag : 1;' allocates exactly 1 bit for that integer. This is highly useful for mapping hardware registers or saving memory on boolean flags, though it can impact access speed due to the extra CPU instructions required to mask and shift bits.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousPointer Arithmetic in CNext →Memory Management in C — malloc calloc free
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged