Structures, Unions and Type Aliases

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

struct is how C builds aggregate types: a fixed set of named members laid out in declaration order. union overlays members in the same storage. Together with typedef they are the whole of C’s type-construction machinery — there is no inheritance and no methods, only composition and function pointers.

Declaring and Initializing a struct

#include <stdio.h>

struct Point {              // "struct Point" is the type name; Point alone is the TAG
    double x;
    double y;
};

int main(void)
{
    struct Point a = { 3.0, 4.0 };                  // positional
    struct Point b = { .x = 1.0, .y = 2.0 };        // designated -- prefer this
    struct Point origin = { };                      // C23 empty initializer: all zero
    struct Point copy = a;                          // structs assign and copy by value

    copy.x = 10.0;                                  // member access with .

    printf("%g,%g %g,%g %g,%g %g,%g\n",
           a.x, a.y, b.x, b.y, origin.x, origin.y, copy.x, copy.y);
    return 0;
}

Facts that distinguish struct from aggregates in other languages:

  • Assignment copies the whole struct (a member-wise copy, padding included or not — unspecified), and a struct can be passed to and returned from functions by value.

  • Two structs cannot be compared with ==. Compare member by member; memcmp is wrong because padding bytes are indeterminate.

  • The tag (struct Point) lives in a separate namespace from ordinary identifiers, which is why struct Point Point; is legal.

  • Members are laid out in declaration order, with padding as needed — see "Alignment and Padding" below.

Nested Structures

#include <stdio.h>

struct Address {
    const char *street;
    const char *city;
};

struct Employee {
    const char *name;
    int id;
    struct Address address;         // by value: the Address lives inside the Employee
    struct Employee *manager;       // by pointer: a struct may point to its own type
};

int main(void)
{
    struct Employee boss = {
        .name = "Ada",
        .id = 1,
        .address = { .street = "1 Main St", .city = "Springfield" },
    };

    struct Employee dev = {
        .name = "Grace",
        .id = 2,
        .address = { .city = "Springfield" },       // .street is null
        .manager = &boss,
    };

    printf("%s (%d) reports to %s in %s\n",
           dev.name, dev.id, dev.manager->name, dev.address.city);
    return 0;
}

A struct may contain a pointer to its own type but not an instance of it (the size would be infinite). Use through a pointer, . through a value; p→x is exactly (*p).x.

C11 also allows anonymous members, which flatten access:

#include <stdio.h>

struct Packet {
    int kind;
    struct {                        // anonymous struct: no tag, no member name
        unsigned short port;
        unsigned int address;
    };                              // its members are accessed directly on Packet
};

int main(void)
{
    struct Packet p = { .kind = 1, .port = 8080, .address = 0x7F000001u };
    printf("%d %u %u\n", p.kind, p.port, p.address);
    return 0;
}

typedef — Type Aliases

typedef gives an existing type another name. It creates no new type, so it never affects compatibility:

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

typedef struct Point { double x, y; } Point;    // tag + alias, the common idiom
typedef uint32_t Milliseconds;                  // a domain name for a plain integer
typedef int (*Comparator)(const void *, const void *);   // a function-pointer alias
typedef char Line[80];                          // an array alias -- legal, rarely wise

// An opaque handle: callers see the name, never the layout. This is how C libraries
// hide implementation details (FILE works exactly this way).
typedef struct Connection Connection;

int main(void)
{
    Point p = { .x = 1.0, .y = 2.0 };           // no "struct" keyword needed
    Milliseconds timeout = 500;
    Line buffer = "text";

    printf("%g %u %s %zu\n", p.x, timeout, buffer, sizeof(Point));
    return 0;
}

Style guidance the C world genuinely disagrees on: the Linux kernel discourages typedef-ing structs (you lose the visible struct, which tells the reader it is an aggregate), while most application code and every public API uses the typedef struct Foo { … } Foo; form. Do use typedef for function pointers and opaque handles — both are unreadable without it.

union and Tagged Unions

