Standard I/O in C

Standard I/O (<stdio.h>) is how a C program talks to a terminal, a user, or another program connected via pipes - printf, scanf, and everything built on stdin/stdout/stderr. It looks simple in a classroom ("read a number, print a number"), but real-world code has to handle input that's malformed, too long, the wrong type, empty, or outright hostile - and most of the classic C security bugs you've heard about (buffer overflows, format string exploits) trace directly back to sloppy standard I/O usage. This guide is about doing it the way production code actually does.


0. Why real-world I/O handling is needed, why industry treats it as a security boundary, and why your syllabus quietly skips most of it

Why it's needed at all

scanf("%d", &n) works perfectly... until someone types "banana" instead of a number, or pastes in a 10,000-character string where you expected a name, or pipes in a file with no trailing newline, or hits Ctrl+D with nothing typed at all. The moment a program's input comes from a real user (or a real file, or a real network connection) instead of a tidy classroom test case, every assumption about "the input will look like I expect" becomes a potential bug - or a security hole.

Why real projects treat this so seriously

Why your syllabus (probably) stopped early

This is maybe the single most consequential gap between "passes a classroom C assignment" and "ships C code that survives contact with real, uncooperative input" - and it's almost entirely learnable from one guide, which is exactly what this one is trying to be.


1. The printf/scanf family - a quick refresher

Both functions are built around format specifiers that describe the type of each value. Getting the specifier wrong for the actual argument type is undefined behavior - the compiler often warns, but doesn't always catch it.

#include <stdio.h>

int main(void) {
    int age = 25;
    float gpa = 3.8f;
    char grade = 'A';
    char name[] = "Alice";

    printf("%s is %d years old, GPA %.1f, grade %c\n", name, age, gpa, grade);

    int readAge;
    printf("Enter age: ");
    if (scanf("%d", &readAge) == 1) {   // ALWAYS check scanf's return value
        printf("You entered: %d\n", readAge);
    } else {
        printf("That wasn't a valid number.\n");
    }

    return 0;
}

scanf returns the number of items it successfully matched and assigned - not 0 for "worked," and not automatically 1 just because you called it. Ignoring that return value is where most scanf-based bugs start.


2. Why raw scanf("%s", ...) is dangerous

%s with scanf (and fscanf) reads until whitespace with no bound on length - just like gets(), it will happily write past the end of your buffer if the input is longer than you allocated for.

#include <stdio.h>

int main(void) {
    char name[8];

    // DANGEROUS: if the user types more than 7 characters, this
    // overflows the buffer -- scanf has no idea how big 'name' is.
    printf("Enter name: ");
    scanf("%s", name);   // no length limit specified!
    printf("Hello, %s\n", name);

    return 0;
}

The fix is a width specifier, which caps how many characters %s will write:

#include <stdio.h>

int main(void) {
    char name[8];

    printf("Enter name: ");
    scanf("%7s", name);   // reads at most 7 chars + the null terminator = fits in 8
    printf("Hello, %s\n", name);

    return 0;
}

Even with a width limit, scanf for strings is still fragile in other ways (section 5) - for real-world input, fgets (section 4) is generally preferred over scanf entirely.


3. Why gets() must never be used

gets() reads a line into a buffer with no length parameter at all - there is no way to tell it how big your buffer is, so there is no way for it to avoid overflowing it. This is exactly the vulnerability class behind the 1988 Morris Worm, one of the first major internet security incidents, and why gets() was removed from the C standard entirely in C11 (not deprecated - deleted).

#include <stdio.h>

int main(void) {
    char buffer[10];

    // NEVER DO THIS. gets() cannot know buffer is only 10 bytes.
    // Typing more than 9 characters overflows into adjacent memory --
    // potentially corrupting other variables, or the return address
    // on the stack, making this a classic exploitable buffer overflow.
    // gets(buffer);    // <-- won't even compile on a C11+ standard-conformant compiler

    // ALWAYS use fgets instead -- it takes an explicit size limit.
    fgets(buffer, sizeof(buffer), stdin);
    printf("%s", buffer);

    return 0;
}

