C stdio.h Library

The stdio.h header is the standard input/output library in C — it is the toolbox that lets your program talk to the outside world: printing text to the screen, reading what the user types, and reading or writing files on disk. Almost every C program you will ever write includes <stdio.h>, which is why understanding it deeply, not just superficially, pays off immediately.

Overview / How stdio.h Works

stdio.h is not a set of operating-system calls itself; it is a portable wrapper around them. Underneath, your operating system exposes low-level primitives such as read() and write() that operate on integer file descriptors. The C standard library builds a higher-level abstraction on top of these called a stream, represented in your code by a pointer to a FILE structure (FILE *). A FILE object bundles together a file descriptor, an internal memory buffer, the current read/write position, and status flags (end-of-file, error).

Three streams are opened automatically for every C program:

  • stdin — standard input, normally the keyboard.
  • stdout — standard output, normally the terminal.
  • stderr — standard error, also normally the terminal, but a separate stream reserved for error messages.

Buffering is the key performance idea behind stdio. Writing to disk or to a terminal one byte at a time via a system call would be extremely slow, because every system call involves a costly transition into the operating system kernel. Instead, stdio functions write into an in-memory buffer first, and only “flush” that buffer to the real device when it fills up, when you explicitly call fflush(), when the program exits normally, or (for line-buffered streams like the terminal) when a newline character is written. There are three buffering modes: fully buffered (typical for files — flushed only when full or closed), line buffered (typical for interactive terminals — flushed on each newline), and unbuffered (typical for stderr — flushed immediately, so error messages appear right away even if the program crashes next).

This is why mixing printf output with direct writes, or forgetting to flush before a crash, can make output appear “missing” or out of order — it was written into a buffer that never made it to the screen or disk.

Syntax

Most stdio functions share a common shape: a format string containing conversion specifiers, plus a matching list of arguments.

int printf(const char *format, ...);
int scanf(const char *format, ...);
FILE *fopen(const char *filename, const char *mode);
int fclose(FILE *stream);
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
char *fgets(char *str, int n, FILE *stream);
Function Purpose
printf / fprintf Write formatted text to stdout / to any stream
scanf / fscanf Read formatted text from stdin / from any stream
fopen / fclose Open / close a file, returning a FILE * handle
fgets / fputs Read / write a line of text safely (bounded by size)
fread / fwrite Read / write raw binary blocks of memory
fseek / ftell / rewind Move / query the current position in a file
feof / ferror Check whether the last operation hit end-of-file or an error

The most important format specifiers used inside printf/scanf-family functions:

Specifier Meaning
%d signed decimal int
%u unsigned decimal int
%f double in fixed-point notation
%c a single character
%s a null-terminated string
%x / %X unsigned integer in lowercase/uppercase hexadecimal
%p a pointer value
%% a literal percent sign
%ld, %lu, %lld long, unsigned long, long long variants
%zu size_t (the type returned by fread/fwrite)

Fields also support width and precision, such as %5d (pad to at least 5 characters) or %.2f (two digits after the decimal point).

Examples

Example 1: Formatted console output

#include <stdio.h>

int main(void) {
    int age = 30;
    double pi = 3.14159;
    char grade = 'A';
    char name[] = "Ada";

    printf("Name: %s\n", name);
    printf("Age: %d\n", age);
    printf("Pi (2 decimals): %.2f\n", pi);
    printf("Grade: %c\n", grade);
    printf("Age in hex: %x\n", age);
    printf("Padded: [%5d]\n", age);

    return 0;
}
Output:
Name: Ada
Age: 30
Pi (2 decimals): 3.14
Grade: A
Age in hex: 1e
Padded: [   30]

Each % specifier consumes one argument, in order, and formats it according to its rules. %.2f rounds to two decimal places, %x converts 30 to its hexadecimal form 1e, and %5d right-pads the number with spaces so the whole field is at least 5 characters wide.

