Updated Jul 14, 2026

Control Flow

Right now, a C program you write runs exactly one way: top to bottom, one line after another, no decisions, no repetition. That's true but useless - almost nothing you actually want to build works that way. You need to skip code when a condition is false, repeat code while something holds, and choose between several paths. That's control flow, and it's the last piece you need before your programs can actually do something interesting.

The mental model: the program counter takes detours

What it actually is. Underneath, the CPU has a program counter - a register holding the address of the next instruction to run. By default it just increments: run this line, move to the next, run that one, move to the next. Every control flow construct in C - if, while, for, switch - compiles down to one thing: an instruction that changes the program counter based on a condition, instead of letting it fall through to the next line. if (x > 0) { ... } is, underneath, "check x > 0; if false, jump past this block." A while loop is "check the condition; if false, jump past the loop; otherwise run the body, then jump back to the check."

Once you see it that way, none of these keywords are separate magic - they're just different shapes of "conditionally jump."

Truthiness in C: there's no real boolean

What it actually is. Before you write a single if, you need to know what C considers "true." In plain C, there is no readable boolean type until you #include <stdbool.h>. Every condition is just an integer expression: 0 means false, and any nonzero value means true. if (5) is true. if (-1) is true. if (0) is the only way to get false.

Why people get this wrong. Coming from a language with a real bool, it's tempting to assume C has one too, and to write if (x = 1) meaning if (x == 1), which we'll get to below - it compiles, it just doesn't mean what you think.

📝 Terminology. C99 (a 1999 revision of the language) added #include <stdbool.h>, which gives you bool, true, and false as readable names. Under the hood bool is still just a small integer and true/false are still 1/0 - the header is a readability convenience, not a new type of value. Most modern C code includes it; we will too from here on.

#include <stdio.h>
#include <stdbool.h>

int main(void) {
    bool is_ready = true;
    if (is_ready) {
        printf("go\n");
    }
    return 0;
}
$ gcc -Wall -o prog prog.c && ./prog
go

