The C Path — learn C, visually

🪤 30 C Gotchas

The bugs that bite every C programmer once. Each is a complete, runnable program — read it, guess what it does, then run it on real gcc in your browser.

▶ Open the interactive gallery — run every trap live, free, no signup

➗ Integer division silently throws away the fraction

Quick: what does 7 / 2 print?

gotcha.c
#include <stdio.h>

int main(void) {
    printf("7 / 2       = %d\n", 7 / 2);
    int total = 90 + 91;
    printf("average     = %d\n", total / 2);
    printf("100 C in F  = %d\n", 100 * (9 / 5) + 32);
    return 0;
}
you might expect3.5 for 7/2, 90.5 for the average, 212 for the temperature
what actually happens3, 90, and 132 — every fraction was silently discarded

When both operands of / are integers, C performs integer division and truncates toward zero (C17 6.5.5) — the fractional part simply vanishes. That is why the average of 90 and 91 is "90", and why the Fahrenheit formula collapses: 9 / 5 is 1, so the whole conversion becomes 100 + 32. The fix costs one character: make either operand floating point — 7 / 2.0, or total / 2.0 stored in a double.

📖 Learn it properly: Arithmetic: +, −, ×, ÷ and the truncation trap →

🧭 Modulo of a negative number is negative

Everyone knows % wraps around... so what is -7 % 3?

gotcha.c
#include <stdio.h>

int main(void) {
    printf(" 7 %% 3 = %d\n", 7 % 3);
    printf("-7 %% 3 = %d\n", -7 % 3);
    int i = -1;
    printf("wrapped index: %d\n", i % 5);
    return 0;
}
you might expect2 — like Python, so -1 % 5 gives a friendly wrap-around index of 4
what actually happens-7 % 3 is -1, and -1 % 5 is -1 — a negative "index"

Since C99, integer division truncates toward zero, and % must satisfy (a/b)*b + a%b == a (C17 6.5.5) — so the result of % takes the sign of the dividend. Python floors instead, which is why the two languages disagree about every negative operand. If you want a wrap-around array index that is always non-negative, use the idiom ((i % n) + n) % n.

📖 Learn it properly: Arithmetic: +, −, ×, ÷ and the truncation trap →

🔓 if (x = 1): the assignment that always says yes

One missing = turns a login check into a skeleton key.

gotcha.c
#include <stdio.h>

int main(void) {
    int logged_in = 0;
    if (logged_in = 1)
        printf("Welcome back, admin!\n");
    else
        printf("Access denied.\n");
    printf("logged_in is now %d\n", logged_in);
    return 0;
}
you might expectlogged_in is 0, so the check fails and it prints "Access denied."
what actually happensprints "Welcome back, admin!" — and logged_in has been overwritten to 1

= assigns, == compares — and both are legal inside a condition. An assignment is an expression whose value is the value assigned, so if (logged_in = 1) stores 1 and then tests 1: nonzero, true, every single time. No error, no warning by default — just silently broken logic. Compile with -Wall and GCC will ask if you really meant it (and suggest extra parentheses when you do).

📖 Learn it properly: Comparisons & logic: how C decides →

🫥 The semicolon that ate the if

Why does this program insist it is boiling at 10 degrees?

gotcha.c
#include <stdio.h>

int main(void) {
    int temperature = 10;
    if (temperature > 30);
        printf("It's boiling outside!\n");
    printf("(temperature is %d)\n", temperature);
    return 0;
}
you might expectthe printf runs only when temperature > 30
what actually happens"It's boiling outside!" prints at every temperature

That innocent semicolon in if (temperature > 30); is a complete, empty statement — and it is the entire body of the if. The condition is dutifully evaluated, controls nothing, and the indented printf below is just the next statement, running unconditionally. The same trap works on while and for, where while (cond); becomes an accidental infinite loop. GCC's -Wall flags the suspicious empty body.

📖 Learn it properly: if & else: teaching programs to choose →

🎭 The else that pairs with the wrong if

The indentation says one thing. The compiler sees another.