Example 2: Writing and reading a text file

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("notes.txt", "w");
    if (fp == NULL) {
        fprintf(stderr, "Error: could not open file for writing\n");
        return 1;
    }

    fprintf(fp, "Line 1: Learning C\n");
    fprintf(fp, "Line 2: stdio.h basics\n");
    fclose(fp);

    fp = fopen("notes.txt", "r");
    if (fp == NULL) {
        fprintf(stderr, "Error: could not open file for reading\n");
        return 1;
    }

    char buffer[100];
    while (fgets(buffer, sizeof(buffer), fp) != NULL) {
        printf("Read: %s", buffer);
    }

    fclose(fp);
    remove("notes.txt");

    return 0;
}
Output:
Read: Line 1: Learning C
Read: Line 2: stdio.h basics

This program opens notes.txt in write mode ("w", which creates the file or truncates it if it already exists), writes two lines with fprintf, then closes it so the buffered data is flushed to disk. It then reopens the file in read mode ("r") and uses fgets in a loop to read one line at a time until it returns NULL at end-of-file. Notice that fgets keeps the trailing newline character, so printf does not need to add its own. Finally remove() deletes the temporary file.

Example 3: Binary I/O with fwrite, fread, and fseek

#include <stdio.h>

int main(void) {
    int numbers[5] = {10, 20, 30, 40, 50};
    FILE *fp = fopen("data.bin", "wb");
    if (fp == NULL) {
        fprintf(stderr, "Error opening file for writing\n");
        return 1;
    }
    fwrite(numbers, sizeof(int), 5, fp);
    fclose(fp);

    fp = fopen("data.bin", "rb");
    if (fp == NULL) {
        fprintf(stderr, "Error opening file for reading\n");
        return 1;
    }

    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);
    rewind(fp);
    printf("File size: %ld bytes\n", size);

    int loaded[5];
    size_t count = fread(loaded, sizeof(int), 5, fp);
    printf("Read %zu integers:\n", count);
    for (size_t i = 0; i < count; i++) {
        printf("%d ", loaded[i]);
    }
    printf("\n");

    fclose(fp);
    remove("data.bin");

    return 0;
}
Output:
File size: 20 bytes
Read 5 integers:
10 20 30 40 50

Binary mode ("wb"/"rb") writes and reads the raw bytes of memory rather than formatted text, which is faster and preserves exact values, but is not portable across machines with different byte orders or int sizes. fseek(fp, 0, SEEK_END) jumps to the end of the file so ftell can report the current byte offset (the file size), and rewind resets back to the beginning before reading. Since each int is 4 bytes and there are 5 of them, the file is 20 bytes long.

How stdio Works Step by Step (Under the Hood)

Understanding the sequence of events helps explain the gotchas later in this lesson:

  • fopen asks the operating system to open the underlying file descriptor and allocates a FILE structure with its own internal buffer (typically a few kilobytes, chosen automatically or set with setvbuf).
  • printf/fprintf format their arguments into text and append that text to the stream's buffer — no system call happens yet.
  • Flushing happens automatically when the buffer is full, when the stream is line buffered and a \n is written, when you call fflush(), or when the stream is closed. Flushing is what actually triggers a write() system call.
  • fgets/fread pull a chunk of bytes from the OS into the buffer (if it is empty) and then hand back only what you asked for, remembering the rest for next time — this is why reading from a file is fast even though system calls are comparatively slow.
  • fclose flushes any pending output, releases the internal buffer, and asks the OS to close the file descriptor. Skipping this step risks losing buffered writes if the program terminates abnormally.

Common Mistakes

Mistake 1: Not checking the return value of fopen

fopen returns NULL if the file cannot be opened (wrong path, missing permissions, disk full, and so on). Using the result without checking leads to undefined behavior.

FILE *fp = fopen("missing_file.txt", "r");
char buffer[50];
fgets(buffer, sizeof(buffer), fp);
printf("%s", buffer);

