08 · File I/O¶
🎥 Video walkthrough¶
Programs that forget everything the moment they exit aren't very useful.
C's standard library gives you a small, consistent set of functions —
fopen, fprintf, fscanf, fgets, fclose — for reading and writing
files, all built around a single type: FILE *.
FILE * and fopen¶
FILE * is a pointer to a structure the standard library uses internally to
track an open file (its position, buffering, etc.). You never look inside it
yourself — you just pass it to the I/O functions. fopen opens a file and
gives you back that pointer, or NULL if it failed:
#include <stdio.h>
int main(void) {
FILE *file = fopen("greeting.txt", "w"); // "w" = write mode
if (file == NULL) {
printf("Could not open file for writing.\n");
return 1;
}
fprintf(file, "Hello, file!\n");
fclose(file);
printf("Wrote greeting.txt\n");
return 0;
}
// Output:
// Wrote greeting.txt
Always check the return value of fopen before using it. A missing file,
a bad path, or a permissions problem all make fopen return NULL — and
dereferencing that NULL (by trying to read or write through it) crashes the
program, exactly like the NULL pointer risk from
Module 6.
Modes¶
The second argument to fopen says how you intend to use the file:
| Mode | Meaning | If file doesn't exist | If file exists |
|---|---|---|---|
"r" |
Read (text) | fopen returns NULL |
Read from the start |
"w" |
Write (text) | Created | Overwritten / truncated |
"a" |
Append (text) | Created | Writes are added at the end |
"r+" |
Read and write | fopen returns NULL |
Read/write, starts at beginning |
"w+" |
Read and write | Created | Overwritten / truncated |
"a+" |
Read and append | Created | Reads from start, writes append at end |
"w" and "w+" silently erase any existing content the moment fopen
succeeds — reach for "a" instead if you want to keep what's already there.
Writing with fprintf¶
fprintf works exactly like printf, except the first argument says where
to send the output — a FILE * instead of the screen:
#include <stdio.h>
int main(void) {
FILE *file = fopen("numbers.txt", "w");
if (file == NULL) {
printf("Error opening file.\n");
return 1;
}
for (int i = 1; i <= 3; i++) {
fprintf(file, "Line %d: value = %d\n", i, i * i);
}
fclose(file);
printf("Wrote 3 lines to numbers.txt\n");
return 0;
}
// Output:
// Wrote 3 lines to numbers.txt
Reading lines with fgets¶
For reading text a line at a time, fgets is safer than fscanf because you
give it a buffer size, so it can't overflow your array:
#include <stdio.h>
int main(void) {
FILE *file = fopen("numbers.txt", "r");
if (file == NULL) {
printf("Error opening file.\n");
return 1;
}
char line[100];
while (fgets(line, sizeof(line), file) != NULL) {
printf("Read: %s", line); // line already includes its own '\n'
}
fclose(file);
return 0;
}
// Output:
// Read: Line 1: value = 1
// Read: Line 2: value = 4
// Read: Line 3: value = 9
fgets returns NULL once it reaches the end of the file, which is exactly
what makes it a natural loop condition — no extra "did I hit the end?" check
needed.
fscanf for structured text¶
When a file's format is predictable (numbers separated by whitespace, for
example), fscanf parses values directly into variables, the same way
scanf reads from the keyboard:
#include <stdio.h>
int main(void) {
FILE *file = fopen("scores.txt", "w");
fprintf(file, "85 92 78\n");
fclose(file);
file = fopen("scores.txt", "r");
if (file == NULL) {
printf("Error opening file.\n");
return 1;
}
int a, b, c;
fscanf(file, "%d %d %d", &a, &b, &c);
printf("Scores: %d, %d, %d\n", a, b, c);
fclose(file);
return 0;
}
// Output:
// Scores: 85, 92, 78
Why fclose matters¶
Closing a file with fclose flushes any buffered output to disk and releases
the operating system's file handle. Forgetting to close a file you wrote to
can mean the data never actually makes it to disk (it's stuck in a buffer),
and forgetting to close many files can exhaust the limited number of file
handles a program is allowed to have open at once. Every fopen should be
matched with an fclose.
Putting it together: write then read back¶
#include <stdio.h>
int main(void) {
// 1. Write a few lines
FILE *out = fopen("log.txt", "w");
if (out == NULL) {
printf("Could not open log.txt for writing.\n");
return 1;
}
fprintf(out, "Startup complete\n");
fprintf(out, "Loaded 3 modules\n");
fprintf(out, "Ready\n");
fclose(out);
// 2. Read it back and print each line
FILE *in = fopen("log.txt", "r");
if (in == NULL) {
printf("Could not open log.txt for reading.\n");
return 1;
}
char line[100];
printf("--- log.txt contents ---\n");
while (fgets(line, sizeof(line), in) != NULL) {
printf("%s", line);
}
fclose(in);
return 0;
}
// Output:
// --- log.txt contents ---
// Startup complete
// Loaded 3 modules
// Ready
A preview of binary I/O¶
Everything above is text I/O — human-readable, but not the most compact
or efficient way to store structured data like a whole struct at once. C
also has fread and fwrite for binary I/O, which copy raw bytes
directly (an entire struct Point, for example, in one call) instead of
formatting them as text. Binary I/O is covered in depth in
Level 2, Module 5, once you've seen more of
what structs (from Module 7) can hold.
How It Actually Works¶
fopen is a thin wrapper around your operating system's own file-opening
mechanism — on Linux/macOS, ultimately the open() system call, which asks
the kernel to locate the file on disk, check permissions, and hand back a
small integer called a file descriptor that the kernel uses internally
to track the open file (its current read/write position, buffering state,
and so on). The FILE * you get back from fopen is a heap-allocated
structure — defined by the C standard library, not the kernel — that wraps
that file descriptor together with a user-space buffer, which is why
FILE * is opaque: you're not meant to know or rely on its internal
layout, only pass it to stdio.h functions.
That user-space buffer is the whole reason fclose matters. fprintf
doesn't write to disk immediately — for efficiency, it first appends bytes
into an in-memory buffer inside the FILE structure (typically several
kilobytes), and only flushes that buffer to the kernel (via a write()
system call) when it fills up, when you call fflush, or when you call
fclose. If the program crashes or exits abnormally before the buffer is
flushed, whatever was sitting in that buffer never reaches the disk — the
data existed only in the process's own memory, not yet handed off to the
kernel's page cache. fclose both flushes this buffer and releases the
file descriptor back to the OS, which matters because a process is only
allowed a limited number of open file descriptors at once (commonly 1024)
— leaking them by skipping fclose in a long-running program eventually
makes every further fopen fail.
The different modes ("r", "w", "a") map to flags passed straight
through to the kernel's open() call — "w" corresponds to
O_WRONLY | O_CREAT | O_TRUNC, where O_TRUNC is what causes the kernel
to immediately discard the file's existing contents and reset its size to
zero the moment the file is opened, before you've written a single byte —
which is why the truncation happens even if your program crashes right
after fopen and never calls fprintf at all.
fgets reading in a loop until NULL works because internally each call
asks the buffered layer for the next chunk up to (or including) a newline;
when the underlying read() system call returns zero bytes (end of file),
fgets has nothing left to return and reports that with NULL — there's
no separate "are we at EOF" flag you have to poll, the return value itself
carries that information because the kernel told the library the file is
exhausted.
🔀 See this in another language¶
Exercise¶
Write a program that asks the user (with scanf) for 5 integers, one at a
time, and writes each one to a file called input.txt, one per line. Then,
in the same program, reopen input.txt for reading, read all 5 numbers back
with fscanf (or fgets + parsing), compute their sum and average, and
print both. Make sure to check every fopen call for NULL before using it.