Structs & Data Structures · beginner · ~12 min

Structs

**What you will learn** - Define a `struct` type and create instances of it (definition vs. instance). - Access members two ways: `.` (dot) on a value and `->` (arrow) on a pointer. - Initialise structs cleanly, including designated initialisers and zero-initialisation. - Pass structs to functions safely: by value (a copy) vs. by pointer (`const T *` vs. `T *`). - Reason about memory layout, alignment, and padding, and why `sizeof` is often larger than the sum of the fields. - Avoid the classic struct pitfalls: comparing with `==`, unsafe `memcmp`, and overflowing fixed-size members.

Overview

A struct (short for structure) lets you group several related variables under one new type. Each variable inside it is called a member or field. Up to now, building on Variables and Arrays, you have stored one value per variable, or many values of the same type in an array. A struct is the missing piece: it lets you bundle values of different types that belong together into a single, named thing.

Think of a 2D point. It is really two numbers, x and y, that only make sense together. You could carry them as two separate ints, but the moment you have ten points, two functions, and a sort routine, those loose variables become a bookkeeping nightmare. A struct point { int x; int y; }; turns "two ints that travel together" into one value you can name, copy, pass, and return.

Structs are how C programs build composite data: coordinates, user records, network packets, file handles, and every node of every linked list, tree, and hash table. Where an array is a sequence of identical cells (an idea you met in Arrays), a struct is a labelled record of possibly different types. The two combine constantly — arrays of structs, and structs that contain arrays.

You access members in two ways, and the distinction matters for the rest of your C career:

  • Use . (dot) on a struct value: p.x.
  • Use -> (arrow) on a pointer to a struct: q->x.

The next lesson, typedef, builds directly on this by giving a struct a one-word name so you can drop the struct keyword.

Why it matters

Without structs, you would pass many separate parameters every time you described something like a user or a TCP segment. A function signature like update(char *name, int age, int id, double balance, ...) is fragile: swap two arguments of the same type and the compiler happily accepts a bug.

With a struct, you pass a single value (or a single pointer). The receiver gets clear, named access to each field, and the compiler checks the whole bundle as one type. This is not a cosmetic improvement — it is the foundation of essentially every data structure and system interface in C:

  • Data structures: linked-list nodes, tree nodes, hash-table entries are all structs that hold data plus pointers to other structs.
  • Operating systems: the Linux kernel describes every running process with a giant struct (task_struct); files are FILE structs.
  • Networking: protocol headers (IP, TCP, UDP) and socket addresses (struct sockaddr_in) are structs laid out to match bytes on the wire.

On the robustness side (the safety mindset you will lean on throughout C): grouping related state into one type makes invariants explicit. If a width and a height always belong together, putting them in one struct means you can validate them together, copy them together, and never accidentally update one without the other. Loose parallel variables drift out of sync; a struct keeps them in lockstep.

Core concepts

1. Definition vs. instance

A struct definition describes the layout — the fields and their types. It allocates no storage by itself.

struct point { int x; int y; };   // a blueprint, not a variable

An instance is an actual variable of that type that occupies memory:

struct point p;        // one real point, x and y uninitialised
struct point o = {0, 0};

How it works internally: the definition tells the compiler how many bytes a struct point needs and where each field lives relative to the start. The instance reserves those bytes (on the stack, in static storage, or on the heap).

When to use / not: define a struct whenever two or more values describe one logical thing. Do not wrap a single value in a struct "just in case" — it adds noise with no benefit.

Pitfall: forgetting that the definition alone creates nothing. struct point; declares the type but no variable, so you cannot read point.x — there is no point object.

Knowledge check: What is the difference between writing struct point { int x; int y; }; and struct point p;? Which one reserves memory?

2. Member access: . vs ->

Use . on a struct value and -> on a pointer to a struct. The arrow is pure shorthand: q->x means exactly (*q).x — dereference the pointer, then take the field.