gotcha.c
#include <stdio.h>

int main(void) {
    int a = 0, b = 1;
    if (a == 1)
        if (b == 1)
            printf("a and b are both 1\n");
    else
        printf("a is not 1\n");
    printf("done\n");
    return 0;
}
you might expecta is 0, so the else fires and prints "a is not 1"
what actually happensonly "done" prints — the else secretly belongs to the INNER if

C attaches an else to the nearest unmatched if (C17 6.8.4.1) — indentation is invisible to the parser. Here the else pairs with if (b == 1), and since the outer condition a == 1 is false, the whole inner if/else never runs at all. Braces around every if body make the pairing explicit and the trap impossible — which is exactly why style guides insist on them.

📖 Learn it properly: if & else: teaching programs to choose →

🕳️ switch cases fall through by default

Second place should win one medal. It wins three.

gotcha.c
#include <stdio.h>

int main(void) {
    int place = 2;
    switch (place) {
        case 1:  printf("gold medal\n");
        case 2:  printf("silver medal\n");
        case 3:  printf("bronze medal\n");
        default: printf("thanks for playing\n");
    }
    return 0;
}
you might expectplace is 2, so it prints just "silver medal"
what actually happensprints silver medal, bronze medal, AND thanks for playing

A case label is just an entry point, not a fence: once execution jumps in, it keeps flowing straight through every following case until it hits a break or the end of the switch (C17 6.8.4.2). Forgetting break is one of the oldest bugs in C — famous enough that C23 added the [[fallthrough]] attribute so you can mark the rare times you fall through on purpose. GCC's -Wimplicit-fallthrough (part of -Wextra) catches the accidental kind.

📖 Learn it properly: switch, case, default: the multi-way jump →

🪢 The comma operator keeps only the last value

a = (1, 2, 3) and b = 1, 2, 3 — surely the same thing?

gotcha.c
#include <stdio.h>

int main(void) {
    int a = (1, 2, 3);               /* comma: run all, keep the LAST */
    int b;
    b = 1, 2, 3;                     /* = binds tighter than , */
    printf("a = %d\n", a);
    printf("b = %d\n", b);
    return 0;
}
you might expectboth variables end up holding the same value, whatever it is
what actually happensa = 3 but b = 1 — the two lines parse completely differently

The comma operator evaluates left to right and yields only its last operand (C17 6.5.17), so (1, 2, 3) is 3. But comma has the lowest precedence in C — lower than = — so b = 1, 2, 3 parses as (b = 1), 2, 3: b gets 1 and the rest is evaluated and discarded. The comma earns its keep in for headers like i++, j--; anywhere else it is usually a bug wearing a disguise.

📖 Learn it properly: for loops: init, test, step — all in one line →

🎯 == binds tighter than &

flags is binary 110. So why does C swear bit 2 is clear?

gotcha.c
#include <stdio.h>

int main(void) {
    int flags = 6;                       /* binary 110: bit 2 is set */
    if (flags & 4 == 4)
        printf("bit 2 is set\n");
    else
        printf("bit 2 is NOT set\n");
    printf("flags & 4 == 4 evaluates to %d\n", flags & 4 == 4);
    return 0;
}
you might expectit tests (flags & 4) == 4 and reports that bit 2 is set
what actually happensreports "bit 2 is NOT set" — it computed flags & (4 == 4), i.e. 6 & 1 = 0

The comparison operators outrank the bitwise operators &, ^ and | in C's precedence table, so flags & 4 == 4 parses as flags & (4 == 4) — that's 6 & 1, which is 0. This ranking is a historical accident from before C had &&, and Dennis Ritchie himself listed it among his regrets. The rule to tattoo somewhere visible: always parenthesize bit tests(flags & 4) == 4 or simply flags & 4 as the whole condition.

📖 Learn it properly: Bitwise operators: surgery on individual bits →

↔️ x << 2 + 1 is not (x << 2) + 1

Shift left by two, then add one. C hears something else.

gotcha.c
#include <stdio.h>