What just happened: is_ready holds 1 (that's all true is). if (is_ready) checks "is this nonzero?" - it is, so the block runs.

if / else if / else

What it does in real life. C checks conditions top to bottom and runs the first branch whose condition is true, skipping the rest entirely.

int score = 72;

if (score >= 90) {
    printf("A\n");
} else if (score >= 80) {
    printf("B\n");
} else if (score >= 70) {
    printf("C\n");
} else {
    printf("F\n");
}
$ gcc -Wall -o prog prog.c && ./prog
C

What just happened: C checked score >= 90 (false), then score >= 80 (false), then score >= 70 (true) - printed C and skipped else. It never even looks at branches after the first true one.

flowchart TD
  A{score >= 90?} -- yes --> B[print A]
  A -- no --> C{score >= 80?}
  C -- yes --> D[print B]
  C -- no --> E{score >= 70?}
  E -- yes --> F[print C]
  E -- no --> G[print F]

⚠️ The gotcha that bites everyone once: = vs ==. = assigns; == compares. Both are valid inside an if's parentheses, so the compiler won't stop you from typing the wrong one.

int x = 0;
if (x = 5) {          // assigns 5 to x, then checks "is 5 truthy?" - yes, always
    printf("this always runs\n");
}
$ gcc -Wall -o prog prog.c && ./prog
prog.c:2:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
    2 |     if (x = 5) {
      |         ~~^~~
this always runs

What just happened: x = 5 is itself an expression that evaluates to 5 (the value just assigned), and 5 is nonzero, so the branch runs every time, and x has silently been overwritten. -Wall (which you should always compile with) catches this and warns you - don't ignore that warning.

⚠️ The dangling-else gotcha. Without braces, else always binds to the nearest unmatched if, which is not always the one that lines up visually:

if (a > 0)
    if (b > 0)
        printf("both positive\n");
else
    printf("a is not positive\n");   // misleading indentation!

Despite the indentation, that else belongs to if (b > 0), not if (a > 0) - so if a is -1, this prints nothing at all. Why this saves you later: always write braces on multi-statement or nested ifs, even when C doesn't require them. It costs you two characters and removes an entire category of bug.

Loops: while, do-while, for

What it actually is. All three loops are the same idea - repeat a block while a condition holds - with different places for the setup and the check.

while checks the condition before every iteration, including the first:

int n = 3;
while (n > 0) {
    printf("%d\n", n);
    n--;
}
$ gcc -Wall -o prog prog.c && ./prog
3
2
1

do-while checks after the body, so the body always runs at least once - useful for things like "prompt the user, then re-prompt while their input is bad":

int n = 0;
do {
    printf("ran once even though n starts at 0\n");
} while (n > 0);
$ gcc -Wall -o prog prog.c && ./prog
ran once even though n starts at 0

for bundles init, condition, and post-step into one line - it's the loop you reach for when you know how many times you're iterating:

for (int i = 0; i < 3; i++) {
    printf("%d\n", i);
}
$ gcc -Wall -o prog prog.c && ./prog
0
1
2
flowchart LR
  I[init: i = 0] --> C{i < 3?}
  C -- yes --> B[run body] --> P[i++] --> C
  C -- no --> D[loop exits]

What just happened: int i = 0 runs once, before anything else. Then C checks i < 3; if true, it runs the body, runs i++, and checks the condition again. It exits the moment the check fails - the body never runs a fourth time.

⚠️ The gotcha that costs people an hour: a stray semicolon. This compiles cleanly and silently loops forever, doing nothing:

int n = 0;
while (n < 5);      // <-- that semicolon is the entire loop body!
{
    printf("n is %d\n", n);   // this block is NOT part of the loop
    n++;
}

The ; right after while (n < 5) is the loop body - an empty statement that does nothing, checked forever, since n never changes. The { ... } that follows just runs once, separately, after the (never happening) loop ends. This is one of the most common "my program hung" bugs in C, and the fix is simply: never put a semicolon directly after a loop's condition unless you mean an empty body on purpose.

break and continue

break exits the nearest loop (or switch) immediately. continue skips straight to the next iteration's condition check (in a for loop, the update step - like i++ - runs first), without running the rest of the body.

for (int i = 0; i < 10; i++) {
    if (i == 3) continue;   // skip printing 3
    if (i == 6) break;      // stop entirely at 6
    printf("%d\n", i);
}
$ gcc -Wall -o prog prog.c && ./prog
0
1
2
4
5

What just happened: at i == 3, continue jumped straight back to i++ and the condition check, skipping the printf. At i == 6, break left the loop entirely - 4 and 5 printed, but nothing from 6 onward.

switch: choosing between many exact values

What it actually is. switch compares one value against a list of exact constants and jumps to the matching case. It exists (rather than everyone just writing a chain of else ifs) because a compiler can often turn a switch on a dense range of values into a jump table - one lookup straight to the right code, instead of testing conditions one by one. That's the design trade-off: switch is less flexible than if/else if (only exact-value matches, no ranges or >/<) in exchange for being potentially faster.

The gotcha that defines this construct: fallthrough. Unlike if/else if, a switch does not stop after the matching case - it keeps running every case below it too, until it hits a break or the end of the block.

int day = 6;

switch (day) {
    case 6:
        printf("Saturday\n");
    case 7:
        printf("Sunday\n");
        break;
    default:
        printf("Weekday\n");
}
$ gcc -Wall -o prog prog.c && ./prog
Saturday
Sunday

What just happened: day matched case 6, printed Saturday, and because that case has no break, execution fell straight through into case 7 and printed Sunday too, only stopping at its break. This is intentional C behavior, not a bug in the language - but forgetting a break when you didn't mean to fall through is one of the most common switch mistakes there is. Why this saves you later: when you write a switch, put a break at the end of every case unless you are deliberately using fallthrough (and if you are, a // falls through comment tells the next reader it's on purpose).

Short-circuit evaluation: && and ||

What it does in real life. C evaluates && and || left to right and stops as soon as the overall result is decided - it never evaluates the right side if the left side already settled the answer.

int *p = NULL;

if (p != NULL && *p > 0) {
    printf("positive\n");
}
$ gcc -Wall -o prog prog.c && ./prog
$

What just happened: nothing printed, and nothing crashed. p != NULL was false, and because && short- circuits, C never evaluated *p > 0 at all - it skipped dereferencing a null pointer, which would have crashed the program. This pattern (checking a pointer is valid before using it, in the same if, relying on short-circuiting) is everywhere in real C code. || short-circuits the same way in the other direction: if the left side is already true, the right side never runs.

Recap

  1. Every control flow construct is a conditional jump on the program counter - if, while, for, and switch are all shapes of the same idea.
  2. C has no true boolean at its core: 0 is false, anything else is true. stdbool.h gives you readable names for the same integers.
  3. if/else if/else runs the first true branch and skips the rest; always brace nested ifs to avoid the dangling-else trap.
  4. = inside a condition assigns and is (almost) always a bug - -Wall will warn you, so always compile with it.
  5. while checks before the body, do-while checks after (so it runs at least once), for bundles init/condition/post into one line.
  6. A semicolon right after a loop's condition creates an empty body and an infinite, do-nothing loop.
  7. switch falls through every case below the match unless you break - useful on purpose sometimes, a common bug the rest of the time.
  8. && and || short-circuit, which is what makes p != NULL && *p > 0 safe.

Test yourself on the ideas that trip people up most in this phase:

[
  {
    "q": "What happens when you compile and run `int x = 0; if (x = 5) { printf(\"yes\\n\"); }`?",
    "choices": [
      "It prints \"yes\", because `x = 5` assigns 5 to x and then evaluates to 5, which is truthy",
      "It doesn't compile, because `=` isn't allowed inside an `if`'s parentheses",
      "It prints nothing, because `x = 5` is treated as a comparison and 5 doesn't equal x's old value",
      "It's undefined behavior and could do anything"
    ],
    "answer": 0,
    "explain": "`=` assigns and the assignment expression evaluates to the value assigned; 5 is nonzero, so the branch runs every time regardless of x's prior value - this is why -Wall's warning about it matters."
  },
  {
    "q": "In a `switch` where `case 6:` matches but has no `break`, what happens next?",
    "choices": [
      "C jumps straight out of the switch, since a match was already found",
      "C keeps running the code in the cases below it until it hits a `break` or the end of the switch",
      "C raises a compile error, since every case needs a `break`",
      "C re-checks the switch value against the next case's condition before deciding whether to run it"
    ],
    "answer": 1,
    "explain": "Unlike if/else if, a matched case with no break falls through and runs every subsequent case's code unconditionally, regardless of whether their labels also match."
  },
  {
    "q": "Why does `if (p != NULL && *p > 0)` safely avoid crashing when `p` is `NULL`?",
    "choices": [
      "C evaluates both sides first and only crashes if both are true",
      "`&&` short-circuits: since `p != NULL` is false, C never evaluates `*p > 0` at all",
      "Dereferencing a NULL pointer in an `if` condition never crashes in C",
      "The compiler reorders the checks so the safer one always runs first"
    ],
    "answer": 1,
    "explain": "`&&` evaluates left to right and stops as soon as the result is decided, so a false left side skips the right side entirely - that's what makes the null-check-then-use pattern safe."
  }
]

← Guide overview · Phase 4: Functions & Program Structure →

Check your understanding 3 questions

1. What happens when you compile and run `int x = 0; if (x = 5) { printf("yes\n"); }`?

2. In a `switch` where `case 6:` matches but has no `break`, what happens next?

3. Why does `if (p != NULL && *p > 0)` safely avoid crashing when `p` is `NULL`?