struct point p = {3, 4};
struct point *q = &p;
int a = p.x;     // dot on a value
int b = q->x;    // arrow on a pointer == (*q).x

Pitfall: writing *q.x expecting it to mean (*q).x. Because . binds tighter than *, *q.x parses as *(q.x) — and q is a pointer with no .x, so it fails to compile. Always use -> for pointers.

Knowledge check (find the bug): Given struct point *q = &p;, why does q.x fail to compile, and what should it be?

3. Memory layout, alignment, and padding

Fields are stored in declaration order. The compiler may insert padding (unused bytes) so each field begins at an address its type requires — this requirement is called alignment. As a result, sizeof(struct ...) is often larger than the sum of the field sizes.

struct mixed { char c; int n; };   // typical 64-bit layout

offset:  0      1   2   3      4   5   6   7
        +----+ +---------------+ +---------------+
        | c  | | PAD  PAD  PAD| |   n (4 bytes) |
        +----+ +---------------+ +---------------+
         1 byte   3 bytes pad      4-byte int

sizeof == 8, even though 1 + 4 == 5

The 3 padding bytes push n to offset 4 so the int is 4-byte aligned. Reordering fields largest-to-smallest often shrinks a struct by reducing padding.

When to care: most of the time you can ignore padding. It matters when you (a) try to compare structs byte-for-byte, (b) write structs to disk or the network, or (c) optimise memory in huge arrays of structs.

Pitfall: assuming the on-disk/on-wire byte layout matches your struct exactly. Padding and endianness make raw fwrite of a struct non-portable; serialise field by field instead.

Knowledge check (predict the output): For struct mixed { char c; int n; }; on a typical 64-bit system, what does printf("%zu\n", sizeof(struct mixed)); most likely print, and why is it not 5?

4. Pass by value vs. pass by pointer

C is value-typed. Passing a struct to a function copies the whole thing, and returning one copies it back. For small structs (a point or two) that is fine and clean. For large structs, copying wastes time and stack space, so pass a pointer.

by value:                       by pointer:
  caller's p  ──copy──▶ param     caller's p ◀──&p── param (points back)
  changes inside DON'T            changes inside DO
  affect caller                   affect caller (unless const)
  • Use const struct point *p when the function only reads the struct. This avoids the copy and documents/enforces that it will not modify the caller's data.
  • Use struct point *p when the function must modify the caller's struct.

Pitfall: passing a big struct by value in a hot loop, silently copying hundreds of bytes each call. Profilers and code reviewers flag this; prefer const T *.

Syntax notes

// 1. Definition: the layout (no storage yet)
struct point {
    int x;
    int y;
};

// 2. Instances + initialisation
struct point a = {3, 4};          // positional: x=3, y=4
struct point b = {.y = 9};        // designated: x=0 (default), y=9
struct point zero = {0};          // zero-initialise every field

// 3. Dot access on a value
printf("%d,%d\n", a.x, a.y);

// 4. Pointer + arrow access
struct point *q = &a;
q->x = 5;                         // same as (*q).x = 5
printf("%d\n", q->x);

// 5. As a function parameter (read-only, no copy)
int sum(const struct point *p) { return p->x + p->y; }

Key points: members are separated by semicolons and the whole definition ends with ;. Designated initialisers (.y = 9) set fields by name and zero any you omit. {0} is the idiom for "all fields zero".

Lesson

A struct bundles several values into one named type.

Access its fields in one of two ways:

  • . on a struct value
  • -> on a pointer to a struct

C is value-typed. Passing a struct to a function copies it. To avoid the copy, pass a pointer:

  • const T * for read-only access
  • T * for read-write access

Code examples

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

/* A 2D point: two coordinates that belong together. */
struct point {
    double x;
    double y;
};

/* Read-only access: const pointer avoids copying and forbids mutation. */
double distance(const struct point *a, const struct point *b) {
    double dx = a->x - b->x;
    double dy = a->y - b->y;
    return sqrt(dx * dx + dy * dy);
}

