📚 The Standard Library
stdio.h: printf, scanf & files, done right
▶ Open the interactive lesson — free, no signupType 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:
| part | examples | meaning |
|---|---|---|
| flags | - + 0 # ' ' | left-align · always show sign · zero-pad · alt form (0x, trailing .) · space for plus |
| width | 8, * | minimum field width (* = take it from an int argument) |
| .precision | .2, .* | digits after the point (floats) · max chars (%s) · min digits (%d) |
| length | h hh l ll z | argument size: short, char, long, long long, size_t |
| conversion | d 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:
#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;
}$ 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.500003.503.50000003.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:
- It returns a value almost nobody checks — the number of conversions that succeeded. If the user types
abcwhen you asked for%d, scanf converts nothing, leaves the garbage in the input buffer, and your variable is uninitialized. %swith no width is a buffer overflow waiting to happen — scanf will happily write 500 characters into your 16-byte array.
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:
#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):
| mode | meaning | if file exists | if it doesn't |
|---|---|---|---|
"r" | read | open at start | fopen returns NULL |
"w" | write | truncated to empty! | created |
"a" | append | writes go to the end | created |
"r+" | read + write | open at start | NULL |
"w+" | read + write | truncated | created |
"rb", "wb"… | binary variants | no 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:
#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;
}$ 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:
#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;
}$ 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.