Structs and Unions in C

Structs and unions are how C lets you group multiple values into a single unit. C has no classes, no objects in the OOP sense - structs (and, more rarely, unions) are the foundation almost everything else in "real-world" C data modeling is built on: linked lists, trees, network packets, hardware registers, even simple polymorphism.

This guide is split into two parts: Part 1 covers structs (sections 1.1–1.18), Part 2 covers unions (sections 2.1–2.6), and a short bridging comparison sits at the start of Part 2 since it needs both to make sense.


0. Why they're needed, why industry leans on them, and why your syllabus barely touches half of this

Why they're needed at all

A single int or char can only hold one value. The moment you need to represent something with multiple related pieces of data - a point with an x and y, a student with a name and a GPA, a network packet with a header and a payload - you need a way to bundle those pieces together and treat them as one thing. That's exactly what a struct gives you: a custom, named grouping of variables (called members or fields) that travel together as a single type.

A union solves a different problem: sometimes you want one piece of memory to be interpreted as different types at different times - reading raw bytes as either a float or an int, for example - without wasting space storing both.

Why real projects lean on them so heavily

Why your syllabus (probably) stopped early

Most BSc courses cover "declare a struct, access .member, maybe use -> with a pointer" and stop there - for similar reasons to the macro gap:

And yes - this is another one of those "quietly very cool" corners of C. Once you see a tagged union acting like a mini type-safe variant, or a struct overlaying raw bytes off the network exactly onto named fields, C starts to feel a lot less limited than it's given credit for.


Part 1: Structs

1.1 Declaring and using a basic struct

A struct groups named variables (members) into one type.

#include <stdio.h>

struct Point {
    int x;
    int y;
};

int main(void) {
    struct Point p;
    p.x = 3;
    p.y = 4;
    printf("(%d, %d)\n", p.x, p.y);
    return 0;
}

Note that in plain C (unlike C++), you must write struct Point, not just Point, when declaring a variable - unless you use typedef (section 1.3).


1.2 Initializing structs

