File Handling · intermediate · ~10 min
- Open files for reading, writing, or appending with `fopen` and the correct mode string - Check the `FILE *` return value, read `errno`, and report failures with `perror` - Understand how buffered (stdio) streams differ from raw operating-system file descriptors - Pair every `fopen` with an `fclose` on every exit path, and know why `fclose` itself can fail - Pick between text and binary modes and know when `+`, `b`, and `fflush` matter - Use the standard open/check/use/close pattern that underlies almost all C file work
A C program that only prints to the screen and reads from the keyboard cannot do much that lasts. The moment you want to remember something after the program exits — a saved game, a log line, a configuration value, the rows of a spreadsheet — you need files. fopen is the door to files in standard C.
In the prerequisite lesson printf and format specifiers you already used printf to send formatted text to the screen. The screen is actually just a special pre-opened stream called stdout. fopen lets you open your own streams to files on disk, and then you use the very same family of functions — fprintf is printf aimed at a file, fgets reads a line, fread/fwrite move blocks of bytes.
printf(...) -> stdout (screen) <- already open for you
fprintf(fp, ...) -> fp (your file) <- you open it with fopen
The call looks like this:
FILE *fp = fopen(path, mode);
path is the name (or full path) of the file. mode is a short string such as "r", "w", or "a" that says what you intend to do. On success you get back a FILE * — a pointer to an open stream. On failure you get NULL. A stream is a managed connection to the file: the C library hides operating-system details and adds a memory buffer so your program is fast.
The word stdio means "standard input/output" — the part of the C standard library declared in <stdio.h>. fopen, fclose, fprintf, fgets, fread, and fwrite are all part of it. Learning fopen well unlocks the whole family.
Files are how programs store data and share it — with other programs, with the operating system, and with people. Almost every real tool is built on fopen plus a handful of stdio functions:
Getting file handling right is also a robustness and reliability issue. A program that does not check whether fopen succeeded will crash with a segmentation fault the first time a file is missing or the disk is full. A program that forgets fclose slowly leaks resources until it can open nothing at all. The difference between a fragile script and a dependable tool often comes down to disciplined open/check/use/close handling — exactly what this lesson teaches.
FILE * handleA stream is the C library's abstraction for a flow of bytes to or from a file. When fopen succeeds it allocates a FILE structure (holding the buffer, current position, error flags, and the underlying OS handle) and returns a pointer to it. You never look inside that structure; you only pass the pointer to other stdio functions.
How it works internally: under the hood the stream wraps a lower-level operating-system file descriptor — a small integer the kernel uses to track an open file. stdio adds a memory buffer on top so most of your reads and writes never touch the disk directly.
your code ---> FILE * (stdio buffer ~4KB) ---> file descriptor (kernel) ---> disk
When to use: any time you read or write text or moderate-sized data. When NOT to: when you need exact, unbuffered control over every system call (rare; then you use the lower-level open/read/write).
Pitfall: the returned pointer can be NULL. Using a NULL FILE * is undefined behaviour and usually crashes.
Knowledge check: In your own words, what is the difference between a FILE * stream and a kernel file descriptor?
The mode string is the single most important argument because it decides whether existing data survives.
| Mode | Read? | Write? | If file exists | If file missing |
|---|---|---|---|---|
"r" |
yes | no | opens at start | fails (NULL) |
"w" |
no | yes | truncated to empty | created |
"a" |
no | yes | keeps data, writes at end | created |
"r+" |
yes | yes | opens at start, keeps data | fails |
"w+" |
yes | yes | truncated to empty | created |
"a+" |
yes | yes | reads anywhere, writes at end | created |
Add b (e.g. "rb", "wb") for binary mode.
Pitfall: opening with "w" when you meant "a" silently erases the whole file. Pause before using "w" on a file you care about.
Knowledge check: You want to add lines to an existing log file across many program runs without ever losing the earlier lines. Which mode do you pass to fopen, and why would "r+" be the wrong choice for that goal?
stdio does not push every byte to disk immediately. It collects bytes in a memory buffer (commonly around 4 KB) and writes them in batches, which is far faster than one system call per byte.
The buffer is flushed (actually written out) in these situations:
fclosefflush(fp) fprintf(fp, "a"); buffer: [a ] (nothing on disk yet)
fprintf(fp, "b"); buffer: [ab ] (still nothing on disk)
fclose(fp); buffer flushed ---> disk now contains "ab"
When to use fflush: when another program (or a human watching a log) must see your output now, before the file closes. When NOT to: flushing after every tiny write defeats buffering and is slow.
Pitfall: if your program crashes before fclose/fflush, buffered data is lost — the file on disk looks shorter than what you "wrote".
fopen returns NULL on failure and sets the global variable errno to a code describing why (no such file, permission denied, too many open files, ...). perror("fopen") prints your label followed by a readable message; strerror(errno) gives the same text as a string.
Pitfall: reading errno is only meaningful immediately after the failing call, because the next library call may overwrite it.
Knowledge check (find the bug): Why is this dangerous?
FILE *f = fopen("data.txt", "r");
char line[100];
fgets(line, sizeof line, f); /* f might be NULL */
#include <stdio.h>
FILE *fp = fopen("data.txt", "r"); /* open: path + mode */
if (fp == NULL) { /* ALWAYS check before use */
perror("fopen"); /* prints e.g. "fopen: No such file or directory" */
return 1;
}
char line[256];
/* fgets stops at a newline or when the buffer is full; it keeps the \n if it fit */
while (fgets(line, sizeof line, fp) != NULL) {
fputs(line, stdout); /* process the line */
}
if (fclose(fp) != 0) { /* fclose can fail (a final flush may error) */
perror("fclose");
}
The shape is always the same: open, check for NULL, use the stream, close. Note sizeof line works only because line is a real array here, not a pointer parameter.
The function signature is:
FILE *fopen(const char *path, const char *mode);
It returns a stream pointer, or NULL on failure.
The common modes are:
"r" — read text; error if the file is missing."w" — write text; truncates an existing file or creates a new one."a" — append text; creates the file if missing."b" for binary, e.g. "rb" or "wb".Always pair fopen with fclose. Check the return value of both — fopen can fail to open, and fclose can fail to flush.
#include <stdio.h>
#include <stdlib.h>
/* Append one message to a log file, then read the file back and number
every line. Demonstrates two modes ("a" and "r") and full error handling. */
static int append_log(const char *path, const char *msg) {
FILE *fp = fopen(path, "a"); /* append: create if missing, never truncate */
if (fp == NULL) {
perror("fopen (append)");
return -1;
}
fprintf(fp, "%s\n", msg); /* fprintf == printf aimed at a file */
if (fclose(fp) != 0) { /* closing flushes the buffer; report failure */
perror("fclose (append)");
return -1;
}
return 0;
}
static int print_numbered(const char *path) {
FILE *fp = fopen(path, "r"); /* read: fails if the file does not exist */
if (fp == NULL) {
perror("fopen (read)");
return -1;
}
char line[256];
int n = 0;
while (fgets(line, sizeof line, fp) != NULL) {
printf("%d: %s", ++n, line); /* line already ends in \n if it fit */
}
if (ferror(fp)) { /* distinguish a read error from end-of-file */
perror("fgets");
fclose(fp);
return -1;
}
fclose(fp);
return 0;
}
int main(void) {
const char *path = "app.log";
if (append_log(path, "server started") != 0) return EXIT_FAILURE;
if (append_log(path, "request handled") != 0) return EXIT_FAILURE;
if (print_numbered(path) != 0) return EXIT_FAILURE;
return EXIT_SUCCESS;
}
What it does: append_log opens app.log in append mode and adds one line; because the mode is "a", running the program twice keeps the earlier lines instead of erasing them. print_numbered reopens the file in read mode and prints each line with a line number.
Expected output (first run; a second run would show four lines):
1: server started
2: request handled
Edge cases: if the directory is not writable, the first fopen returns NULL and you see a Permission denied message. A line longer than 255 characters is split across several fgets calls (and would be numbered as several lines) — that is a known limit of fixed-size line buffers.
Walking through main and the helpers in execution order:
main sets path = "app.log" and calls append_log(path, "server started").append_log, fopen(path, "a") asks the OS for the file. The file does not exist yet, so append mode creates it and returns a non-NULL FILE *.if (fp == NULL) check passes (pointer is valid), so we skip the error branch.fprintf(fp, "%s\n", msg) formats server started\n into the stream's memory buffer. Nothing is on disk yet.fclose(fp) flushes the buffer to disk (now the file contains one line) and releases the handle. It returns 0, so we return 0.main calls append_log again with "request handled". This time fopen(path, "a") finds the existing file and positions writing at the end, so the new line is added after the first.main calls print_numbered(path). fopen(path, "r") succeeds because the file now exists.while loop calls fgets(line, 256, fp).| Iteration | fgets returns |
line contents |
n after ++n |
printed |
|---|---|---|---|---|
| 1 | non-NULL | server started\n |
1 | 1: server started |
| 2 | non-NULL | request handled\n |
2 | 2: request handled |
| 3 | NULL (EOF) |
unchanged | — | loop ends |
ferror(fp) is false (we hit end-of-file, not an error), so we skip that branch, fclose(fp), and return 0.0, so main returns EXIT_SUCCESS.The key idea: the same file is opened twice with two different modes, and the buffer is what stands between your fprintf calls and the bytes that actually land on disk.
1. Using the stream without checking for NULL.
FILE *fp = fopen("data.txt", "r");
fgets(line, sizeof line, fp); /* WRONG: fp may be NULL -> crash */
Why it is wrong: if the file is missing, fp is NULL and passing it to fgets is undefined behaviour (usually a segfault). Corrected:
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) { perror("fopen"); return 1; }
fgets(line, sizeof line, fp);
How to prevent it: treat the NULL check as part of the fopen call — write them together, every time.
2. Using "w" when you meant "a".
FILE *fp = fopen("important.log", "w"); /* WRONG if you wanted to keep old lines */
Why it is wrong: "w" truncates the file to zero length the instant it opens — the old contents are gone before you write anything. Corrected: use "a" to add to the end. Recognize it when a file you expected to grow keeps resetting to just the latest write.
3. Forgetting fclose (especially on error paths).
FILE *fp = fopen(path, "w");
if (something_failed) return -1; /* WRONG: leaks the open file */
Why it is wrong: the handle leaks, and buffered data may never reach disk. Corrected: close before every return, or use a single cleanup label:
FILE *fp = fopen(path, "w");
if (fp == NULL) return -1;
if (something_failed) goto done;
/* ... */
done:
fclose(fp);
4. Ignoring the result of fclose. A write can fail at flush time (disk full). if (fclose(fp) != 0) perror("fclose"); is how you notice that data did not make it.
5. Forgetting b for binary data on Windows. In text mode Windows translates \n to/from \r\n, which corrupts binary files. Use "rb"/"wb" for non-text data.
Compiler issues
implicit declaration of function 'fopen' or 'FILE' undeclared: you forgot #include <stdio.h>.warning: passing argument ... discards 'const': your path parameter should be const char *.Runtime / fopen returns NULL
if (!fp) { perror("fopen"); ... }. The message tells you the cause:No such file or directory — the path is wrong, or you used "r" on a file that does not exist. Print the path you actually passed.Permission denied — the file or directory permissions block the mode you asked for.Too many open files — you are leaking handles; find the missing fclose.Logic errors
fcloses (or fflushes) first.while (!feof(fp)) to control reading. Prefer while (fgets(...) != NULL) — feof only becomes true after a read past the end, so it reads one time too many.fopen(dir, "r") works but the later read fails. Check ferror after reads.Questions to ask when it does not work: Did I check fp for NULL? What exact path did fopen receive? What is the current working directory? Did I use the mode I intended? Did the writer close the file before the reader opened it?
This lesson is about resource safety as much as memory safety; both cause real bugs.
fopen must be matched by exactly one fclose, including on every error/return path. A file descriptor is the OS's handle for an open file; leaking them eventually makes fopen fail with Too many open files. The goto cleanup pattern gives each exit one place to close the file.FILE * without checking for NULL is undefined behaviour. Check immediately after fopen.FILE * after fclose; the pointer is now dangling. Setting it to NULL after closing helps catch accidental reuse.fgets with the real buffer size (fgets(buf, sizeof buf, fp)) so it cannot overflow buf. Beware that sizeof gives the pointer size, not the array size, if buf was passed into a function as a parameter — pass the size explicitly in that case.fgets/fread reads nothing, the buffer keeps its old (possibly uninitialized) contents. Check the return value before using what you think you read.fflush (and, for stronger guarantees, the OS-level sync calls covered later) matter.Concrete uses: server log files (append mode), application config readers, CSV/TSV importers and exporters, save-game and document loaders, build tools that read source files, and download utilities that stream a file to disk a block at a time. The Unix tools cat, grep, and wc (mirrored in this lesson's related exercises) are all thin wrappers around exactly this stdio pattern.
Professional best-practice habits
Beginner rules
fopen for NULL and report with perror."w" on data you might need.fgets(buf, sizeof buf, fp) so reads stay inside the buffer.Advanced habits
goto done label when a function has several exits.fclose and use ferror/feof to distinguish a real read error from a normal end-of-file."b") for any non-text data and for portable byte-exact I/O.setvbuf, and fflush at the points where another reader must see your output.Beginner 1 — File exists?
Objective: write int can_read(const char *path) that returns 1 if path can be opened for reading and 0 otherwise.
Requirements: open with "r", check for NULL, and fclose if it opened. Do not print anything.
Example: can_read("app.log") returns 1 after you have created that file; can_read("missing.txt") returns 0.
Hints: the answer is just whether fopen returned non-NULL. Concepts: modes, NULL check, fclose.
Beginner 2 — Count the lines.
Objective: print how many lines a file has.
Requirements: open in "r", loop with fgets, count iterations, handle a missing file with perror, and close at the end.
Example input file with three lines -> output 3.
Constraint: assume no line exceeds 1023 characters. Hint: while (fgets(buf, sizeof buf, fp)) ... Concepts: fgets loop, buffer size.
Intermediate 1 — Append a timestamped note.
Objective: a program that takes a message on the command line and appends it as one line to notes.txt.
Requirements: use mode "a", fprintf the message followed by \n, check both fopen and fclose, and exit with a non-zero status on any failure.
Constraint: running it twice must keep both lines. Hint: argv[1] is the message. Concepts: append mode, error handling on both calls.
Intermediate 2 — Safe file copy.
Objective: copy src to dst using two streams.
Requirements: open src with "rb" and dst with "wb"; copy in a loop with fread/fwrite using a fixed buffer; close both files on every exit path (including when only one opened); report errors with perror.
Example: copying a 10 KB image produces a byte-identical file. Hint: a goto cleanup label keeps the close logic in one place. Concepts: binary mode, two open streams, cleanup discipline.
Challenge — Tiny tail.
Objective: print the last N lines of a file (default N = 10).
Requirements: read the whole file once, remember line positions or buffer recent lines in a fixed-size ring of N strings, then print them in order. Handle files with fewer than N lines, a missing file, and a file that does not end in a newline. Validate that N is positive.
Constraint: do not load the whole file into one giant allocation if it is very large — keep only N lines.
Hint: a circular buffer of N char arrays, overwriting the oldest as you go, avoids storing the entire file. Concepts: fgets loop, bounded buffering, careful end-of-file handling.
fopen(path, mode) returns a FILE * stream, or NULL on failure — always check for NULL and report with perror."r" read (must exist), "w" write (truncates), "a" append (keeps and adds), + adds the other direction, b is binary.fflush, or you fclose. A crash before that loses buffered data.fopen with an fclose on every path; a goto cleanup label makes this reliable. Check fclose's result too — a flush can fail.fread, fwrite, fgets, fprintf) is just variations on the same idea.