Input and Output in C — printf, scanf, and Safe I/O Patterns

scanf("%s") wrote 31 bytes into an 8-byte buffer. A guide to C I/O that shows the unsafe patterns failing rather than just naming them.

A green arrow entering a box and blue and amber arrows leaving it, illustrating the stdin, stdout and stderr streams in C

The most-copied line in beginner C code is scanf("%s", name). Given thirty characters and an eight-byte buffer, it writes all thirty — AddressSanitizer reports a stack buffer overflow of exactly 31 bytes. The second most-copied is fflush(stdin), which is undefined behaviour and, on the machine used for this article, silently does nothing at all.

This guide covers C’s input and output properly: complete printf and scanf specifier tables, why output sometimes appears in the wrong order, and which input functions are safe to use in 2026 — with the unsafe ones demonstrated failing rather than merely described. Every program was compiled and run on Ubuntu 24.04 with GCC 13.3 under -std=c11 and -std=c17, with -Wall -Wextra. The overflow and the compiler diagnostics are captured verbatim.

Table of Contents

Streams: The Model Behind C I/O

In C, all input and output happens through streams. A stream is a sequence of bytes flowing into or out of your program, and the same functions work whether that stream is connected to the keyboard, a file, or another program — which is what makes C I/O device-independent. Three streams open automatically: stdin (input, normally the keyboard), stdout (output, normally the screen), and stderr (errors, also the screen but separately redirectable). All three are declared in <stdio.h>.

StreamPurposeDefaultBuffering
stdinInputKeyboardLine-buffered
stdoutNormal outputScreenLine-buffered to a terminal, block-buffered to a pipe or file
stderrErrors and warningsScreenUnbuffered

The header is <stdio.h> — standard I/O. It is not <stdlib.h>. Compiling a printf call with only <stdlib.h> included produces:

warning: implicit declaration of function 'printf' [-Wimplicit-function-declaration]

Two older streams, stdprn and stdaux, appear in pre-1995 tutorials. They were DOS extensions, never part of standard C, and do not exist on any current platform.

For the complete function list and their exact guarantees, cppreference’s <cstdio> reference is the authority.

printf: Formatted Output

printf writes a format string to stdout, substituting each conversion specification with the corresponding argument.

c

#include <stdio.h>

int main(void) {
    printf("Hello, world\n");                 /* literal text */
    printf("%d apples\n", 5);                 /* one integer */
    printf("%d plus %d is %d\n", 2, 3, 2+3);  /* several */
    return 0;
}

Conversion specifiers

SpecifierPrintsExample output
%d, %iSigned decimal integer42
%uUnsigned decimal integer42
%oUnsigned octal52
%x, %XUnsigned hexadecimal2a, 2A
%fDecimal floating point3.141590
%e, %EScientific notation1.234568e+04
%g, %GShorter of %e or %f1.23e-05
%cSingle characterA
%sStringhello
%pPointer address0x7ffc56a9e370
%%A literal percent sign%

Length modifiers — the part that causes bugs

Passing the wrong-width argument is undefined behaviour, not a formatting quirk:

TypeSpecifier
short%hd
long%ld
long long%lld
unsigned long%lu
double%f (same as float — it promotes)
long double%Lf
size_t%zu
ptrdiff_t%td
int32_t, int64_tPRId32, PRId64 from <inttypes.h>

%zu for size_t is the one people miss most often, because %d appears to work on 32-bit builds and then prints garbage on 64-bit. GCC catches every mismatch under -Wall, so there is no reason to guess.

A note for anyone reading older tutorials: the claim that “on a PC, short is the same as int, so %hd is never needed” was true on 16-bit DOS and is false today. Measured here: sizeof(short) is 2, sizeof(int) is 4.

Width, precision, and flags

c