int main(void) {
    int x = 1;
    printf("x << 2 + 1   = %d\n", x << 2 + 1);
    printf("(x << 2) + 1 = %d\n", (x << 2) + 1);
    return 0;
}
you might expect1 shifted left twice is 4, plus 1 makes 5
what actually happens8 — it parsed as 1 << (2 + 1)

Addition binds tighter than the shift operators, so x << 2 + 1 means x << 3. This bites hardest in bit-packing code like base << 4 + offset, which quietly shifts by the wrong amount instead of adding after the shift. Same medicine as every precedence trap: parentheses around anything mixing shifts with arithmetic — they cost nothing and remove all doubt.

📖 Learn it properly: Bitwise operators: surgery on individual bits →

🎈 0.1 + 0.2 is not 0.3

The most famous decimal lie in computing.

gotcha.c
#include <stdio.h>

int main(void) {
    double sum = 0.1 + 0.2;
    if (sum == 0.3)
        printf("equal\n");
    else
        printf("NOT equal: sum is %.17f\n", sum);
    return 0;
}
you might expectequal — 0.1 + 0.2 is 0.3, this is grade-school math
what actually happensNOT equal: the sum is 0.30000000000000004

Doubles are binary fractions, and 0.1, 0.2, 0.3 all fall between the representable ones — each literal is really the nearest binary neighbor, and the rounding errors of 0.1 + 0.2 don't land exactly on the neighbor chosen for 0.3. Nothing overflowed and nothing is broken; the values differ in the 17th decimal place, and == is merciless about it. Compare floating-point with a tolerance — fabs(a - b) < 1e-9 — or work in integer units (cents, not dollars).

📖 Learn it properly: Floating point: scientific notation in bits →

🎲 Uninitialized locals are stack garbage (undefined behavior)

sum starts at 0 automatically... doesn't it?

gotcha.c
#include <stdio.h>

void scribble(void) {
    volatile int junk[6] = {111111, 222222, 333333, 444444, 555555, 666666};
    (void)junk[0];
}

int sum_to(int n) {
    int sum;                         /* forgot  = 0  */
    for (int i = 1; i <= n; i++) sum += i;
    return sum;
}

int main(void) {
    scribble();
    printf("1+2+3+4+5 = %d\n", sum_to(5));
    return 0;
}
you might expect1+2+3+4+5 = 15
what actually happensprinted 29491 on our verified run — 15 plus whatever garbage the previous call left on the stack (a different number in every environment; -O2 happened to print 15)

Automatic (local) variables are not zeroed — sum begins life as whatever bytes the last function call left in that stack slot, which is exactly why scribble() runs first here: to salt the stack and make the garbage visible. Using an indeterminate value like this is undefined behavior (C17 6.3.2.1), and the result genuinely changes between machines, runs, and optimization levels. Initialize at the point of declaration, always — and note that static and global variables are zero-initialized, which is why this bug loves to appear only after a refactor moves a variable.

📖 Learn it properly: Variables & types: naming your bytes →

🃏 Lying to printf about a type (undefined behavior)

Print a double with %d — and watch your 3.14 come out of a different printf.

gotcha.c
#include <stdio.h>

int main(void) {
    double price = 3.14;
    printf("with %%d: %d\n", price);
    printf("with %%f: %f\n", 42);
    return 0;
}
you might expectwith %d: 3 (truncated, maybe?), with %f: 42.000000
what actually happens%d printed garbage (1664582216 on our verified run) — and %f printed 3.140000, the 3.14 left over in a floating-point register!

printf cannot see your types — it trusts the format string and reads whatever the specifier implies from the varargs machinery. On x86-64, doubles travel in floating-point registers and ints in integer registers, so %d read an integer register nobody had set (garbage), and the later %f read the float register still holding 3.14 from the first call. A mismatched conversion specifier is undefined behavior per C17 7.21.6.1p9 — the spooky register reuse is just one way it can look. -Wall makes GCC check format strings against arguments; treat those warnings as errors.

📖 Learn it properly: Variables & types: naming your bytes →