You can initialize members at declaration time, either positionally or with designated initializers (which are clearer and don't depend on member order).

struct Point {
    int x;
    int y;
};

int main(void) {
    struct Point a = {3, 4};             // positional: x=3, y=4
    struct Point b = {.y = 10, .x = 5};  // designated: order doesn't matter
    struct Point c = {0};                // zero-initializes ALL members

    printf("%d %d\n", a.x, a.y);
    printf("%d %d\n", b.x, b.y);
    printf("%d %d\n", c.x, c.y);         // 0 0
    return 0;
}

{0} is the standard idiom for zero-initializing an entire struct, regardless of how many members it has.


1.3 typedef with structs

typedef lets you drop the struct keyword when declaring variables - almost universal in real-world C.

typedef struct {
    int x;
    int y;
} Point;

/* Equivalent, and needed if the struct must also refer to its own name
   (see section 1.13, self-referential structs): */
typedef struct Point2 {
    int x;
    int y;
} Point2;

int main(void) {
    Point p = {1, 2};        // no "struct" keyword needed
    Point2 q = {3, 4};       // works the same way
    printf("%d %d | %d %d\n", p.x, p.y, q.x, q.y);
    return 0;
}

1.4 Nested structs

A struct can contain another struct as a member.

typedef struct {
    int x;
    int y;
} Point;

typedef struct {
    Point topLeft;
    Point bottomRight;
} Rectangle;

int main(void) {
    Rectangle r = { .topLeft = {0, 0}, .bottomRight = {100, 50} };
    printf("width = %d, height = %d\n",
           r.bottomRight.x - r.topLeft.x,
           r.bottomRight.y - r.topLeft.y);
    return 0;
}

1.5 Arrays of structs

Structs work naturally as array elements - one of the most common patterns in real code (a table of records, a list of entities, etc.).

typedef struct {
    char name[32];
    int score;
} Student;

int main(void) {
    Student class[3] = {
        {"Alice", 90},
        {"Bob", 75},
        {"Cara", 88},
    };

    for (int i = 0; i < 3; i++) {
        printf("%s: %d\n", class[i].name, class[i].score);
    }
    return 0;
}

1.6 Pointers to structs and the -> operator

Accessing members through a pointer uses -> instead of . - this is just shorthand for (*ptr).member.

typedef struct {
    int x;
    int y;
} Point;

int main(void) {
    Point p = {3, 4};
    Point *ptr = &p;

    printf("%d\n", (*ptr).x);  // works, but clunky
    printf("%d\n", ptr->x);    // preferred: identical meaning

    ptr->x = 100;              // modifies the original p through the pointer
    printf("%d\n", p.x);       // prints 100
    return 0;
}

1.7 Passing structs to functions: by value vs. by pointer

Passing a struct by value copies the entire thing - fine for small structs, wasteful (and unable to modify the original) for large ones. Passing by pointer avoids the copy and lets the function modify the caller's data.

typedef struct {
    int x;
    int y;
} Point;

/* By value: gets a COPY. Changes here do not affect the caller's struct. */
void tryToMove(Point p) {
    p.x += 10;
}

/* By pointer: modifies the caller's actual struct. */
void move(Point *p, int dx, int dy) {
    p->x += dx;
    p->y += dy;
}

int main(void) {
    Point a = {0, 0};

    tryToMove(a);
    printf("%d %d\n", a.x, a.y);   // still 0 0 -- unaffected

    move(&a, 10, 5);
    printf("%d %d\n", a.x, a.y);   // 10 5 -- actually changed
    return 0;
}

Rule of thumb: pass small structs (a couple of ints) by value if you don't need to modify them; pass anything larger, or anything you need to modify, by pointer.


1.8 Struct padding and alignment

The compiler is free to insert unused padding bytes between struct members so that each member sits at a memory address that's a multiple of its own alignment requirement (usually its size, for basic types). This means sizeof(struct) is often larger than the sum of its members' sizes.

#include <stdio.h>

struct Bad {
    char a;     // 1 byte
    int b;      // 4 bytes -- needs 4-byte alignment
    char c;     // 1 byte
};              // likely sizeof == 12 (1 + 3 padding + 4 + 1 + 3 padding)

struct Good {
    int b;      // 4 bytes
    char a;     // 1 byte
    char c;     // 1 byte
};              // likely sizeof == 8 (4 + 1 + 1 + 2 padding)

int main(void) {
    printf("sizeof(struct Bad) = %zu\n", sizeof(struct Bad));   // e.g. 12
    printf("sizeof(struct Good) = %zu\n", sizeof(struct Good)); // e.g. 8
    return 0;
}

Ordering members from largest to smallest generally minimizes padding. This matters a lot when you have large arrays of structs, or when a struct's exact byte layout needs to match an external format (see section 1.14).


1.9 Bit-fields

You can tell the compiler to pack a member into a specific number of bits, useful for flags or matching a hardware/protocol layout that's specified in bits rather than bytes.

#include <stdio.h>

struct Flags {
    unsigned int isReadable : 1;
    unsigned int isWritable : 1;
    unsigned int isExecutable : 1;
    unsigned int permissionLevel : 4;  // 0-15
};

int main(void) {
    struct Flags f = {0};
    f.isReadable = 1;
    f.isWritable = 1;
    f.permissionLevel = 7;

    printf("r=%u w=%u x=%u level=%u\n",
           f.isReadable, f.isWritable, f.isExecutable, f.permissionLevel);
    printf("sizeof = %zu\n", sizeof(f));  // likely 4, not 4 separate ints' worth
    return 0;
}

Caveats: the exact memory layout of bit-fields (bit order, whether they cross byte boundaries) is implementation-defined, so they're not portable for cross-platform binary formats - for that, use explicit masking and shifting instead.


1.10 Flexible array members

A struct's last member can be declared as an array with no size, allowing a single malloc to allocate the struct together with a variable-length trailing buffer, avoiding a second allocation and an extra pointer indirection.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    int length;
    char data[];   // flexible array member -- must be last, no size given
} Buffer;

int main(void) {
    const char *text = "hello";
    size_t len = strlen(text);

    /* Allocate the struct PLUS len+1 bytes for data, in one allocation. */
    Buffer *buf = malloc(sizeof(Buffer) + len + 1);
    buf->length = (int)len;
    memcpy(buf->data, text, len + 1);

    printf("length=%d data=%s\n", buf->length, buf->data);

    free(buf);
    return 0;
}