printf("%-10s|%s\n", "left", "right");
printf("%10.3f|%+d|%05d|%#x|%#o\n", 3.14159, 42, 42, 255, 8);
left      |right
     3.142|+42|00042|0xff|010
  • Width (%10f) sets a minimum field width, padding with spaces.
  • Precision (%.3f) sets digits after the decimal point.
  • - left-justifies, + forces a sign, 0 pads with zeros, # adds the 0x or 0 prefix.

Why Your Output Appears in the Wrong Order

A prompt without a newline sometimes appears after the thing it was supposed to prompt for. This is not a bug in your code — it is buffering.

stdout is line-buffered when connected to a terminal, so output is flushed when a newline appears. When redirected to a file or a pipe, it becomes fully buffered, and nothing appears until the buffer fills or the program exits.

Same program, piped to a file:

Enter your name: 
[1 second passed before this line]

Both lines arrived together at exit, in a single block.

Two fixes, in order of preference:

c

printf("Enter your name: ");
fflush(stdout);              /* flush output explicitly - well defined */

or end the prompt with \n. Note this is fflush(stdout), which is entirely valid — unlike fflush(stdin), covered below.

stderr is unbuffered by design, which is why error messages appear immediately even when a program crashes mid-write.

Reading Input Safely

This is the section where most C tutorials, including the previous version of this one, actively cause harm.

Reading input in C: what to useEvery verdict below was compiled and run. Errors are real GCC output.gets(buf)REMOVED from C in C11cannot bound the input at allneverscanf(“%s”, buf)unbounded writeoverflowed char[8] with 30 bytesneverfflush(stdin)undefined behaviourdid nothing here; read ‘and’neverscanf(“%7s”, buf)bounded, but stops at spacecheck the return valueokfgets(buf, n, stdin)bounded, keeps the linestrip the trailing newlinepreferredThe linker itself warns about gets():warning: the `gets’ function is dangerous and should not be used.

gets() — removed from the language

gets() cannot be used safely. It has no way to know the size of your buffer, so any input longer than the buffer overwrites whatever follows it. This is the flaw the 1988 Morris worm exploited, and the C standards committee removed gets() from the language in C11.

Compiling the published example under -std=c11:

warning: implicit declaration of function 'gets'; did you mean 'fgets'?

It is not declared, because it no longer exists. And if you force it through, the GNU linker adds its own verdict:

warning: the `gets' function is dangerous and should not be used.

When the linker warns you about a function by name, the argument is over.

scanf(“%s”) — unbounded by default

scanf("%s", buf) has exactly the same flaw. Reading thirty characters into char name[8]:

ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 31

The fix is a field width, one character less than the buffer to leave room for the terminator:

c

char name[8];
if (scanf("%7s", name) == 1)
    printf("read: %s\n", name);
read: AAAAAAA

Always check the return value. scanf returns the number of items successfully assigned — not the number you asked for. Ignoring it means using variables that were never written.

fgets() — the one to reach for

c

char line[128];
if (fgets(line, sizeof line, stdin) != NULL) {
    line[strcspn(line, "\n")] = '\0';   /* strip the trailing newline */
    printf("You typed: %s\n", line);
}

fgets takes the buffer size, so it cannot overflow. It reads a whole line including spaces, which scanf("%s") cannot. The one thing to remember is that it keeps the newline — strcspn is the tidiest way to remove it.

fflush(stdin) Does Not Work

After scanf("%d", &age) reads a number, everything else the user typed is still sitting in the input buffer, waiting to be misread by the next call. Countless tutorials — including the one this replaces — recommend fflush(stdin) to clear it.

fflush on an input stream is undefined behaviour. The C standard defines fflush only for output streams. Microsoft’s runtime happens to implement it as an extension; glibc does not.

Tested with the input 21 and never older followed by Sam:

=== with fflush(stdin) ===
age? name? age=21 name=and

=== with the portable fix ===
age? name? age=21 name=Sam

fflush(stdin) did nothing. The leftover text was read as the name — precisely the bug it was supposed to prevent.

The portable fix reads and discards characters up to the newline:

c

static void discard_line(void) {
    int c;
    while ((c = getchar()) != '\n' && c != EOF) { }
}