💥 scanf without & is a crash, not a typo (undefined behavior)

One missing character stands between you and a segfault.

gotcha.c
#include <stdio.h>

int main(void) {
    int age = 0;
    scanf("%d", age);                /* forgot the & */
    printf("You are %d\n", age);
    return 0;
}
you might expectreads 21 from input and prints "You are 21"
what actually happensinstant crash (SIGSEGV) — scanf tried to write the number to address 0

scanf must modify your variable, so it needs the variable's address: scanf("%d", &age). Passing age instead hands scanf the value 0, which it dutifully uses as the address to store into — undefined behavior, and on our verified run an immediate segmentation fault. It won't always be so merciful: had age held stack garbage that happened to be a writable address, scanf would corrupt memory silently. GCC's format checking (-Wall) catches this one at compile time — let it.

📖 Learn it properly: stdio.h: printf, scanf & files, done right →

🕵️ Why getchar returns int, not char

A perfectly normal byte in your file that looks exactly like EOF.

gotcha.c
#include <stdio.h>

int main(void) {
    char c = 0xFF;     /* a perfectly normal data byte, say from an image */
    if (c == EOF)
        printf("Looks like the end of the file!\n");
    printf("c = %d, EOF = %d\n", c, EOF);
    return 0;
}
you might expect0xFF is just byte 255 — nothing like the end-of-file marker
what actually happensprints "Looks like the end of the file!" — stored in a char, 0xFF is -1, which equals EOF

getchar() returns int for a reason: it must express 257 distinct results — all 256 byte values plus EOF (-1). Squeeze the result into a char and the distinction dies: on x86-64 Linux, where plain char is signed, the honest data byte 0xFF becomes -1 and compares equal to EOF, so reading a binary file stops early. On platforms where char is unsigned (ARM Linux!), it's the opposite disaster — c == EOF is never true and the read loop never ends. Whether char is signed is implementation-defined (C17 6.2.5); the cure is always int c = getchar();.

📖 Learn it properly: stdio.h: printf, scanf & files, done right →

🔤 sizeof 'a' is 4, because 'a' is an int

sizeof 'a' — one byte, obviously?

gotcha.c
#include <stdio.h>

int main(void) {
    char c = 'a';
    printf("sizeof 'a'   = %zu\n", sizeof 'a');
    printf("sizeof c     = %zu\n", sizeof c);
    printf("sizeof(char) = %zu\n", sizeof(char));
    return 0;
}
you might expect1 — 'a' is a character
what actually happenssizeof 'a' is 4, while sizeof c and sizeof(char) are 1

In C, a character constant like 'a' has type int (C17 6.4.4.4p10) — it only becomes a one-byte value when you store it into a char. So sizeof 'a' is sizeof(int), 4 on this platform. C++ chose differently ('a' is a char there, size 1), making this the classic "is this file really C?" interview question. Mostly harmless trivia — until you memcpy sizeof 'x' bytes and stomp three neighbors.

📖 Learn it properly: sizeof: the measuring-tape operator →

🪜 unsigned char b = ~a, and yet b != ~a

Flip the bits of 0xFF and compare. C says they're different.

gotcha.c
#include <stdio.h>

int main(void) {
    unsigned char a = 0xFF;
    unsigned char b = ~a;            /* surely b == ~a now... */
    printf("b       = %u\n", b);
    printf("~a      = %d\n", ~a);
    printf("b == ~a : %d\n", b == ~a);
    return 0;
}
you might expect~0xFF is 0x00, so b == ~a prints 1
what actually happensb = 0 but ~a = -256, so b == ~a prints 0

Before almost any arithmetic happens, C silently widens anything smaller than int up to int — the integer promotions (C17 6.3.1.1). So ~a is not 8-bit bit-flipping: a becomes the int 255, and ~255 is -256. Assigning that to b truncates back to 0, but the comparison b == ~a happens in int-land: 0 versus -256. When you need byte-sized bit math to stay byte-sized, mask it: (unsigned char)~a or ~a & 0xFF.