Because missing_file.txt does not exist, fp is NULL, and passing it to fgets is undefined behavior — most implementations will crash. Always check the pointer first:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("missing_file.txt", "r");
    if (fp == NULL) {
        printf("Error: could not open the file.\n");
    } else {
        char buffer[50];
        fgets(buffer, sizeof(buffer), fp);
        printf("%s", buffer);
        fclose(fp);
    }
    return 0;
}
Output:
Error: could not open the file.

Mistake 2: Forgetting that fgets keeps the newline character

Unlike some other languages' line-reading functions, fgets includes the trailing \n in the string it returns (unless the line was truncated because it was longer than the buffer). Comparing that string directly against a literal without the newline silently fails.

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

int main(void) {
    FILE *fp = fopen("color.txt", "w");
    fprintf(fp, "blue\n");
    fclose(fp);

    fp = fopen("color.txt", "r");
    char color[20];
    fgets(color, sizeof(color), fp);
    fclose(fp);

    if (strcmp(color, "blue") == 0) {
        printf("Match!\n");
    } else {
        printf("No match. Got: \"%s\"\n", color);
    }

    remove("color.txt");
    return 0;
}
Output:
No match. Got: "blue
"

The string stored in color is actually "blue\n", not "blue", so strcmp reports a mismatch. The fix is to strip the newline with strcspn before comparing:

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

int main(void) {
    FILE *fp = fopen("color.txt", "w");
    fprintf(fp, "blue\n");
    fclose(fp);

    fp = fopen("color.txt", "r");
    char color[20];
    fgets(color, sizeof(color), fp);
    color[strcspn(color, "\n")] = '\0';
    fclose(fp);

    if (strcmp(color, "blue") == 0) {
        printf("Match!\n");
    } else {
        printf("No match. Got: \"%s\"\n", color);
    }

    remove("color.txt");
    return 0;
}
Output:
Match!

Best Practices

  • Always check the return value of fopen (and of fread/fwrite/fgets) before using the result — never assume I/O succeeds.
  • Prefer fgets over the removed, unsafe gets function; fgets takes a buffer size and cannot overflow it.
  • Use snprintf instead of sprintf when building strings, so you cannot overflow the destination buffer.
  • Match your scanf/printf conversion specifiers exactly to the argument's type — a mismatch (e.g. %d for a double) is undefined behavior, not just a warning.
  • Always pass the address of a variable to scanf (e.g. &age); forgetting the & is one of the most common beginner bugs.
  • Close every file you open with fclose, ideally as soon as you are done with it, to flush buffered writes and free the OS file descriptor.
  • Use binary mode ("rb"/"wb") for non-text data, and text mode for human-readable content.
  • Write error messages to stderr, not stdout, so they are not silently mixed into redirected program output.

Practice Exercises

  • Exercise 1: Write a program that creates a text file containing five lines of your choice, then reads it back and prints how many lines and how many total characters it contains.
  • Exercise 2: Write a program that uses fprintf to save three "name,age" records to a file (one per line), then use fscanf to read them back and print each name with its age.
  • Exercise 3: Write a program that stores an array of 10 double values to a binary file with fwrite, then reads them back with fread and prints their sum. (Hint: use ftell after opening the file to confirm the byte count matches 10 * sizeof(double).)

Summary

  • stdio.h provides formatted and unformatted I/O through FILE * streams, including the automatic stdin, stdout, and stderr.
  • Output is buffered for performance; buffers are flushed on newline (for terminals), when full, on fflush(), or on fclose().
  • fopen/fclose manage file handles; always check fopen's return value for NULL.
  • fgets/fputs handle text lines safely; fread/fwrite handle raw binary data.
  • fseek, ftell, and rewind let you move around within a file rather than reading it strictly start to end.
  • Common bugs include unchecked fopen results, mismatched format specifiers, forgetting & in scanf, and forgetting that fgets retains the newline character.