If you ever see gets() in a codebase (or in a textbook example), treat it as a bug to fix, not a pattern to follow - fgets is the direct, safe replacement in every situation gets() was used for.


4. The real-world pattern: fgets + parsing, instead of raw scanf

Production code overwhelmingly prefers reading a whole line with fgets (which has a hard size limit) and then parsing that line, rather than using scanf directly on stdin. This separates "safely getting bytes off the input stream" from "interpreting what those bytes mean," which makes error handling much cleaner.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    char line[64];

    printf("Enter your age: ");
    if (fgets(line, sizeof(line), stdin) == NULL) {
        fprintf(stderr, "No input received.\n");
        return 1;
    }

    // fgets keeps the trailing newline -- strip it before parsing
    line[strcspn(line, "\n")] = '\0';

    char *endPtr;
    long age = strtol(line, &endPtr, 10);

    if (endPtr == line || *endPtr != '\0') {
        fprintf(stderr, "'%s' is not a valid number.\n", line);
        return 1;
    }

    printf("Age: %ld\n", age);
    return 0;
}

This pattern - fgets for safe, bounded input, then strtol/strtod/ sscanf for parsing with explicit error checking - is close to universal in real-world C command-line tools.


5. The classic scanf leftover-newline trap

scanf("%d", ...) consumes the digits but leaves the trailing \n sitting in the input buffer. The next read (especially a %c or a subsequent fgets) then immediately consumes that leftover newline instead of the input you meant it to read - a famously confusing bug for anyone learning C.

#include <stdio.h>

int main(void) {
    int age;
    char name[32];

    printf("Enter age: ");
    scanf("%d", &age);          // reads "25", leaves "\n" sitting in the buffer

    printf("Enter name: ");
    fgets(name, sizeof(name), stdin);   // WRONG RESULT: immediately reads the
                                          // leftover "\n" from the previous
                                          // line, giving an empty name

    printf("Age: %d, Name: %s\n", age, name);
    return 0;
}

Fix: explicitly consume the rest of the line after a scanf call, before switching to fgets:

#include <stdio.h>

int main(void) {
    int age;
    char name[32];

    printf("Enter age: ");
    scanf("%d", &age);

    int c;
    while ((c = getchar()) != '\n' && c != EOF) {
        // discard leftover characters, including the newline, up to end of line
    }

    printf("Enter name: ");
    fgets(name, sizeof(name), stdin);   // now reads the actual next line correctly

    printf("Age: %d, Name: %s", age, name);
    return 0;
}

This is exactly why many real-world programs avoid mixing scanf and fgets altogether, and just use fgets + parsing everywhere (section 4).


6. Validating and sanitizing numeric input with strtol/strtod

atoi/atof have no error reporting at all - they silently return 0 for both "0" and "garbage", making it impossible to tell valid input from invalid input. strtol/strtod solve this with an "end pointer" that tells you exactly how much of the string was actually consumed as a number.

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>

int parseInt(const char *str, long *outValue) {
    char *endPtr;
    errno = 0;   // strtol doesn't reset errno itself -- you must do it first

    long value = strtol(str, &endPtr, 10);

    if (endPtr == str) {
        return 0;   // no digits were consumed at all -- not a number
    }
    if (*endPtr != '\0') {
        return 0;   // trailing garbage after the number, e.g. "42abc"
    }
    if (errno == ERANGE || value > INT_MAX || value < INT_MIN) {
        return 0;   // out of range, e.g. overflowed a long, or too big for int
    }

    *outValue = value;
    return 1;   // success
}

int main(void) {
    long result;

    printf("%d\n", parseInt("42", &result));       printf("%ld\n", result);   // 1, 42
    printf("%d\n", parseInt("banana", &result));   // 0 -- rejected
    printf("%d\n", parseInt("42abc", &result));    // 0 -- rejected (trailing junk)
    printf("%d\n", parseInt("99999999999999", &result)); // 0 -- rejected (out of range)

    return 0;
}