/* Read-write access: the caller's point is modified through the pointer. */
void translate(struct point *p, double dx, double dy) {
    p->x += dx;
    p->y += dy;
}

int main(void) {
    struct point origin = {0.0, 0.0};      /* positional init */
    struct point target = {.x = 3.0, .y = 4.0}; /* designated init */

    printf("distance = %g\n", distance(&origin, &target));

    translate(&origin, 1.0, 1.0);          /* origin is changed in place */
    printf("origin now = (%g, %g)\n", origin.x, origin.y);

    /* A struct on the heap: calloc zero-initialises every field. */
    struct point *p = calloc(1, sizeof(*p));
    if (p == NULL) {                       /* always check allocation */
        fprintf(stderr, "out of memory\n");
        return 1;
    }
    p->x = 10.0;
    p->y = 20.0;
    printf("heap point = (%g, %g)\n", p->x, p->y);

    free(p);                               /* release heap memory */
    p = NULL;                              /* avoid a dangling pointer */
    return 0;
}

What it does: defines a struct point, then shows the three things you do with structs every day — read fields through a const pointer (distance), modify fields through a non-const pointer (translate), and allocate one on the heap with calloc. It prints the distance between the origin and (3,4), the origin after being translated, and a heap-allocated point.

Expected output:

distance = 5
origin now = (1, 1)
heap point = (10, 20)

Edge cases: distance of a point to itself is 0. calloc can return NULL when memory runs out, which is why the check exists. Setting p = NULL after free prevents accidental reuse of freed memory. Note 5 prints (not 5.0000) because %g trims trailing zeros — the 3-4-5 right triangle gives exactly 5.

Compile with cc -std=c11 -Wall -Wextra prog.c -lm (the -lm links the math library for sqrt).

Line by line

Walking through the key parts of the example:

  1. struct point { double x; double y; }; — defines the layout. No memory is used yet; this only tells the compiler that a struct point is two doubles, x first then y.
  2. struct point origin = {0.0, 0.0}; — creates an instance on the stack. Positional init fills x then y, so origin.x == 0.0, origin.y == 0.0.
  3. struct point target = {.x = 3.0, .y = 4.0}; — designated init names each field. Order does not matter; any omitted field would be zero.
  4. distance(&origin, &target)&origin and &target are addresses, so no struct is copied. Inside, a->x reads origin.x (0.0) and b->x reads target.x (3.0). dx = -3.0, dy = -4.0, and sqrt(9 + 16) = sqrt(25) = 5.0.
  5. translate(&origin, 1.0, 1.0) — receives a non-const pointer, so p->x += dx writes back into the caller's origin. After the call origin is (1.0, 1.0).
  6. calloc(1, sizeof(*p)) — asks for one zeroed struct point on the heap. sizeof(*p) is the size of what p points to, so it stays correct even if the type changes.
  7. if (p == NULL)calloc returns NULL on failure; using a NULL pointer would crash, so we bail out cleanly.
  8. free(p); p = NULL; — returns the memory; setting p = NULL makes any later accidental use an obvious NULL deref rather than silent corruption.
Step origin target p
after init (0,0) (3,4)
after distance (0,0) (3,4) — (returns 5)
after translate (1,1) (3,4)
after calloc + set (1,1) (3,4) (10,20) on heap
after free (1,1) (3,4) NULL

Common mistakes

1. Comparing structs with ==

// WRONG
if (a == b) { ... }   // does not compile; C has no struct == 

C does not define == for structs. Compare field by field:

// CORRECT
if (a.x == b.x && a.y == b.y) { ... }

Recognise/prevent: the compiler error mentions "invalid operands to binary ==". Write a small points_equal() helper and use it everywhere.

2. Using memcmp to compare structs