📖 Learn it properly: Conversions & casts: C changes your types behind your back →

🌀 Unsigned numbers can't go below zero — they wrap

3 minus 5 equals 4,294,967,294 items in stock.

gotcha.c
#include <stdio.h>

int main(void) {
    unsigned int have = 3, need = 5;
    printf("have - need = %u\n", have - need);
    unsigned int u = 0;
    if (u - 1 < 0)
        printf("u - 1 is negative\n");
    else
        printf("u - 1 = %u, and it can NEVER be negative\n", u - 1);
    return 0;
}
you might expecthave - need is -2, and u - 1 is less than 0 when u is 0
what actually happenshave - need = 4294967294, and the u - 1 < 0 branch can never run

Unsigned arithmetic is defined as modulo 2N (C17 6.2.5p9) — dip below zero and you wrap to the top. That's not a bug in the compiler; it's the deal you signed by writing unsigned. Worse, the usual arithmetic conversions drag signed values along: compare an int to an unsigned and the int converts, so -1 > 2000000000u is true. This is why for (size_t i = n - 1; i >= 0; i--) loops forever — a size_t is always ≥ 0 — and why subtracting sizes is a minefield: prefer if (have >= need) over inspecting have - need.

📖 Learn it properly: Conversions & casts: C changes your types behind your back →

🧨 INT_MAX + 1 is undefined, not just wrong (undefined behavior)

Add 1 to the biggest int. What could go wrong?

gotcha.c
#include <stdio.h>
#include <limits.h>

int main(void) {
    int n = INT_MAX;
    printf("n     = %d\n", n);
    printf("n + 1 = %d\n", n + 1);
    return 0;
}
you might expect2147483648 — or at worst some error
what actually happensprinted -2147483648 on our verified run: positive + positive = negative. And because it is UB, the optimizer may assume it never happens at all

Signed integer overflow is undefined behavior (C17 6.5p5) — the standard doesn't promise wraparound, garbage, or anything else. At -O0 you'll typically observe the two's-complement wrap shown here, which lulls people into trusting it; at -O2 GCC instead assumes overflow cannot happen and deletes "impossible" code — the classic victim being the safety check if (n + 1 < n), optimized away entirely. Contrast with unsigned types, whose wraparound is fully defined. If you need to detect overflow, check before the operation (n > INT_MAX - 1) or use GCC's __builtin_add_overflow.

📖 Learn it properly: Undefined behavior: here be nasal demons →

🎰 Shifting a 32-bit int by 32 places (undefined behavior)

1 << 32 — zero, obviously? This program printed 1.

gotcha.c
#include <stdio.h>

int main(void) {
    unsigned int x = 1;
    int n = 32;
    printf("1 << 31 = %u\n", x << 31);
    printf("1 << 32 = %u\n", x << n);
    return 0;
}
you might expect0 — every bit was shifted out the far end
what actually happensprinted 1 at -O0 (the x86 shift instruction masks the count to 5 bits, so 32 acts like 0) — and 0 at -O2 when the compiler folds it. Same program, two answers

Shifting by an amount ≥ the width of the (promoted) type is undefined behavior (C17 6.5.7p3) — and this one shows why "UB" doesn't mean "crash": the program runs happily and simply gives different answers depending on who computes the shift. The x86 shl instruction masks its count mod 32, so at runtime 1 << 32 becomes 1 << 0; when the optimizer constant-folds it instead, it can produce 0 (or anything else). Note the promotion angle too: 1 << 31 already overflows a signed int — for bit masks, work unsigned and keep counts strictly below the width.

📖 Learn it properly: Undefined behavior: here be nasal demons →

📦 Array parameters are secretly pointers

Ten elements go in. The function counts two.

gotcha.c
#include <stdio.h>

void report(int arr[]) {
    printf("inside func: %zu elements?\n", sizeof(arr) / sizeof(arr[0]));
}

int main(void) {
    int a[10] = {0};
    printf("in main    : %zu elements\n", sizeof(a) / sizeof(a[0]));
    report(a);
    return 0;
}
you might expectboth lines print 10 elements
what actually happensin main: 10 elements — inside the function: 2 (sizeof gave the size of a pointer, 8, not the array, 40)