This is the real-world standard for "safely turn user text into a number" - atoi should be treated as unsuitable for anything where the input isn't already guaranteed valid.


7. Validating and sanitizing string input

Beyond just bounding the length (section 2), real-world input often needs content validation - rejecting or stripping characters that don't belong, especially before that input is used somewhere sensitive (a file path, a shell command, a database query, HTML output).

#include <stdio.h>
#include <string.h>
#include <ctype.h>

// Returns 1 if every character is alphanumeric or underscore -- e.g. for
// validating something like a username before using it in a file path.
int isValidUsername(const char *str) {
    if (str[0] == '\0') return 0;   // reject empty input

    for (size_t i = 0; str[i] != '\0'; i++) {
        if (!isalnum((unsigned char)str[i]) && str[i] != '_') {
            return 0;
        }
    }
    return 1;
}

int main(void) {
    printf("%d\n", isValidUsername("alice_92"));   // 1 -- valid
    printf("%d\n", isValidUsername("../etc/passwd")); // 0 -- rejected: contains '/' and '.'
    printf("%d\n", isValidUsername(""));            // 0 -- rejected: empty

    return 0;
}

The general principle real-world code follows is allow-listing (explicitly permitting only known-safe characters) rather than block-listing (trying to enumerate every dangerous character) - it's far easier to accidentally miss a dangerous character than to miss a safe one, so allow-listing is the safer default.


8. Safe formatted output: snprintf instead of sprintf

sprintf writes to a buffer with no length limit, exactly like the gets() problem but on the output side - if the formatted result is longer than your buffer, it overflows. snprintf takes an explicit size limit and truncates safely instead.

#include <stdio.h>

int main(void) {
    char buffer[16];

    // DANGEROUS: if the formatted string exceeds 16 bytes, this
    // overflows the buffer just like an unchecked strcpy would.
    // sprintf(buffer, "Hello, %s! You are visitor #%d", "Alice", 123456789);

    // SAFE: snprintf never writes more than 'sizeof(buffer)' bytes,
    // including the null terminator -- excess output is simply truncated.
    int written = snprintf(buffer, sizeof(buffer), "Hello, %s! You are visitor #%d",
                            "Alice", 123456789);

    printf("buffer: %s\n", buffer);
    printf("would have needed %d bytes (excluding null terminator)\n", written);

    return 0;
}

snprintf's return value is the number of bytes that would have been written with an unlimited-size buffer - useful for detecting truncation (written >= sizeof(buffer) means the output didn't fully fit).


9. Format string vulnerabilities - never pass user input as the format string

printf(userInput) (rather than printf("%s", userInput)) is a serious, well-known vulnerability class: if the attacker's input contains format specifiers like %x or %n, printf will try to read (or, with %n, write to) arguments that were never actually passed - leaking stack memory, crashing the program, or in the worst cases enabling arbitrary memory writes.

#include <stdio.h>

int main(void) {
    char userInput[64];
    fgets(userInput, sizeof(userInput), stdin);

    // DANGEROUS: if userInput contains format specifiers like "%x %x %x %n",
    // printf will try to read/write nonexistent arguments -- this is a real,
    // historically-exploited vulnerability class (a "format string bug").
    // printf(userInput);

    // SAFE: user input is always passed as a VALUE to a format specifier,
    // never as the format string itself.
    printf("%s", userInput);

    return 0;
}

The fix is simple and absolute: user-controlled data is never the format string - it's always an argument to %s (or another appropriate specifier) in a format string you wrote. This one rule eliminates the entire vulnerability class.


10. Integer overflow when parsing numeric input

Beyond just rejecting non-numeric input, real code has to guard against technically-valid numbers that are simply too large for the target type - strtol reports this via ERANGE, but only if you actually check for it (as shown fully in section 6). Here's the failure mode in isolation:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>

