Structs & Data Structures · beginner · ~12 min
**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.
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:
. (dot) on a struct value: p.x.-> (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.
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:
task_struct); files are FILE structs.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.
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; };andstruct point p;? Which one reserves memory?
. 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 doesq.xfail to compile, and what should it be?
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 doesprintf("%zu\n", sizeof(struct mixed));most likely print, and why is it not 5?
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)
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.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 *.
// 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".
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 structC is value-typed. Passing a struct to a function copies it. To avoid the copy, pass a pointer:
const T * for read-only accessT * for read-write access#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).
Walking through the key parts of the example:
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.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.struct point target = {.x = 3.0, .y = 4.0}; — designated init names each field. Order does not matter; any omitted field would be zero.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.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).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.if (p == NULL) — calloc returns NULL on failure; using a NULL pointer would crash, so we bail out cleanly.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 |
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
Compiler errors
struct == struct. Compare fields.. on a pointer (or -> on a value). Check which side is a pointer.struct foo;) but never defined in this translation unit, so its fields are unknown. Include the header that defines it.Runtime errors
-> 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.{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>).memcmp check — padding again; switch to field-by-field comparison.Questions to ask when it doesn't work
NULL and still valid (not freed)?Structs introduce a few specific undefined-behaviour and safety concerns:
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.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.free(p), set p = NULL so later p->... faults loudly instead of corrupting reclaimed memory.calloc(1, sizeof(*p)) stays correct even if p's type changes later. Mismatched sizes cause heap overflows.memcmp/serialisation of raw structs is unreliable. Treat padding as garbage.malloc(n * sizeof(struct foo)) can overflow if n is large/attacker-influenced; prefer calloc(n, sizeof(struct foo)), which checks the multiplication.Concrete uses
task_struct); the C standard library exposes files as the opaque FILE struct in <stdio.h>.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.Professional best-practice habits
Beginner rules:
width, not w2); keep related fields together.{0}, designated initialisers, or calloc).const T *; pass large structs by pointer, not by value.calloc/malloc for NULL; free exactly once and null the pointer.Advanced habits:
fwrite a raw struct for persistence or the network — serialise field by field to control layout and endianness.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.
struct point { ... };) describes layout and reserves nothing; an instance (struct point p;) is a real variable in memory.. on a value and -> on a pointer (q->x is (*q).x). Mixing them up is the most common beginner error.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.{0} or calloc), bound copies into array members with snprintf, check allocations for NULL, and free once then set the pointer to NULL.