// WRONG (subtle): padding bytes may differ even when all fields match
if (memcmp(&a, &b, sizeof a) == 0) { ... }

Two logically equal structs can have different padding bytes (leftover garbage), so memcmp may report them as different. Compare fields explicitly instead. (memcmp is only safe if you are certain the struct has no padding and was fully overwritten — rare and brittle.)

3. The arrow/dot mix-up on pointers

struct point *q = &a;
// WRONG
int v = q.x;     // q is a pointer, not a struct
int w = *q.x;    // parses as *(q.x); still wrong
// CORRECT
int v2 = q->x;   // or (*q).x

Recognise: "request for member 'x' in something not a structure" or "'q' is a pointer; did you mean to use '->'?".

4. Overflowing a fixed-size array member

struct user { char name[16]; };
struct user u;
// WRONG: no length check; overflows name[] for long input
strcpy(u.name, some_long_string);
// CORRECT: bound the copy and guarantee termination
snprintf(u.name, sizeof u.name, "%s", some_long_string);

An oversized copy overruns the field into neighbouring memory — a classic stack-smashing bug.

5. Reading uninitialised members

struct point p;          // x and y hold garbage
printf("%g\n", p.x);     // WRONG: undefined value
// CORRECT
struct point p = {0};    // every field zeroed

Debugging tips

Compiler errors

  • "invalid operands to binary ==" — you tried struct == struct. Compare fields.
  • "request for member 'x' in something not a structure or union" — you used . on a pointer (or -> on a value). Check which side is a pointer.
  • "dereferencing pointer to incomplete type" — the struct was only declared (struct foo;) but never defined in this translation unit, so its fields are unknown. Include the header that defines it.

Runtime errors

  • Segfault when using -> usually means the pointer is NULL or dangling. In gdb: print q to check the address, then print *q to dump the whole struct at once.
  • Garbage field values often mean the instance was never initialised. Use {0} or calloc.

Logic / size surprises

  • sizeof(struct foo) larger than expected? That is padding/alignment, not a bug. Print field offsets with printf("%zu\n", offsetof(struct foo, field)); (from <stddef.h>).
  • Two "equal" structs failing a memcmp check — padding again; switch to field-by-field comparison.

Questions to ask when it doesn't work

  1. Is the variable a struct value or a pointer? (dot vs. arrow)
  2. Did I initialise every field I read?
  3. Is the pointer non-NULL and still valid (not freed)?
  4. Am I assuming a byte layout that padding/endianness breaks?

Memory safety

Structs introduce a few specific undefined-behaviour and safety concerns:

  • Uninitialised fields. A bare struct point p; leaves x and y with indeterminate values; reading them is undefined behaviour. Prefer struct point p = {0}; on the stack and calloc(1, sizeof(*p)) on the heap, which zero every field at once.
  • Bounds of array members. A char name[16] inside a struct is still just 16 bytes. Never strcpy/gets/sprintf into it without bounding the length; use snprintf(p->name, sizeof p->name, ...) so the field cannot overflow into neighbouring members or the saved return address.
  • Lifetimes and dangling pointers. Do not return the address of a local struct from a function — its storage dies when the function returns. After free(p), set p = NULL so later p->... faults loudly instead of corrupting reclaimed memory.
  • Allocation size correctness. Size the allocation from the pointer, not the type name: calloc(1, sizeof(*p)) stays correct even if p's type changes later. Mismatched sizes cause heap overflows.
  • Padding is not zeroed by field assignment. Setting every field still leaves padding bytes indeterminate, which is why byte-wise memcmp/serialisation of raw structs is unreliable. Treat padding as garbage.
  • Integer overflow when sizing arrays of structs. malloc(n * sizeof(struct foo)) can overflow if n is large/attacker-influenced; prefer calloc(n, sizeof(struct foo)), which checks the multiplication.

Real-world uses