sizeof(Buffer) does not include the flexible array member's contents - only the fixed part of the struct.


1.11 Comparing structs - why == doesn't work, and the padding trap

C has no built-in structural equality for structs; == on struct values isn't even legal syntax. You either compare members manually, or use memcmp - but memcmp has a subtle trap involving padding bytes.

#include <stdio.h>
#include <string.h>

struct Point {
    int x;
    int y;
};

int pointsEqual(struct Point a, struct Point b) {
    return a.x == b.x && a.y == b.y;   // safe: compares real values only
}

struct Padded {
    char a;
    int b;   // padding bytes exist between a and b, with indeterminate content
};

int main(void) {
    struct Point p1 = {1, 2}, p2 = {1, 2};
    // if (p1 == p2) ...   // COMPILE ERROR -- not legal C

    printf("%d\n", pointsEqual(p1, p2));  // 1 (correct way)

    struct Padded x = {0}, y = {0};
    x.a = 'A'; x.b = 5;
    y.a = 'A'; y.b = 5;
    /* memcmp COULD report these as different, because the padding bytes
       between 'a' and 'b' are not guaranteed to be zero or consistent --
       only the member VALUES are guaranteed equal here, not every byte. */
    printf("%d\n", memcmp(&x, &y, sizeof(x)) == 0);  // not guaranteed to be 1!
    return 0;
}

Prefer writing an explicit member-by-member comparison function; only use memcmp on structs you've deliberately zero-initialized with {0} first (which at least makes the padding consistent between two separately zeroed instances) or on structs you've confirmed have no padding.


1.12 Copying structs

