Skip to main content
C · lesson 13 of 25
C intermediate Lesson 13 of 25

Structs in C

C structs with real output: designated initializers, typedef, pointers and the arrow operator, padding measured with offsetof, and bit fields.

By SkillByExample · Updated

Defining and Using Structs

A struct groups related values of different types under one name. Structs can nest, and designated initializers set members by name:

#include <stdio.h>

struct Point {
    double x;
    double y;
};

struct Rectangle {
    struct Point top_left;
    struct Point bottom_right;
};

double rect_area(struct Rectangle r) {
    double width  = r.bottom_right.x - r.top_left.x;
    double height = r.top_left.y - r.bottom_right.y;
    return width * height;
}

int main(void) {
    struct Point p = {3.0, 4.0};                 /* positional */
    struct Rectangle rect = {                    /* designated (C99) */
        .top_left     = {.x = 0.0, .y = 10.0},
        .bottom_right = {.x = 5.0, .y = 0.0}
    };
    struct Point q = {.y = -2.0};                /* x is zeroed */

    printf("p = (%.1f, %.1f), q = (%.1f, %.1f)\n", p.x, p.y, q.x, q.y);
    printf("area = %.1f\n", rect_area(rect));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o basic basic.c
$ ./basic
p = (3.0, 4.0), q = (0.0, -2.0)
area = 50.0

q named only .y, and the unnamed .x was zeroed. As with arrays, once an initializer is present, every member it doesn’t mention is zero. The height is top_left.y - bottom_right.y because y grows upward here. Subtract the other way round and the area comes out as -50.0, a sign bug that compiles cleanly.

Structs Copy by Value

Assigning a struct copies every member, and passing one to a function copies it too:

#include <stdio.h>

struct Point { int x, y; };

void move_copy(struct Point p)  { p.x += 100; }
void move_ptr(struct Point *p)  { p->x += 100; }

int main(void) {
    struct Point a = {1, 2};
    struct Point b = a;          /* whole-struct copy */
    b.x = 50;
    printf("a = (%d, %d), b = (%d, %d)\n", a.x, a.y, b.x, b.y);

    move_copy(a);
    printf("after move_copy: a.x = %d\n", a.x);
    move_ptr(&a);
    printf("after move_ptr:  a.x = %d\n", a.x);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o copy copy.c
$ ./copy
a = (1, 2), b = (50, 2)
after move_copy: a.x = 1
after move_ptr:  a.x = 101

Changing b left a alone, and move_copy changed only its private copy. Unlike arrays, structs never decay to pointers, so passing a struct always copies it. That’s also the only way to pass an array by value: wrap it in a struct. What you can’t do is compare structs directly:

$ gcc -Wall -Wextra -std=c17 -o compare compare.c
compare.c: In function 'main':
compare.c:7:11: error: invalid operands to binary == (have 'struct Point' and 'struct Point')
    7 |     if (a == b) printf("equal\n");
      |           ^~

Write a comparison function that checks each member. memcmp isn’t a safe shortcut, because the padding bytes covered below can differ between two structs whose members are equal.

typedef with Structs

typedef names the type so you can drop the struct keyword. A struct that points to its own type still needs a tag, because the typedef name doesn’t exist yet inside the braces:

#include <stdio.h>

typedef struct {
    char   name[32];
    int    age;
    double salary;
} Employee;

typedef struct Node {        /* tag needed: the struct refers to itself */
    int          value;
    struct Node *next;
} Node;

void print_employee(const Employee *e) {
    printf("%-14s %3d  $%9.2f\n", e->name, e->age, e->salary);
}

int main(void) {
    Employee staff[] = {
        {"Alice Johnson", 32,  95000.0},
        {"Bob Smith",     28,  72000.0},
        {"Carol Davis",   45, 120000.0},
    };
    for (size_t i = 0; i < sizeof staff / sizeof staff[0]; i++) {
        print_employee(&staff[i]);
    }

    Node second = {2, NULL};
    Node first = {1, &second};
    printf("list: %d -> %d\n", first.value, first.next->value);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o employee employee.c
$ ./employee
Alice Johnson   32  $ 95000.00
Bob Smith       28  $ 72000.00
Carol Davis     45  $120000.00
list: 1 -> 2

first.next->value mixes both member operators. first is a struct, so it uses ., and first.next is a pointer, so it uses ->. Two nodes linked by hand are already a linked list, and the linked-lists lesson builds on exactly this Node. print_employee takes a const Employee * rather than an Employee, which avoids copying 48 bytes per call and promises not to modify the record.

Struct Pointers and the Arrow Operator

Large structs are usually heap-allocated and passed around by pointer:

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

typedef struct {
    char  title[128];
    char  author[64];
    int   year;
    float rating;
} Book;

void update_rating(Book *b, float new_rating) {
    b->rating = new_rating;          /* same as (*b).rating = new_rating */
}

Book *create_book(const char *title, const char *author, int year) {
    Book *b = malloc(sizeof *b);
    if (!b) return NULL;
    snprintf(b->title, sizeof b->title, "%s", title);
    snprintf(b->author, sizeof b->author, "%s", author);
    b->year = year;
    b->rating = 0.0f;
    return b;
}

int main(void) {
    printf("sizeof(Book) = %zu, sizeof(Book *) = %zu\n", sizeof(Book), sizeof(Book *));
    Book *b = create_book("The C Programming Language", "Kernighan & Ritchie", 1978);
    if (!b) return 1;
    update_rating(b, 4.9f);
    printf("'%s' by %s (%d), rating %.1f\n", b->title, b->author, b->year, b->rating);
    free(b);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o book book.c
$ ./book
sizeof(Book) = 200, sizeof(Book *) = 8
'The C Programming Language' by Kernighan & Ritchie (1978), rating 4.9

Passing b hands over 8 bytes. Passing a Book by value would copy all 200. malloc(sizeof *b) sizes the allocation from the variable rather than repeating the type name, so it stays correct if b’s type ever changes. snprintf(dst, size, "%s", src) is a compact, always-terminated string copy.

Nested Structs and Arrays of Structs

Structs compose. Chain . to reach members of members:

#include <stdio.h>

typedef struct { int day, month, year; } Date;
typedef struct { char first[16]; char last[16]; } Name;
typedef struct {
    Name name;
    Date birthday;
    char email[32];
} Person;

void print_person(const Person *p) {
    printf("%s %s | born %04d-%02d-%02d | %s\n",
           p->name.first, p->name.last,
           p->birthday.year, p->birthday.month, p->birthday.day, p->email);
}

int main(void) {
    Person people[] = {
        {{"Alice", "Smith"},   {15, 3, 1990}, "alice@example.com"},
        {{"Bob",   "Johnson"}, {22, 7, 1985}, "bob@example.com"},
    };
    for (size_t i = 0; i < sizeof people / sizeof people[0]; i++) {
        print_person(&people[i]);
    }
    people[1].birthday.year++;       /* chain . through nested members */
    printf("Bob's birth year is now %d\n", people[1].birthday.year);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o person person.c
$ ./person
Alice Smith | born 1990-03-15 | alice@example.com
Bob Johnson | born 1985-07-22 | bob@example.com
Bob's birth year is now 1986

The nested braces in the initializer mirror the nesting of the types: {"Alice", "Smith"} fills the Name, and {15, 3, 1990} fills the Date. p->name.first starts with -> because p is a pointer, then uses . because name is a struct held directly, not a pointer.

Struct Padding and Size

The compiler inserts padding so each member lands on an address suited to its type. The same four members take different amounts of space depending on their order:

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

struct Padded {
    char   a;
    int    b;
    char   c;
    double d;
};

struct Reordered {
    double d;
    int    b;
    char   a;
    char   c;
};

int main(void) {
    printf("Padded:    %zu bytes (members total %zu)\n", sizeof(struct Padded),
           sizeof(char) + sizeof(int) + sizeof(char) + sizeof(double));
    printf("  offsets: a=%zu b=%zu c=%zu d=%zu\n",
           offsetof(struct Padded, a), offsetof(struct Padded, b),
           offsetof(struct Padded, c), offsetof(struct Padded, d));
    printf("Reordered: %zu bytes\n", sizeof(struct Reordered));
    printf("  offsets: d=%zu b=%zu a=%zu c=%zu\n",
           offsetof(struct Reordered, d), offsetof(struct Reordered, b),
           offsetof(struct Reordered, a), offsetof(struct Reordered, c));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o padding padding.c
$ ./padding
Padded:    24 bytes (members total 14)
  offsets: a=0 b=4 c=8 d=16
Reordered: 16 bytes
  offsets: d=0 b=8 a=12 c=13

In Padded, the offsets show the gaps: b starts at 4, not 1 (3 bytes of padding), and d starts at 16, not 9 (7 more). That’s 10 wasted bytes out of 24. Ordering largest first leaves only 2 bytes of padding at the end. Those 2 bytes are needed so that in an array of Reordered, the next element’s double is still 8-byte aligned. Across a million records, that’s 8 MB saved.

Bit Fields

Bit fields give a member an exact width in bits, packing several small values into one integer:

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

typedef struct {
    unsigned ready   : 1;
    unsigned busy    : 1;
    unsigned error   : 1;
    unsigned timeout : 1;
    unsigned mode    : 2;      /* values 0-3 */
    unsigned         : 2;      /* unnamed: reserved bits */
} StatusReg;

typedef struct {
    uint8_t ready : 1, busy : 1, error : 1, timeout : 1, mode : 2;
} StatusReg8;

int main(void) {
    StatusReg reg = {0};
    reg.ready = 1;
    reg.mode = 3;
    printf("ready=%u busy=%u mode=%u\n", reg.ready, reg.busy, reg.mode);

    reg.mode = 5;              /* doesn't fit in 2 bits */
    printf("after mode = 5: mode=%u\n", reg.mode);

    printf("sizeof(StatusReg)  = %zu\n", sizeof(StatusReg));
    printf("sizeof(StatusReg8) = %zu\n", sizeof(StatusReg8));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o bitfields bitfields.c
bitfields.c: In function 'main':
bitfields.c:23:16: warning: unsigned conversion from 'int' to 'unsigned char:2' changes value from '5' to '1' [-Woverflow]
   23 |     reg.mode = 5;              /* doesn't fit in 2 bits */
      |                ^
$ ./bitfields
ready=1 busy=0 mode=3
after mode = 5: mode=1
sizeof(StatusReg)  = 4
sizeof(StatusReg8) = 1

Assigning 5 (binary 101) to a 2-bit field kept only the low two bits, 01. GCC warned, but it would stay silent if the value came from a variable. The field layout needs only 8 bits, yet StatusReg is 4 bytes: its fields are declared unsigned, and GCC gives the struct the size and alignment of that type. Declaring them uint8_t brings it down to 1 byte. Bit-field types other than int, unsigned, signed and _Bool are implementation-defined in C17, but GCC and Clang accept them. Bit order, padding, and whether a field can straddle a boundary are all compiler-specific. To match an exact hardware register or wire format, prefer masks and shifts on a fixed-width integer.

Practice

  1. Write double distance(Point a, Point b) for typedef struct { double x, y; } Point; and test it on (0,0)→(3,4) and (3,4)→(−1,1).
Solution
#include <stdio.h>
#include <math.h>

typedef struct { double x, y; } Point;

double distance(Point a, Point b) {
    double dx = b.x - a.x, dy = b.y - a.y;
    return sqrt(dx * dx + dy * dy);
}

int main(void) {
    Point origin = {0, 0}, p = {3, 4}, q = {-1, 1};
    printf("origin -> p: %.2f\n", distance(origin, p));
    printf("p -> q:      %.2f\n", distance(p, q));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c -lm
$ ./p1
origin -> p: 5.00
p -> q:      5.00

Both are 3-4-5 triangles, so both distances are exactly 5. Passing a two-double struct by value is cheap (16 bytes), so a pointer would add nothing here.

  1. Sort an array of Player { char name[16]; int score; } by score, highest first, with qsort.
Solution
#include <stdio.h>
#include <stdlib.h>

typedef struct {
    char name[16];
    int  score;
} Player;

int by_score_desc(const void *a, const void *b) {
    const Player *pa = a, *pb = b;
    return (pb->score > pa->score) - (pb->score < pa->score);
}

int main(void) {
    Player team[] = { {"Ana", 72}, {"Ben", 95}, {"Cy", 88}, {"Di", 95} };
    size_t n = sizeof team / sizeof team[0];
    qsort(team, n, sizeof team[0], by_score_desc);
    for (size_t i = 0; i < n; i++) printf("%-4s %d\n", team[i].name, team[i].score);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
Ben  95
Di   95
Cy   88
Ana  72

qsort moves whole structs around, sizeof team[0] bytes at a time. Ben and Di tie at 95. qsort is not guaranteed to be stable, so their relative order could differ on another C library. Add a tie-breaker on name if the order matters.

  1. Predict sizeof for struct { char a; double b; char c; int d; char e; }, then reorder the members to shrink it.
Solution
#include <stdio.h>
#include <stddef.h>

struct Wasteful { char a; double b; char c; int d; char e; };
struct Tight    { double b; int d; char a; char c; char e; };

int main(void) {
    printf("Wasteful: %zu bytes\n", sizeof(struct Wasteful));
    printf("Tight:    %zu bytes\n", sizeof(struct Tight));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
Wasteful: 32 bytes
Tight:    16 bytes

In Wasteful, a is followed by 7 padding bytes before b at offset 8. c sits at 16, d at 20 and e at 24, and then the struct is padded to 32, a multiple of 8. Tight holds the same 15 bytes of data in half the space.

  1. Pack a date into bit fields: 5 bits for the day, 4 for the month, 12 for the year. How big is it?
Solution
#include <stdio.h>

typedef struct {
    unsigned day   : 5;   /* 1-31 */
    unsigned month : 4;   /* 1-12 */
    unsigned year  : 12;  /* 0-4095 */
} PackedDate;

int main(void) {
    PackedDate d = {.day = 23, .month = 9, .year = 2026};
    printf("%04u-%02u-%02u in %zu bytes\n", d.year, d.month, d.day, sizeof d);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
2026-09-23 in 4 bytes

5 + 4 + 12 = 21 bits fit in one 32-bit unsigned, so the date takes 4 bytes instead of the 12 an int day, month, year struct needs. Each field’s width is chosen from its range: 31 needs 5 bits, 12 needs 4, and 4095 needs 12.

Frequently Asked Questions

What is struct padding and why does it matter?
Compilers insert padding bytes between struct members so each member starts at an address suited to its type, for example a double on an 8-byte boundary. That makes sizeof(struct) larger than the sum of its members: a char, int, char, double struct is 24 bytes on x86-64 despite holding 14 bytes of data. Ordering members from largest to smallest alignment usually minimizes the waste.
Should I use typedef with structs?
It's a matter of style. typedef lets you write Employee instead of struct Employee. The Linux kernel avoids typedef for structs to keep types explicit, while many application codebases use it. A self-referential struct still needs its tag inside the definition, as in struct Node *next.
What is the -> operator?
The arrow operator follows a pointer to a struct and accesses a member. p->member means exactly the same as (*p).member; the parentheses are required in the long form because . binds tighter than *.
Can I compare two structs with == in C?
No. C has no built-in struct comparison, and == on two structs is a compile error. Compare the members one by one in a function. Don't use memcmp on structs either: padding bytes between members can hold arbitrary values, so two structs with equal members can still differ byte for byte.