A `union’s members all start at offset zero and share storage; its size is that of its largest member. Reading a member other than the one last written is only defined for the common initial sequence of structs — everything else is type punning, covered in Memory Model and Alignment.

#include <stdio.h>

union Value {
    int as_int;
    float as_float;
    unsigned char as_bytes[4];
};

int main(void)
{
    union Value v = { .as_int = 0x41424344 };

    printf("size = %zu, as_int = %#x, first byte = %#x\n",
           sizeof v, (unsigned)v.as_int, v.as_bytes[0]);
    return 0;
}

The safe, idiomatic use is a tagged (discriminated) union: a struct pairing a tag with a union, where the tag says which member is live. This is C’s equivalent of a sum type:

#include <stdio.h>

enum ValueKind { VALUE_INT, VALUE_DOUBLE, VALUE_STRING };

struct Value {
    enum ValueKind kind;            // the discriminant -- always set it when you write
    union {
        long as_int;
        double as_double;
        const char *as_string;
    };                              // anonymous union: v.as_int, not v.u.as_int
};

static void print_value(const struct Value *v)
{
    switch (v->kind) {
    case VALUE_INT:
        printf("int %ld\n", v->as_int);
        break;
    case VALUE_DOUBLE:
        printf("double %g\n", v->as_double);
        break;
    case VALUE_STRING:
        printf("string %s\n", v->as_string);
        break;
    default:
        printf("unknown\n");
        break;
    }
}

int main(void)
{
    struct Value values[3] = {
        { .kind = VALUE_INT,    .as_int = 42 },
        { .kind = VALUE_DOUBLE, .as_double = 3.5 },
        { .kind = VALUE_STRING, .as_string = "text" },
    };

    for (size_t i = 0; i < sizeof values / sizeof values[0]; ++i) {
        print_value(&values[i]);
    }
    return 0;
}

Compile with -Wswitch-enum so adding a ValueKind breaks the build until every switch handles it.

Bit-Fields

A bit-field packs members into a specified number of bits — useful for protocol headers and flag sets, and full of implementation-defined behavior:

#include <stdio.h>

struct Flags {
    unsigned int visible   : 1;     // 1 bit
    unsigned int selected  : 1;
    unsigned int priority  : 3;     // 0..7
    unsigned int           : 0;     // width 0: force the next member to a new unit
    unsigned int reserved  : 8;
};

int main(void)
{
    struct Flags f = { .visible = 1, .priority = 5 };

    f.selected = 1;
    f.priority = 7;                 // assigning 8 would silently truncate

    printf("%u %u %u %zu\n", f.visible, f.selected, f.priority, sizeof(struct Flags));
    return 0;
}

What the standard does not fix: the allocation order within a unit (little- or big-endian bit order), whether a bit-field may straddle a storage unit, the alignment of the unit, and — before C23 — whether a plain int bit-field is signed. You therefore cannot portably overlay a bit-field struct on a wire format; do explicit shifts and masks for that. Use bit-fields for internal compactness only, and note that you cannot take the address of one.

Flexible Array Members

A struct’s last member may be an array of unspecified length — one allocation then holds the header and its payload contiguously:

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

struct Buffer {
    size_t length;
    char data[];                    // C99 flexible array member -- must be last
};

static struct Buffer *buffer_create(const char *text)
{
    size_t n = strlen(text);

    // Allocate the header plus n+1 bytes of payload in one block.
    struct Buffer *b = malloc(sizeof *b + n + 1);
    if (b == nullptr) {
        return nullptr;
    }

    b->length = n;
    memcpy(b->data, text, n + 1);
    return b;
}

int main(void)
{
    struct Buffer *b = buffer_create("hello");
    if (b == nullptr) {
        return EXIT_FAILURE;
    }

    printf("%zu %s (sizeof header = %zu)\n", b->length, b->data, sizeof *b);
    free(b);                        // one allocation, one free
    return 0;
}

Rules: the flexible array member does not count toward sizeof (so sizeof b + n is the right size), a struct with one cannot be a member of another struct or an array element, and it must not be the *only member. Before C99 people wrote char data[1] and over-allocated — that "struct hack" is undefined behavior; the flexible array member is the supported spelling.

Alignment and Padding

Each type has an alignment: the addresses at which it may be placed. The compiler inserts padding between members to respect it, and trailing padding so the struct’s size is a multiple of its own alignment (arrays must stay correctly aligned).

Two layouts of the same four members: struct Bad orders char
#include <stdalign.h>
#include <stddef.h>
#include <stdio.h>

struct Bad {                // declaration order forces padding
    char  a;                // offset 0        + 3 padding
    int   b;                // offset 4
    char  c;                // offset 8        + 7 padding
    double d;               // offset 16
};                          // sizeof 24, alignof 8

struct Good {               // widest members first
    double d;               // offset 0
    int    b;               // offset 8
    char   a;               // offset 12
    char   c;               // offset 13       + 2 trailing padding
};                          // sizeof 16, alignof 8

struct Aligned {
    alignas(64) char cache_line[64];        // C23 keyword (C11: _Alignas)
};

int main(void)
{
    printf("Bad:  size %zu align %zu (b at %zu, d at %zu)\n",
           sizeof(struct Bad), alignof(struct Bad),
           offsetof(struct Bad, b), offsetof(struct Bad, d));
    printf("Good: size %zu align %zu (b at %zu, c at %zu)\n",
           sizeof(struct Good), alignof(struct Good),
           offsetof(struct Good, b), offsetof(struct Good, c));
    printf("Aligned: size %zu align %zu\n", sizeof(struct Aligned), alignof(struct Aligned));
    return 0;
}

Practical consequences:

  • Order members from widest to narrowest when a struct is allocated in bulk — the Bad/Good pair above is a third smaller for free. pahole and clang -Xclang -fdump-record-layouts show the real layout.

  • Padding bytes have indeterminate values, so never memcmp two structs and never write one to a file or socket without a defined serialization.

  • static_assert(sizeof(struct Header) == 8, "…​") is how you pin a layout you depend on — see Constants, Enumerations and Initialization.

  • offsetof(type, member) from <stddef.h> gives a member’s byte offset, and is the supported way to recover a containing struct from a member pointer (the kernel’s container_of).

See Also

References