Call that after any scanf whose leftovers might matter. It is defined behaviour on every platform.

Character and Line Functions

FunctionReads/writesNotes
getchar()One character from stdinReturns int, not char, so it can return EOF
putchar(c)One character to stdout
fgetc(fp), fputc(c, fp)One character, any stream
fgets(buf, n, stdin)One line, boundedPreferred for line input
puts(s)String plus a newlineAdds \n automatically
fputs(s, fp)String, any streamDoes not add \n
ungetc(c, fp)Pushes one character backOnly one is guaranteed

getchar() returns int for a reason. It must be able to return every possible character and the distinct value EOF. Storing the result in a char makes the EOF test unreliable:

c

int c;                       /* correct */
while ((c = getchar()) != EOF) { ... }

EOF is negative, but the standard does not guarantee it is exactly −1. Compare against EOF rather than a literal.

stderr and Redirection

Send diagnostics to stderr, never stdout:

c

fprintf(stderr, "Error: could not open %s\n", filename);

That keeps error text out of redirected output, so ./prog > results.txt still shows problems on screen.

stderr can be redirected — older DOS-era tutorials claim otherwise. In any modern shell:

bash

./prog > out.txt 2> errors.txt     # stdout and stderr separately
./prog > combined.txt 2>&1         # both to one file

One security point worth stating plainly:

c

fprintf(stderr, msg);              /* WRONG - format string vulnerability */
fprintf(stderr, "%s", msg);        /* correct */

If msg contains user input with a %s or %n in it, the first form interprets it as a format specification and reads arguments that were never passed. %n in particular can write to memory. Always supply the format string yourself.

Common Beginner Errors

SymptomCauseFix
Prompt appears after the inputstdout buffered, no newlinefflush(stdout) after the prompt
Second scanf skipped entirelyNewline left in the bufferdiscard_line() after reading numbers
Garbage or crash on long inputscanf("%s") or gets()fgets, or scanf("%7s")
String has a trailing newlinefgets keeps itline[strcspn(line, "\n")] = '\0';
Variable unchanged after scanfReturn value not checkedif (scanf(...) != 1) { ... }
Wrong numbers printedSpecifier/type mismatch%zu for size_t, %ld for long
while (!feof(fp)) reads twicefeof is true only after a failed readTest the read call itself

Key Takeaways

  • gets() was removed from C in C11. GCC no longer declares it, and the linker warns that it “is dangerous and should not be used.”
  • scanf("%s") is unbounded — it wrote 31 bytes into an 8-byte buffer in testing. Use a field width, or use fgets.
  • fflush(stdin) is undefined behaviour and did nothing in testing. Read and discard to the newline instead.
  • Always check scanf‘s return value. It reports how many items were assigned, which may be fewer than you asked for.
  • stdout is line-buffered to a terminal, fully buffered to a pipe. That is why prompts appear late; fflush(stdout) fixes it.
  • %zu for size_t, %ld for long. Mismatches are undefined behaviour, and -Wall catches all of them.
  • Never pass a variable as a format string. fprintf(stderr, msg) is a vulnerability; fprintf(stderr, "%s", msg) is not.

Frequently Asked Questions

Conclusion

C’s I/O functions are older than most of the people using them, and the internet’s collective memory of how to use them has not aged well. gets() is not merely discouraged; it was deleted from the language. fflush(stdin) does not do what a generation of tutorials claims. scanf("%s") is the same buffer overflow as gets() wearing different clothes.

The safe subset is small and easy to remember: fgets for lines, bounded scanf with a checked return value for numbers, printf with a specifier that matches the type, fflush(stdout) for prompts, and stderr for anything that went wrong. Compile with -Wall -Wextra and the compiler will catch the rest. Once keyboard I/O behaves, the same stream model extends directly to disk — the guide to file handling in C picks up exactly where this leaves off, and the C programming section covers the language features these functions operate on.

Scroll to Top