int main(void) {
    const char *hugeNumber = "999999999999999999999";

    errno = 0;
    long value = strtol(hugeNumber, NULL, 10);

    if (errno == ERANGE) {
        printf("Input was out of range for a long -- rejecting.\n");
    } else {
        printf("Parsed value: %ld\n", value);
    }

    // If you need it to additionally fit in a smaller type (like int),
    // check against that type's own limits too:
    if (value > INT_MAX || value < INT_MIN) {
        printf("Value doesn't fit in an int either.\n");
    }

    return 0;
}

Without this check, a program that assumes "if it parsed, it's a reasonable number" can end up with silently wrapped-around or clamped values feeding into array indices, memory allocation sizes, or loop bounds - a well-known source of real security bugs.


11. Handling EOF and empty input gracefully

Input can simply stop - the user hits Ctrl+D (Unix) / Ctrl+Z (Windows), a piped file runs out, or a redirect points at an empty file. Real code checks for this explicitly instead of assuming input always arrives.

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

int main(void) {
    char line[64];

    printf("Enter a command (Ctrl+D to quit): ");
    while (fgets(line, sizeof(line), stdin) != NULL) {
        line[strcspn(line, "\n")] = '\0';

        if (strlen(line) == 0) {
            printf("(empty input, try again)\n");
        } else {
            printf("You entered: %s\n", line);
        }

        printf("Enter a command (Ctrl+D to quit): ");
    }

    // fgets returned NULL -- either EOF or a read error
    if (feof(stdin)) {
        printf("\nEOF reached, exiting cleanly.\n");
    } else if (ferror(stdin)) {
        fprintf(stderr, "\nAn input error occurred.\n");
    }

    return 0;
}

A program that instead assumes fgets/scanf always succeeds - and doesn't check the return value - will typically end up in an infinite loop or working with stale/garbage data once input actually runs out.


12. Trimming and normalizing input

Real user input almost always needs cleanup before it's usable - trailing newlines (already shown), leading/trailing whitespace, and sometimes case normalization for case-insensitive comparisons.

#include <stdio.h>
#include <string.h>
#include <ctype.h>

// Trims leading and trailing whitespace IN PLACE.
void trim(char *str) {
    // Trim trailing whitespace first
    size_t len = strlen(str);
    while (len > 0 && isspace((unsigned char)str[len - 1])) {
        str[--len] = '\0';
    }

    // Trim leading whitespace by shifting the remaining content left
    size_t start = 0;
    while (str[start] != '\0' && isspace((unsigned char)str[start])) {
        start++;
    }
    if (start > 0) {
        memmove(str, str + start, len - start + 1);   // +1 to include the null terminator
    }
}

void toLowercase(char *str) {
    for (size_t i = 0; str[i] != '\0'; i++) {
        str[i] = (char)tolower((unsigned char)str[i]);
    }
}

int main(void) {
    char input[] = "   Hello, World!   \n";

    trim(input);
    printf("trimmed: '%s'\n", input);

    toLowercase(input);
    printf("lowercased: '%s'\n", input);

    return 0;
}

Normalizing input like this before comparing it (e.g. against a list of valid commands) avoids a huge class of "works when I type it, fails when a real user types it slightly differently" bugs.


13. Parsing structured input robustly

Real command-line tools often need to parse a line into multiple fields - robust code checks that every expected field was actually present, rather than assuming a fixed input shape.

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main(void) {
    char line[128];
    printf("Enter: name age city (e.g. 'Alice 30 Boston')\n> ");

    if (fgets(line, sizeof(line), stdin) == NULL) {
        return 1;
    }

    char name[32], city[32];
    int age;

    // sscanf's return value tells you exactly how many fields matched --
    // treat anything less than expected as a parsing failure.
    int matched = sscanf(line, "%31s %d %31s", name, &age, city);

    if (matched != 3) {
        fprintf(stderr, "Expected 3 fields, got %d -- invalid input.\n", matched);
        return 1;
    }

    printf("Name: %s, Age: %d, City: %s\n", name, age, city);
    return 0;
}