A parameter declared int arr[] — even int arr[10] — is adjusted to int *arr (C17 6.7.6.3p7): arrays are never passed by value in C, only a pointer to their first element travels. So inside the function, sizeof(arr) is sizeof(int *) = 8, and the "element count" is 8/4 = 2 regardless of what was passed. The sizeof a / sizeof a[0] trick works only where the actual array is in scope; a function must be handed the length as a separate parameter. GCC even ships a dedicated warning for this one: -Wsizeof-array-argument.

📖 Learn it properly: Arrays vs pointers: the great confusion →

🧱 i <= 5 walks one step past the end (undefined behavior)

We zero five scores. So why is total wiped too?

gotcha.c
#include <stdio.h>

int main(void) {
    struct { int scores[5]; int total; } s = { {10, 20, 30, 40, 50}, 150 };
    for (int i = 0; i <= 5; i++)     /* <=  goes one step too far */
        s.scores[i] = 0;
    printf("total = %d\n", s.total); /* we never touched total... right? */
    return 0;
}
you might expecttotal = 150 — the loop only touches scores
what actually happenstotal = 0: writing scores[5] landed on the neighboring field (verified at -O0; at -O2 the same UB happened to leave total at 150)

An array of 5 has valid indexes 0 through 4 — the condition i <= 5 performs six writes, and scores[5] is one past the end: undefined behavior (C17 6.5.6). Here the stray write deterministically lands on total, the next struct member in memory — a tidy demonstration of how buffer overflows corrupt whatever innocent data lives next door (in real programs: other variables, heap bookkeeping, return addresses — the raw material of security exploits). The idiomatic C loop is for (i = 0; i < N; i++): start at 0, compare with <, and the count of iterations equals N.

📖 Learn it properly: Arrays: many values, one name →

🚷 A string without its \0 doesn't know where to stop (undefined behavior)

Five letters, five bytes. What could printf get wrong?

gotcha.c
#include <stdio.h>

int main(void) {
    struct {
        char word[5];
        char next[9];
    } s = { {'h', 'e', 'l', 'l', 'o'}, "neighbor" };
    printf("word = %s\n", s.word);
    return 0;
}
you might expectword = hello
what actually happensword = helloneighbor — printf kept walking straight into the next field

A C string is bytes-until-\0, so "hello" really needs six bytes. The initializer {'h','e','l','l','o'} fills all five slots of word with letters and leaves no room for the terminator — and %s, which just walks memory until it meets a zero byte, strolls past the end into next. That out-of-bounds read is undefined behavior; here the struct layout makes the damage visible and repeatable. Write char word[6] = "hello"; (or let the compiler count: char word[] = "hello";) and the terminator is included automatically.

📖 Learn it properly: Strings: char arrays with a secret handshake →

✂️ strncpy doesn't promise a \0 (undefined behavior)

The "safe" string copy that quietly leaves strings unterminated.

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

int main(void) {
    struct { char dst[4]; char after[10]; } s;
    strcpy(s.after, "OOPS!");
    strncpy(s.dst, "gotcha", sizeof s.dst);   /* copies g,o,t,c - no '\0' */
    printf("dst = %s\n", s.dst);
    return 0;
}
you might expectdst = "gotc" — truncated, but a proper string
what actually happensdst = gotcOOPS! — four characters were copied, no terminator was written, and printf ran on into the next field

strncpy has a nasty contract (C17 7.24.2.4): if the source has ≥ n characters, it copies exactly n and does not write a terminator. "gotcha" is longer than 4, so dst got g o t c and nothing else — an unterminated buffer that %s happily reads past (undefined behavior, made visible here by the neighboring field). Despite the reassuring n in the name, strncpy was designed for fixed-width fields in 1970s Unix directory entries, not for safety. Either terminate manually — dst[sizeof dst - 1] = '\0'; — or use snprintf(dst, sizeof dst, "%s", src), which always terminates.

