The C Path — learn C, visually

📚 The Standard Library

stdio.h: printf, scanf & files, done right

⏱ 16 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

Type letters when a program asks for a number and a surprising amount of software freezes or loops forever — that's the scanf trap, and this lesson teaches you the input pattern real programs use so bad typing can never break yours. You'll also read and write your first files, and discover why a message you printed right before a crash sometimes never appears — a quirk that fools people into hunting bugs in the wrong place.

You've used printf since lesson one — but stdio.h is a deep library, and most C bugs in the wild involve it. In this lesson you'll finally learn the full anatomy of a format specifier (the %d-style placeholders you've typed since day one), why scanf is a loaded gun, the robust input pattern professionals use, how to read and write files, and why the library sometimes holds your output in a buffer instead of showing it immediately.

Anatomy of a printf format specifier

Every conversion follows one grammar: %[flags][width][.precision][length]conversion. Each part is optional except the conversion letter:

partexamplesmeaning
flags- + 0 # ' 'left-align · always show sign · zero-pad · alt form (0x, trailing .) · space for plus
width8, *minimum field width (* = take it from an int argument)
.precision.2, .*digits after the point (floats) · max chars (%s) · min digits (%d)
lengthh hh l ll zargument size: short, char, long, long long, size_t
conversiond u x o f e g c s p %signed · unsigned · hex · octal · fixed float · scientific · shortest · char · string · pointer · literal %

Watch the pieces combine — same values, wildly different output:

format.c
#include <stdio.h>

int main(void) {
    int    n = 42;
    double pi = 3.14159265;
    printf("[%d]\n",     n);    /* plain            */
    printf("[%8d]\n",    n);    /* width 8, right   */
    printf("[%-8d]\n",   n);    /* flag -: left     */
    printf("[%08d]\n",   n);    /* flag 0: zero-pad */
    printf("[%+d]\n",    n);    /* flag +: sign     */
    printf("[%#x]\n",    n);    /* flag #: 0x form  */
    printf("[%.2f]\n",   pi);   /* precision 2      */
    printf("[%10.2f]\n", pi);   /* width + prec     */
    printf("[%.3s]\n",   "hello"); /* 3 chars max   */
    printf("[%*d]\n", 6, n);    /* width from arg   */
    return 0;
}
terminal
$ gcc format.c -o format && ./format
[42]
[      42]
[42      ]
[00000042]
[+42]
[0x2a]
[3.14]
[      3.14]
[hel]
[    42]
💡

%.3s prints at most 3 characters of a string — a rare printf feature that lets you print a substring without copying it. And %*d reads the width from an argument, so column widths can be computed at runtime.

🧠 Checkpoint: What does printf("%07.2f", 3.5) print?

  • 3.50
  • 0003.50
  • 3.5000000
  • 3.50
Show answer

0003.50 — Precision .2 gives "3.50" (4 chars), width 7 demands seven, and the 0 flag pads with zeros instead of spaces: 0003.50. Note the width counts the point and digits — everything.

scanf: powerful, and dangerous

scanf is printf in reverse: it parses text from stdin into variables you pass by address. Two things bite everyone:

  1. It returns a value almost nobody checks — the number of conversions that succeeded. If the user types abc when you asked for %d, scanf converts nothing, leaves the garbage in the input buffer, and your variable is uninitialized.
  2. %s with no width is a buffer overflow waiting to happen — scanf will happily write 500 characters into your 16-byte array.
scanf_danger.c — study, don’t imitate the bad lines
char name[16];
int age;

scanf("%s", name);        /* 💀 no width limit: overflow!    */
scanf("%15s", name);      /* ok: at most 15 chars + '\0'     */

scanf("%d", &age);        /* ⚠ return value ignored!         */

if (scanf("%d", &age) != 1) {   /* ✔ check it                */
    /* "abc" is still stuck in the input buffer here —       */
    /* the next scanf("%d") will fail the same way, forever  */
}
💀

Never write scanf("%s", buf). There is no limit on how much it writes — a long input overruns the buffer, which is undefined behavior and a classic security hole (the gets() function was removed from C11 for exactly this reason). Always give a width: scanf("%15s", buf) for a char buf[16] — the width counts characters before the terminating \0.

🧠 Checkpoint: scanf("%d", &x) returns what on success?

  • 0
  • The value read
  • The number of successful conversions — here 1
  • The number of characters consumed
Show answer

The number of successful conversions — here 1 — scanf returns how many conversions succeeded (or EOF at end of input). One %d means success is exactly 1. Ignoring this return value means using an uninitialized variable when parsing fails.

The robust pattern: fgets + sscanf

Professional C reads input in two steps: grab a whole line with fgets (which takes a buffer size and never overflows), then parse the line with sscanf (scanf on a string). Bad input? The line is consumed either way — just ask again. No stuck buffers, no overflow:

robust_input.c
#include <stdio.h>

int main(void) {
    char line[128];
    int age = 0;

    for (;;) {
        printf("Age? ");
        fflush(stdout);                     /* prompt has no \n */
        if (!fgets(line, sizeof line, stdin))
            return 1;                       /* EOF or error     */
        if (sscanf(line, "%d", &age) == 1)
            break;                          /* got a number     */
        printf("That wasn't a number, try again.\n");
    }
    printf("You are %d.\n", age);
    return 0;
}

This spot has an interactive flow widget — open the interactive lesson to play with it.

Files: fopen and friends

A FILE * is an opaque handle to an open stream. You get one from fopen(path, mode), use the f-family functions on it, and always fclose it (which also flushes pending writes):

modemeaningif file existsif it doesn't
"r"readopen at startfopen returns NULL
"w"writetruncated to empty!created
"a"appendwrites go to the endcreated
"r+"read + writeopen at startNULL
"w+"read + writetruncatedcreated
"rb", "wb"binary variantsno newline translation (matters on Windows)

Text I/O uses fprintf/fscanf/fgets; raw bytes use fread/fwrite; and fseek/ftell/rewind move the file position. Here's a full round trip:

files.c
#include <stdio.h>

int main(void) {
    FILE *f = fopen("scores.txt", "w");
    if (!f) { perror("fopen"); return 1; }
    fprintf(f, "alice %d\n", 91);
    fprintf(f, "bob %d\n",   84);
    fclose(f);                         /* flushes + releases  */

    f = fopen("scores.txt", "r");
    if (!f) { perror("fopen"); return 1; }

    char name[32]; int score;
    while (fscanf(f, "%31s %d", name, &score) == 2)
        printf("%s scored %d\n", name, score);

    fseek(f, 0, SEEK_END);             /* jump to the end     */
    printf("file is %ld bytes\n", ftell(f));
    rewind(f);                         /* back to the start   */

    if (ferror(f)) printf("a real I/O error occurred\n");
    fclose(f);
    return 0;
}
terminal
$ gcc files.c -o files && ./files
alice scored 91
bob scored 84
file is 16 bytes
⚠️

The while (!feof(f)) anti-pattern. feof only turns true after a read has already failed — it does not predict the future. Looping on !feof processes the last record twice (once real, once from the failed read that left stale data). The correct idiom: loop on the read function itself — while (fgets(line, sizeof line, f)) — and afterwards use feof/ferror to learn why it stopped: end of file, or an actual error.

🤔 A file holds two lines. Why does while (!feof(f)) { fgets(line,…,f); puts(line); } print the second line twice?

Think first

After reading line 2, EOF has not been hit yet — the file position sits just past the final newline. feof is still false, so the loop runs a third time. That third fgets fails, returns NULL, and leaves line untouched — still holding line 2 — which puts happily prints again. Loop on the read call itself: while (fgets(line, sizeof line, f)).

Buffering: why your printf vanished

stdio doesn't hand every byte to the OS immediately — it collects output in a buffer. Terminals are line-buffered (flushed at each \n), files and pipes are fully buffered (flushed when the buffer fills), and stderr is unbuffered. So if your program crashes, output still sitting in the buffer is lost — which makes printf-debugging lie to you about where the crash happened:

buffered.c — the vanishing printf
#include <stdio.h>

int main(void) {
    printf("checkpoint A");   /* no \n — sits in the buffer */
    int *p = 0;
    *p = 42;                  /* 💥 crash: SIGSEGV           */
    printf("checkpoint B");
    return 0;
}
terminal
$ gcc buffered.c -o buffered && ./buffered
Segmentation fault (core dumped)
# "checkpoint A" never appeared — it died in the buffer.
# You'd wrongly conclude the crash was BEFORE the printf!
$ # fix: fprintf(stderr, ...) or fflush(stdout) after each print

Fixes: end debug prints with \n and call fflush(stdout), print to stderr instead, or disable buffering with setvbuf(stdout, NULL, _IONBF, 0) while debugging.

🧠 Checkpoint: Your program crashed and a printf placed before the crash never showed. Most likely reason?

  • printf is broken
  • The output was still in stdio’s buffer when the process died
  • The compiler reordered the code
  • stdout was closed
Show answer

The output was still in stdio’s buffer when the process died — Buffered output is only handed to the OS on a flush (newline on terminals, buffer-full on pipes/files, fclose, or normal exit). A crash skips all of that. fflush(stdout) or stderr are your debugging friends.

This spot has an interactive editor widget — open the interactive lesson to play with it.

stdio covers I/O — next door lives stdlib.h, the junk drawer of essentials: conversions, random numbers, sorting, and program control.

▶ Practice this lesson interactively (with live gcc)