Note the width limits (%31s) even inside sscanf - the same overflow risk from section 2 applies here too, since the source buffer's content is attacker/user-controlled either way.


14. Detecting interactive vs. piped/redirected input

Real CLI tools often behave differently depending on whether stdin is an interactive terminal or a pipe/file - e.g. showing a prompt only when a human is actually typing. POSIX's isatty checks this.

#include <stdio.h>
#include <unistd.h>   // POSIX -- not part of standard C, but nearly universal on Unix-like systems

int main(void) {
    if (isatty(fileno(stdin))) {
        printf("Running interactively -- I'll show prompts.\n");
        printf("Enter something: ");
    } else {
        // stdin is a pipe or redirected file -- a prompt here would just
        // clutter piped output with no one there to read it
        fprintf(stderr, "Reading from a pipe/file, no interactive prompt.\n");
    }

    char line[64];
    if (fgets(line, sizeof(line), stdin) != NULL) {
        printf("Got: %s", line);
    }

    return 0;
}

This is why well-behaved CLI tools don't print prompts (or print them to stderr rather than stdout) when their input/output is redirected - ./tool < input.txt > output.txt shouldn't have prompt text polluting output.txt.


15. Buffering behavior differs between terminals and pipes

stdout is typically line-buffered when connected to an interactive terminal (flushed automatically at each newline) but fully buffered (flushed only when the buffer fills or the program exits) when redirected to a file or a pipe - a frequent source of "my output isn't showing up during a long-running redirected process" confusion.

#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("Starting a long process...\n");
    // When run as `./program`, this line appears immediately (line-buffered
    // terminal). When run as `./program > log.txt`, it may not actually
    // hit the file until much later, or until the program exits, because
    // stdout is now fully buffered instead.

    for (int i = 0; i < 3; i++) {
        printf("Step %d\n", i);
        fflush(stdout);   // force it out immediately regardless of buffering mode --
                           // important for progress output during redirected/piped runs
        sleep(1);
    }

    return 0;
}

Any program whose output is meant to be watched live (progress bars, streaming logs) while also supporting redirection needs to fflush explicitly rather than relying on the default buffering behavior.


16. Prompting correctly: flush before you read

If you printf a prompt and then immediately call a blocking read (like scanf or fgets), the prompt might not actually be visible yet if stdout's buffer hasn't been flushed - an explicit fflush(stdout) guarantees the user sees the prompt before the program waits for their input.

#include <stdio.h>

int main(void) {
    char name[32];

    printf("Enter your name: ");
    fflush(stdout);   // guarantees the prompt is visible before we block waiting for input

    fgets(name, sizeof(name), stdin);
    printf("Hello, %s", name);

    return 0;
}

In practice this often "just works" without the explicit fflush because terminals are commonly line-buffered - but as shown in section 15, that guarantee disappears the moment output is redirected, so real-world code includes the fflush rather than relying on the terminal's default behavior.


17. Sensitive input: disabling terminal echo for passwords

Reading a password with plain fgets leaves it visible on screen as it's typed. POSIX's termios API lets you temporarily disable terminal echo, the standard technique behind every command-line password prompt.

#include <stdio.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>

void readPassword(char *buffer, size_t size) {
    struct termios oldSettings, newSettings;

    tcgetattr(STDIN_FILENO, &oldSettings);     // save the current terminal settings
    newSettings = oldSettings;
    newSettings.c_lflag &= ~ECHO;              // turn off the ECHO flag specifically
    tcsetattr(STDIN_FILENO, TCSANOW, &newSettings);

    printf("Password: ");
    fflush(stdout);
    fgets(buffer, size, stdin);
    buffer[strcspn(buffer, "\n")] = '\0';

    tcsetattr(STDIN_FILENO, TCSANOW, &oldSettings);   // ALWAYS restore, even the echo setting
    printf("\n");
}