Concrete uses

  • Operating systems: the Linux kernel represents every process with a single large struct (task_struct); the C standard library exposes files as the opaque FILE struct in <stdio.h>.
  • Networking: struct sockaddr_in (<netinet/in.h>) holds an address family, port, and IP for socket calls; protocol headers (IP/TCP/UDP) are structs sized to match bytes on the wire.
  • Data structures: every linked-list node, binary-tree node, and hash-table entry is a struct that bundles a payload with one or more pointers to other nodes.
  • Applications: game engines store an entity's position/velocity/health in a struct; databases keep row and index metadata in structs.

Professional best-practice habits

Beginner rules:

  • Give fields clear, specific names (width, not w2); keep related fields together.
  • Always initialise structs ({0}, designated initialisers, or calloc).
  • Pass read-only structs as const T *; pass large structs by pointer, not by value.
  • Check calloc/malloc for NULL; free exactly once and null the pointer.

Advanced habits:

  • Provide constructor/initialiser and "free" helper functions for structs that own resources, so allocation and cleanup live in one place.
  • Order fields large-to-small to reduce padding in memory-critical, high-count arrays.
  • Never fwrite a raw struct for persistence or the network — serialise field by field to control layout and endianness.
  • Keep struct definitions in headers and validate invariants in one place when fields change together.

Practice tasks

Beginner 1 — Point distance Objective: compute the Euclidean distance between two points. Requirements: define struct point { double x; double y; }; and write double distance(struct point a, struct point b);. In main, create two points and print the distance. Example: points (0,0) and (3,4)5. Hints: use sqrt from <math.h> and compile with -lm. Concepts: definition, dot access, pass by value.

Beginner 2 — User record Objective: model a user and print it. Requirements: define a struct with a char name[32] and an int age. Write void print_user(const struct user *u); that prints both fields. Fill a user with designated initialisers and call it. Constraints: copy any name in with snprintf, never strcpy. Concepts: array member, const T *, arrow access.

Intermediate 1 — Translate in place Objective: modify a struct through a pointer. Requirements: write void translate(struct point *p, double dx, double dy); that adds the offsets to the point. Show that the caller's point really changed. Hint: the parameter must be non-const. Concepts: pass by pointer, in-place mutation.

Intermediate 2 — Array of structs Objective: find the point closest to the origin. Requirements: make an array of 5 struct point. Write a function that takes the array and its length and returns the index of the point nearest (0,0). Input/Output example: points including (1,1) and (5,5) → index of (1,1). Constraints: pass the array by pointer with an explicit length; do not read past the end. Concepts: arrays + structs, looping, comparison helpers.

Challenge — Heap-allocated rectangle list Objective: manage a small dynamic collection of rectangles. Requirements: define struct rect { int w; int h; };. Allocate an array of n rectangles on the heap with calloc, fill them, write long total_area(const struct rect *arr, size_t n);, print the total, and free everything. Constraints: check calloc for NULL; free exactly once; null the pointer after freeing. Use long for the area to avoid int overflow. Concepts: heap allocation, const pointers, sizing with sizeof(*p), cleanup.

Summary

  • A struct groups related fields of possibly different types into one named type; it extends the single-value variables and same-type arrays you already know.
  • A definition (struct point { ... };) describes layout and reserves nothing; an instance (struct point p;) is a real variable in memory.
  • Access fields with . on a value and -> on a pointer (q->x is (*q).x). Mixing them up is the most common beginner error.
  • C copies structs when you pass or return them by value. Pass large structs by pointer; use const T * for read-only access, T * to modify the caller's data.
  • sizeof(struct ...) is often larger than the field sizes because of padding/alignment — which is exactly why you must not compare structs with == or memcmp; compare field by field.
  • For safety: always initialise ({0} or calloc), bound copies into array members with snprintf, check allocations for NULL, and free once then set the pointer to NULL.
  • Structs are the building block of every real data structure and system interface in C; the next lesson, typedef, lets you name them in one word.

Practice with these exercises