Plain assignment (=) performs a full member-by-member copy of a struct - no special syntax needed, unlike arrays (which decay to pointers and can't be assigned directly).

#include <stdio.h>
#include <string.h>

typedef struct {
    int id;
    char name[32];
} Record;

int main(void) {
    Record a = {1, "Alice"};
    Record b = a;              // full copy -- a and b are now independent

    strcpy(b.name, "Bob");
    printf("%s %s\n", a.name, b.name);  // Alice Bob -- a untouched

    Record c;
    memcpy(&c, &a, sizeof(Record));     // equivalent manual copy
    printf("%s\n", c.name);             // Alice

    return 0;
}

Caution: if a struct contains a pointer (e.g. to heap-allocated memory), = only copies the pointer value, not what it points to - this is a "shallow copy" and both structs will point at the same memory.


1.13 Self-referential structs - linked lists and trees

A struct can contain a pointer to its own type (but not a full instance of its own type - that would be an infinitely-sized structure). This is the basis of every linked data structure in C.

#include <stdio.h>
#include <stdlib.h>

typedef struct Node {
    int value;
    struct Node *next;   // pointer to the SAME struct type -- this is fine,
                          // because a pointer has a fixed size regardless
                          // of what it points to
} Node;

int main(void) {
    Node *head = malloc(sizeof(Node));
    head->value = 1;
    head->next = malloc(sizeof(Node));
    head->next->value = 2;
    head->next->next = NULL;

    for (Node *cur = head; cur != NULL; cur = cur->next) {
        printf("%d ", cur->value);
    }
    printf("\n");

    free(head->next);
    free(head);
    return 0;
}

1.14 Controlling layout: #pragma pack and alignas

When a struct's byte layout must exactly match an external format (a file format, a network packet, a hardware register map), you need to override the compiler's default padding behavior.

#include <stdio.h>
#include <stdalign.h>

/* Force NO padding between members -- every byte matches the wire format
   exactly. Non-standard but supported by GCC, Clang, and MSVC. */
#pragma pack(push, 1)
struct PacketHeader {
    unsigned char version;
    unsigned short length;
    unsigned int checksum;
};
#pragma pack(pop)

/* Standard C11 way to force a specific alignment, e.g. for SIMD or
   cache-line alignment. */
struct alignas(16) AlignedBuffer {
    float data[4];
};

int main(void) {
    printf("packed sizeof = %zu (would be larger with default padding)\n",
           sizeof(struct PacketHeader));
    printf("alignof(AlignedBuffer) = %zu\n", alignof(struct AlignedBuffer));
    return 0;
}

#pragma pack is compiler-specific syntax (though very widely supported); _Alignas/alignas (via <stdalign.h>) is the standard C11 way to request a minimum alignment.


1.15 offsetof and container_of

offsetof (from <stddef.h>) computes a member's byte offset within a struct. container_of (built on offsetof) recovers a pointer to the whole struct from a pointer to one of its members - this is exactly how intrusive linked lists (used throughout the Linux kernel) work, avoiding a separate allocation for each list node.

#include <stdio.h>
#include <stddef.h>

#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

typedef struct {
    int x, y;
} Position;

typedef struct {
    char name[32];
    Position pos;   // embedded, not a pointer
} Entity;

int main(void) {
    Entity e = { .name = "Player", .pos = {5, 10} };

    Position *posPtr = &e.pos;   // imagine this is all some other code has

    /* Recover the owning Entity* from just the Position* */
    Entity *owner = container_of(posPtr, Entity, pos);
    printf("owner name = %s\n", owner->name);

    printf("offsetof(Entity, pos) = %zu\n", offsetof(Entity, pos));
    return 0;
}

1.16 const with structs and struct members

const can apply to an entire struct instance, or to individual members, with different implications for what you can do with it afterward.

#include <stdio.h>

typedef struct {
    int id;
    const char *name;   // the chars pointed to are read-only through
                          // this pointer; the pointer itself is not const
} Record;

int main(void) {
    const Record r = {1, "Alice"};   // the whole struct is read-only
    // r.id = 2;                     // COMPILE ERROR -- r is const

    Record s = {2, "Bob"};
    // s.name[0] = 'X';              // COMPILE ERROR -- name points to const char
    s.name = "Charlie";              // OK -- reassigning the pointer itself is fine

    printf("%d %s\n", s.id, s.name);
    return 0;
}

Passing const struct T * to functions is the standard way to say "this function reads the struct but won't modify it" without paying for a copy.


1.17 Function pointers in structs - simulating methods and vtables

A struct can hold function pointers, letting different instances carry different behavior - this is literally how C fakes object-oriented polymorphism (and is exactly what the Linux kernel's driver interfaces are built from). This example also uses a tagged union (introduced properly in section 2.3) to hold each shape's specific data.

#include <stdio.h>

typedef struct Shape {
    float (*area)(const struct Shape *self);
    union {
        struct { float radius; } circle;
        struct { float w, h; } rectangle;
    };
} Shape;

float circleArea(const Shape *self) {
    return 3.14159f * self->circle.radius * self->circle.radius;
}

float rectangleArea(const Shape *self) {
    return self->rectangle.w * self->rectangle.h;
}

int main(void) {
    Shape c = { .area = circleArea, .circle = {.radius = 2.0f} };
    Shape r = { .area = rectangleArea, .rectangle = {.w = 3.0f, .h = 4.0f} };

    Shape *shapes[2] = {&c, &r};
    for (int i = 0; i < 2; i++) {
        /* Calling through the function pointer -- this IS virtual dispatch */
        printf("area = %f\n", shapes[i]->area(shapes[i]));
    }
    return 0;
}

1.18 Compound literals - anonymous struct values inline

C99 compound literals let you create a temporary, unnamed struct value inline - useful for passing a one-off struct to a function without a separate named variable.

#include <stdio.h>

typedef struct {
    int x, y;
} Point;

void printPoint(Point p) {
    printf("(%d, %d)\n", p.x, p.y);
}

int main(void) {
    printPoint((Point){.x = 3, .y = 4});   // compound literal, no named variable needed

    Point *p = &(Point){10, 20};           // even a pointer to a compound literal works
    printf("%d %d\n", p->x, p->y);

    return 0;
}

Part 2: Unions

2.0 Struct vs. union - side by side

Before diving into union-specific features, it helps to see the core difference laid out directly, since unions only make sense in contrast to the structs from Part 1.

#include <stdio.h>

struct S {
    int i;
    float f;
    char c;
};

union U {
    int i;
    float f;
    char c;
};

int main(void) {
    printf("struct: each member has its own space -> sizeof = %zu\n", sizeof(struct S));
    printf("union: all members share space   -> sizeof = %zu\n", sizeof(union U));

    struct S s = {1, 2.0f, 'a'};
    printf("struct: s.i=%d s.f=%f s.c=%c (all valid at once)\n", s.i, s.f, s.c);

    union U u;
    u.i = 1;
    printf("union: u.i=%d (only the most recently written member is valid)\n", u.i);

    return 0;
}
structunion
Memoryeach member gets its own spaceall members share the same space
Sizesum of members (plus padding)size of the largest member
Valid membersall members valid simultaneouslyonly the last-written member is valid
Use casegrouping unrelated data togetherone value, multiple possible interpretations

2.1 Declaring and using a basic union

A union looks just like a struct syntactically, but all members share the same memory - the union's size is the size of its largest member, not the sum of all of them.

#include <stdio.h>

union Value {
    int i;
    float f;
    char c;
};

int main(void) {
    union Value v;

    v.i = 65;
    printf("as int: %d\n", v.i);      // 65

    v.f = 3.14f;
    printf("as float: %f\n", v.f);    // 3.14
    // v.i is now garbage -- writing v.f overwrote the same bytes v.i used

    printf("sizeof(union Value) = %zu\n", sizeof(union Value)); // size of float (4), not int+float+char
    return 0;
}

2.2 Union memory layout and type punning

Because all members overlap the same bytes, writing one member and reading a different one lets you reinterpret the same bits as a different type - called type punning. This is technically undefined behavior in strict ISO C (though GCC/Clang explicitly support it as a common extension), so treat it as "works in practice on mainstream compilers," not standard guaranteed behavior.

#include <stdio.h>

union FloatBits {
    float f;
    unsigned int bits;
};

int main(void) {
    union FloatBits fb;
    fb.f = 1.0f;

    /* Reading .bits after writing .f reinterprets the same 4 bytes as
       an unsigned int -- showing the raw IEEE-754 bit pattern of 1.0f. */
    printf("1.0f as raw bits: 0x%08X\n", fb.bits);

    return 0;
}

The portable, standard-compliant way to do the equivalent reinterpretation is memcpy between same-sized types, which compilers optimize down to the same instructions anyway.


2.3 Tagged unions - C's stand-in for variant/sum types

A union alone doesn't know which member was last written. The standard pattern is to pair it with an enum "tag" so you always know how to interpret the union - this is how C fakes the tagged unions/sum types that languages like Rust or Haskell have natively.

#include <stdio.h>

typedef enum { SHAPE_CIRCLE, SHAPE_RECTANGLE } ShapeType;

typedef struct {
    ShapeType type;
    union {
        struct { float radius; } circle;
        struct { float width, height; } rectangle;
    };
} Shape;

float area(const Shape *s) {
    switch (s->type) {
        case SHAPE_CIRCLE:
            return 3.14159f * s->circle.radius * s->circle.radius;
        case SHAPE_RECTANGLE:
            return s->rectangle.width * s->rectangle.height;
    }
    return 0.0f;
}

int main(void) {
    Shape shapes[2] = {
        { .type = SHAPE_CIRCLE, .circle = {.radius = 2.0f} },
        { .type = SHAPE_RECTANGLE, .rectangle = {.width = 3.0f, .height = 4.0f} },
    };

    for (int i = 0; i < 2; i++) {
        printf("shape %d area = %f\n", i, area(&shapes[i]));
    }
    return 0;
}

This costs sizeof(largest variant) + sizeof(tag) instead of sizeof(every variant added together) - the whole point of using a union instead of just putting all the fields directly in the struct.


2.4 Anonymous unions (C11)

C11 allows a union member with no name, whose members are then accessed as if they belonged directly to the containing struct - useful for things like a vector type that can be accessed both as .x/.y/.z and as an array, without an extra name in between.

#include <stdio.h>

typedef struct {
    union {
        struct { float x, y, z; };  // anonymous struct inside anonymous union
        float coords[3];
    };
} Vector3;

int main(void) {
    Vector3 v;
    v.x = 1.0f;
    v.y = 2.0f;
    v.z = 3.0f;

    printf("%f %f %f\n", v.coords[0], v.coords[1], v.coords[2]);
    return 0;
}

This is also what made the tagged union in section 2.3 readable as s->circle.radius instead of s->data.circle.radius - the union member itself had no name.


2.5 Unions for parsing raw bytes (protocols and file formats)

A very common real-world pattern: overlay a type directly onto a raw byte buffer received from a network socket or file, letting you read fields by name instead of manually indexing into a byte array. The safe way to do this is a packed struct plus memcpy (rather than a union or a raw pointer cast), since it avoids both alignment-fault risk on strict CPUs and strict-aliasing undefined behavior.

#include <stdio.h>
#include <string.h>
#include <stdint.h>

#pragma pack(push, 1)
typedef struct {
    uint8_t  version;
    uint16_t payloadLength;   // stored big-endian on the wire, for example
} PacketHeader;
#pragma pack(pop)

int main(void) {
    /* Simulate 3 raw bytes received from a socket: version=1, length=256 */
    unsigned char rawBytes[3] = {0x01, 0x01, 0x00};

    PacketHeader header;
    memcpy(&header, rawBytes, sizeof(header));   // safe way to reinterpret raw bytes

    printf("version = %u\n", header.version);
    printf("payloadLength (raw, endianness not yet handled) = %u\n",
           header.payloadLength);
    return 0;
}

If you do want to use an actual union for this (some codebases do, for convenience, accepting the type-punning caveat from section 2.2), it looks like this instead:

#include <stdio.h>
#include <stdint.h>

#pragma pack(push, 1)
typedef union {
    uint8_t raw[3];
    struct {
        uint8_t  version;
        uint16_t payloadLength;
    } fields;
} PacketUnion;
#pragma pack(pop)

int main(void) {
    PacketUnion p;
    p.raw[0] = 0x01;
    p.raw[1] = 0x01;
    p.raw[2] = 0x00;

    /* Reading .fields after writing .raw is type punning -- works on
       GCC/Clang in practice, but is the union-based (less portable)
       alternative to the memcpy approach above. */
    printf("version = %u\n", p.fields.version);
    return 0;
}

2.6 Common pitfalls checklist


Quick reference

Structs (Part 1)

FeatureSyntaxPurposeSection
Struct declarationstruct Name { ... };Group related variables into one type1.1
typedef structtypedef struct { ... } Name;Drop the struct keyword at use sites1.3
Member access.memberAccess a member of a struct value1.1
Pointer member access->memberAccess a member through a pointer1.6
Designated initializer{.field = value}Initialize specific members by name1.2
Zero-init{0}Zero every member of a struct1.2
Nested structstruct containing another structCompose larger records from smaller ones1.4
Array of structsType arr[N]A table/list of records1.5
By-value parametervoid f(Type t)Function gets a full copy1.7
By-pointer parametervoid f(Type *t)Function can modify caller's data, no copy1.7
Padding(implicit)Compiler-inserted bytes for member alignment1.8
Bit-fieldunsigned int flag : 1;Pack a member into a specific number of bits1.9
Flexible array memberType data[]; (last member)Variable-length trailing data in one allocation1.10
#pragma pack#pragma pack(push, 1)Force a specific (often zero) byte alignment1.14
alignasstruct alignas(16) T { ... };Standard way to force a minimum alignment (C11)1.14
offsetofoffsetof(Type, member)Byte offset of a member within a struct1.15
container_ofmacro built on offsetofRecover owning struct pointer from a member pointer1.15
Compound literal(Type){...}Anonymous, inline struct value1.18
Function pointer memberRetType (*fn)(Args)Simulate methods / virtual dispatch1.17

Unions (Part 2)

FeatureSyntaxPurposeSection
Union declarationunion Name { ... };Overlap several types on the same memory2.1
Type punningwrite one member, read anotherReinterpret the same bytes as a different type2.2
Tagged unionenum tag + unionC's stand-in for a type-safe variant/sum type2.3
Anonymous unionunnamed nested unionAccess nested members without an extra name (C11)2.4
Raw byte parsingpacked struct/union + memcpyOverlay a type onto a fixed external byte layout2.5

Coverage note

This guide covers struct and union declaration, initialization, memory layout, alignment and packing, bit-fields, self-referential and nested structures, type punning, tagged unions, and function-pointer-based polymorphism - the full range of what you'll encounter in real-world systems, embedded, and application C code. If you've read all of Part 1 and Part 2, you have everything needed to design, lay out, and debug any struct or union you'll run into.

Previous:
Macros in C
Next:
Pointers in C