int main(void) {
    char password[64];
    readPassword(password, sizeof(password));
    printf("Password length: %zu\n", strlen(password));
    // In real code: never print or log the password itself.
    return 0;
}

This is POSIX-specific (Windows uses a different API, _getch from <conio.h>, for similar functionality) - cross-platform tools typically wrap this behind their own portable function.


18. Locale-aware input/output

setlocale affects how certain standard I/O functions interpret and format things like decimal points, digit grouping, and date formats - relevant for any real-world program that might run on a machine configured for a different region/language than the one it was developed in.

#include <stdio.h>
#include <locale.h>

int main(void) {
    printf("Default locale: %.2f\n", 1234.5);   // e.g. 1234.50

    setlocale(LC_NUMERIC, "");   // adopt the user's OS-configured locale settings

    // In some European locales, this would print using a comma as the
    // decimal separator instead of a period, if the underlying printf
    // implementation honors LC_NUMERIC (behavior is implementation-defined
    // for some format specifiers).
    printf("After setlocale: %.2f\n", 1234.5);

    return 0;
}

Most command-line tools intended for a single, known audience skip this entirely; it matters most for software distributed internationally.


19. Separating interactive output from machine-readable output

A well-behaved CLI tool keeps human-facing messages (progress, prompts, warnings) separate from its actual data output - typically by sending diagnostics to stderr and only the real output to stdout - so that piping the tool's output into another program doesn't also pipe in unrelated chatter.

#include <stdio.h>

int main(void) {
    fprintf(stderr, "Processing input...\n");   // diagnostic -- goes to stderr

    int result = 42;
    printf("%d\n", result);   // the actual DATA -- goes to stdout, and only this

    fprintf(stderr, "Done.\n");   // diagnostic -- goes to stderr

    return 0;
}

This is why ./tool | grep something works cleanly for real-world Unix tools - grep only ever sees the data that went to stdout, none of the progress/status noise that went to stderr.


20. Common pitfalls checklist


Quick reference

ConcernUnsafe / naive approachSafer real-world approachSection
Reading a linegets(buf)fgets(buf, sizeof(buf), stdin)3
Reading a word/stringscanf("%s", buf)scanf("%Ns", buf) or (better) fgets + parse2, 4
Parsing a numberatoi(str)strtol(str, &endPtr, 10) with error checks6
Formatting into a buffersprintf(buf, ...)snprintf(buf, sizeof(buf), ...)8
Printing user-controlled textprintf(userInput)printf("%s", userInput)9
Leftover newline after scanf(ignored)discard chars up to '\n'/EOF with getchar()5
Detecting no more inputassuming input always existscheck fgets/scanf return value, feof/ferror11
Trimming input(skipped)strip whitespace / trailing newline before use12
Structured parsingassuming fixed input shapecheck sscanf's matched-field count13
Prompt visibilityrelying on default bufferingfflush(stdout) before blocking reads16
Interactive vs. piped behaviorone-size-fits-all outputisatty(fileno(stdin)) (POSIX)14
Password inputplain fgets (echoes to screen)disable terminal echo via termios (POSIX)17
Diagnostics vs. dataeverything to stdoutdata to stdout, diagnostics to stderr19

Coverage note

This guide focuses specifically on the gap between "standard I/O that works in a classroom" and "standard I/O that survives real, untrusted, unpredictable input" - buffer safety, format string safety, numeric validation, EOF/empty-input handling, buffering behavior across terminals and pipes, and the POSIX-level techniques (isatty, disabling echo) that real CLI tools depend on. Combined with the file handling guide (which covers FILE *-based file I/O rather than stdin/stdout), these two guides cover the complete practical space of I/O in real-world C programs.

Previous:
Functions in C
Next:
Fundamentals of C