📖 Learn it properly: string.h: the null-terminated toolbox →

🔐 strcmp returns 0 when strings are EQUAL

The password is correct. The program says it is wrong.

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

int main(void) {
    char stored[] = "hunter2", typed[] = "hunter2";
    if (strcmp(stored, typed))
        printf("Password accepted!\n");
    else
        printf("Wrong password.\n");
    return 0;
}
you might expectthe strings match, strcmp reports success, "Password accepted!"
what actually happensprints "Wrong password." — strcmp returned 0 (falsy) precisely because the strings are equal

strcmp is not an "are these equal?" predicate — it's a three-way comparison for sorting: negative if the first string orders earlier, positive if later, and 0 on equality. Used bare in a condition, that 0 is falsy, so if (strcmp(a, b)) actually means "if different" — the exact opposite of what it appears to say. Always compare the result explicitly: strcmp(a, b) == 0 for equality. (Bonus trap in the same family: a == b on two char arrays compares their addresses, never their contents.)

📖 Learn it properly: string.h: the null-terminated toolbox →

🧟 Returning the address of a local variable (undefined behavior)

The function returned an address. The variable did not survive the trip.

gotcha.c
#include <stdio.h>

int *birthday(void) {
    int candles = 21;
    return &candles;                 /* address of a variable about to die */
}

void party(void) {
    volatile int balloons[4] = {99, 98, 97, 96};
    (void)balloons[0];
}

int main(void) {
    int *p = birthday();
    party();
    printf("candles: %d\n", *p);
    return 0;
}
you might expectcandles: 21
what actually happenscrash (SIGSEGV) — GCC spotted the escape, warned, and returned NULL instead of the dead address; on compilers that return the real address you read whatever the next call scribbled there

candles lives in birthday's stack frame, which is torn down the moment the function returns — the returned pointer refers to memory whose lifetime has ended, and using it is undefined behavior (C17 6.2.4). It's treacherous because it often appears to work: the stale value survives until the next call (here, party()) reuses the frame. GCC's -Wreturn-local-addr warning fires on this pattern and the compiler deliberately returns NULL instead, converting a subtle corruption into the honest crash we verified. To return data from a function, return it by value, or hand back malloc'd memory, or write into a caller-supplied buffer.

📖 Learn it properly: Scope & lifetime: who sees what, and for how long →

👻 Use after free: the haunted ticket (undefined behavior)

You freed it. malloc recycled it. Your old pointer never got the memo.

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

int main(void) {
    int *ticket = malloc(sizeof *ticket);
    *ticket = 42;
    free(ticket);
    int *other = malloc(sizeof *other);   /* recycles the same spot */
    *other = 777;
    printf("my ticket says: %d\n", *ticket);
    return 0;
}
you might expectmy ticket says: 42
what actually happensprinted 777 — the second malloc reused the same chunk, so the freed pointer showed the new tenant's data (at -O2 it printed allocator garbage instead)

After free(ticket), that pointer's value is indeterminate and dereferencing it is undefined behavior (C17 7.22.3) — but the bytes don't vanish, the allocator just puts the chunk back on its shelf. Ask for the same size again and glibc hands you the very same address, which is why *ticket "worked" and showed 777: two owners, one address, and the old pointer reads the new owner's data. This is the use-after-free — a bug class behind countless real-world exploits, and invisible in testing whenever the memory hasn't been reused yet. Discipline: free(p); p = NULL; so any later dereference crashes honestly.

📖 Learn it properly: malloc & friends: memory on demand →

☠️ free() twice, abort once (undefined behavior)

If one free is good, surely two is thorough?

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

int main(void) {
    char *p = malloc(16);
    free(p);
    free(p);                         /* freeing the same pointer twice */
    printf("done\n");
    return 0;
}
you might expectfreeing twice is harmless — then it prints "done"
what actually happenscrashes before "done" with: free(): double free detected in tcache 2

Calling free on a pointer that was already freed is undefined behavior (C17 7.22.3.3) — the second call hands the allocator a chunk that's already on its free list, corrupting the bookkeeping that every future malloc depends on. Historically that corruption was silently exploitable (an attacker could steer where the next allocation lands), which is why modern glibc actively checks and aborts with the message we verified. Note that "done" never printed: the abort fired before stdout was flushed. The same discipline as use-after-free saves you here — free(p); p = NULL; — because free(NULL) is defined to do nothing.

📖 Learn it properly: malloc & friends: memory on demand →

🚱 malloc can say no (undefined behavior)

Ask for 18 quintillion bytes, then store 42 in the answer.

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

int main(void) {
    size_t huge = (size_t)-1;        /* ~18 quintillion bytes */
    int *p = malloc(huge);
    *p = 42;                         /* malloc said no. p is NULL. */
    printf("stored %d\n", *p);
    return 0;
}
you might expecteither the allocation works or the program stops politely
what actually happenscrash (SIGSEGV) — malloc returned NULL, and *p = 42 wrote to address 0

malloc reports failure by returning NULL (C17 7.22.3.4) — it cannot throw, print, or apologize, and glibc rejects any request larger than PTRDIFF_MAX outright, so this one fails instantly. Dereferencing that NULL is undefined behavior; on Linux it's a segfault, on small embedded systems it can silently corrupt address 0 and keep running. Every allocation deserves the three-line tax: if (!p) { /* report and bail */ }. The subtler sibling to watch for: malloc(n * sizeof(int)) where the multiplication overflows first, allocating a tiny block that "succeeds" — calloc(n, sizeof(int)) checks that product for you.

📖 Learn it properly: malloc & friends: memory on demand →

🪤 SQUARE(a + 1) = 9

A macro that squares 3 perfectly and butchers a + 1.

gotcha.c
#include <stdio.h>
#define SQUARE(x) x * x

int main(void) {
    int a = 4;
    printf("SQUARE(3)     = %d\n", SQUARE(3));
    printf("SQUARE(a + 1) = %d\n", SQUARE(a + 1));
    return 0;
}
you might expecta is 4, so SQUARE(a + 1) is 5 squared: 25
what actually happensSQUARE(3) = 9 as advertised, but SQUARE(a + 1) = 9 too

Macros are text paste, not function calls: SQUARE(a + 1) expands to a + 1 * a + 1, and precedence regroups it as a + (1 * a) + 1 = 4 + 4 + 1 = 9. The macro never saw the value 5 — it saw three tokens and dropped them into a precedence minefield. The armor is parentheses on every parameter and the whole body: #define SQUARE(x) ((x) * (x)) — the inner pairs guard against the argument's operators, the outer pair against operators at the call site (100 / SQUARE(5)). And even fully armored, never pass side effects: SQUARE(i++) expands to two unsequenced i++s — undefined behavior.

📖 Learn it properly: Function-like macros: power tools with no guard →

🪞 Even a perfectly parenthesized macro evaluates twice

MAX(i++, 9) — fully armored in parentheses, and still wrong.

gotcha.c
#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main(void) {
    int i = 10;
    int best = MAX(i++, 9);          /* fully parenthesized... safe now? */
    printf("best = %d, i = %d\n", best, i);
    return 0;
}
you might expectbest = 10 (the current value of i), and i becomes 11
what actually happensbest = 11 and i = 12 — the i++ ran twice: once in the comparison, once producing the result

Parentheses fix precedence, but they cannot fix double evaluation — the macro pastes its argument text everywhere the parameter appears, so MAX(i++, 9) becomes ((i++) > (9) ? (i++) : (9)): the first i++ yields 10 (i→11), the comparison succeeds, and the second i++ yields 11 (i→12). This particular expansion is well-defined (?: is a sequence point) — just quietly wrong; with SQUARE(i++) the two increments are unsequenced and it's outright UB. A real function evaluates each argument exactly once; a macro makes no such promise. Keep side effects out of macro arguments — or use a function and let the optimizer inline it.

📖 Learn it properly: Function-like macros: power tools with no guard →

▶ Run all 30 traps interactively