Learn › Learn C › C in Practice
fopen - a hands-on Linux lab on a real virtual machine.
Open, read, and close files from C with fopen, fgets, and fclose, and never trust an open you did not check. When fopen returns NULL, read errno through strerror, report to stderr, and exit with a meaningful code: 0 for success, 2 for a missing file. Results ride stdout, problems ride stderr, and the exit code is a contract other tools branch on.
A small C program reads yesterday's log file and prints a one-line summary. It has run every night for months without a complaint. Then one night the log rotation job finishes late. The file the program expects is not there yet, and instead of a summary, the scheduler captures this:
Segmentation fault
The shell reports exit code 139. No summary, no hint about which file was missing, no clue why. Most engineers meet this exact failure early: a program that reads files perfectly, until the night a file is not there.
The flaw is one missing check. The program asked the operating system to open a file, never looked at the answer, and then tried to read through a handle it never received. This lesson teaches you to open files the right way: read them when they exist, and fail loudly, clearly, and with a meaningful exit code when they do not.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every program output shown was captured from a real Debian 12 machine with gcc 12.2.0, the same image your labs run on. Type the commands yourself; the typing is where the learning sticks.
File I/O means file input and output: a program reading bytes from a file, or writing bytes into one. Every language does it. C makes you watch every step, which is exactly why it teaches you the most.
In C you do not touch a file directly. You ask the C library to open it, and the library hands back a value of type FILE *: a pointer to the library's own bookkeeping for that open file. Remember pointers from the last module: a pointer is an address, a value that says where something lives. You never look inside a FILE * yourself. You only carry it, and hand it back.
Think of a coat check at a theater. fopen is handing over your coat: you get a ticket back. fgets uses the ticket to fetch something. fclose returns the ticket when you are done. And if the coat room refuses you, there is no ticket at all: fopen hands back NULL, the pointer to nothing, address zero.
Three functions carry this whole lesson:
fopen(name, mode) opens the file named name. Mode "r" means read.fgets(buffer, size, f) reads one line from f into buffer, at most size minus one characters.fclose(f) closes the file and releases the handle.Almost every real program starts its life by reading a file. Services read configuration files at startup. Log analyzers read logs. Data tools read CSV files, plain-text tables with one record per line. If your program cannot read a file, it can only work on what you type at it, and real work does not arrive by typing.
Two habits separate production C from toy C here. First: check every open, because files go missing, names get typos, and permissions change. Second: exit with a code that says what happened. In Foundations you read $? after a command to see its exit code. Now you are on the other side of that contract. Your program sets the code, and scripts, schedulers, and other engineers will branch on it with && and ||. Code 0 means success. Anything else means a specific failure that you chose and documented.
fgets reads text one line at a time, which fits logs and configuration files perfectly. The same ticket also works with two siblings you will meet later: fread and fwrite, which move raw bytes in bulk for binary files such as images. And the library quietly buffers everything behind the ticket: it fetches a large chunk from the disk in one trip, then hands your program one line at a time from memory. That is why calling fgets thousands of times in a loop stays fast.
Later in this module, the strace lesson will show you the actual system calls hiding under fopen and fgets, including that buffering caught in the act. For now we stay at the C library level, where almost all real file code is written.
Start with the happy path. First give the program something to read: a file with three short lines. The printf command with \n escapes writes them; you used this form throughout Foundations.
prompt: operator@camp:~$ answer: printf 'a\nb\nc\n' > three.txt output: hint: Use printf with newline escapes and redirect: printf 'a\nb\nc\n' > three.txt
Silence means success, as usual with redirection. Now the program. It opens the file named on the command line, counts its lines, and prints the count. It is saved as count.c in your practice environment:
#include <stdio.h>
int main(int argc, char *argv[]) { FILE *f = fopen(argv[1], "r"); char line[256]; int count = 0;
while (fgets(line, sizeof line, f) != NULL) { count = count + 1; } fclose(f); printf("%d lines\n", count); return 0; }
Walk the highlighted lines. fopen(argv[1], "r") asks for read access to whatever filename you passed as the first argument; the ticket lands in f. fgets(line, sizeof line, f) reads one line into the line buffer each time it is called, and returns NULL when the file has no lines left. So the while loop runs once per line, adding one to count each time. fclose(f) returns the ticket. Remember sizeof from the types lesson: it measures the buffer, 256 bytes here, so fgets knows where it must stop.
>>> The loop never prints what it reads. Each successful fgets just adds one to count. After three lines, fgets returns NULL, the loop ends, and the single printf reports the total: 3 lines.
prompt: operator@camp:~$ answer: gcc -Wall count.c -o count && ./count three.txt output: 3 lines hint: Compile then run in one line: gcc -Wall count.c -o count && ./count three.txt
fgets handed back a line three times, then returned NULL at end of file, and the loop stopped with count at 3. The && between compile and run is the Foundations habit doing real work: the program only runs if the compile exited 0. But look at what count.c never does. It never checks whether fopen actually succeeded. Hand it a filename that does not exist, and it will try to read through a ticket it never got. That is the 2 a.m. crash from the cold open, and it is the next thing we fix.
When fopen cannot open the file, it does two things. It returns NULL, and it sets errno. errno is a global error number the C library maintains: every time a library call fails, it stores a number there that says why. The number is for machines. strerror(errno) translates it into text for humans, like No such file or directory. To use them you include <errno.h> and <string.h>.
Here is the production version, saved as fio.c. Same reading loop, plus the checks count.c was missing:
#include <stdio.h> #include <string.h> #include <errno.h>
int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "usage: %s <file>\n", argv[0]); return 1; }
FILE *f = fopen(argv[1], "r"); if (f == NULL) { fprintf(stderr, "%s: %s\n", argv[1], strerror(errno)); return 2; }
char line[256]; int count = 0; while (fgets(line, sizeof line, f) != NULL) { count = count + 1; } fclose(f);
printf("%d lines\n", count); return 0; }
The highlighted block is the heart of this lesson. if (f == NULL) catches the failed open before any read happens. fprintf(stderr, ...) prints the filename and the translated reason; fprintf is printf with an explicit destination, and stderr is that destination, which the next steps explain fully. And return 2 ends the program with exit code 2, this program's documented meaning for could not open the input. The guard at the top matters too: run it with no filename and it explains itself and exits 1, instead of reading an argv[1] that does not exist.
>>> The NULL check catches the failure before the first read. strerror(errno) supplies the words No such file or directory, and return 2 sets the exit code. No crash, no lie, a clear report.
prompt: operator@camp:~$ answer: gcc -Wall fio.c -o fio && ./fio missing.txt; echo "exit: $?" output: missing.txt: No such file or directory exit: 2 hint: Compile and run, then read the code: gcc -Wall fio.c -o fio && ./fio missing.txt; echo "exit: $?"
fopen failed, stored the error number for a missing path in errno, and returned NULL. The check caught it, strerror turned the number into words, and return 2 became the exit code that $? handed back. One failed open, fully explained, in two lines of output. Remember from Foundations: the semicolon before echo just runs it next, no matter how the program exited, which is exactly what you want when you are reading exit codes.
Put the two programs side by side and the only difference that matters is four lines. This fragment is count.c, the naive one, at the moment of the flaw:
FILE *f = fopen(argv[1], "r"); /* nothing here checks f */ while (fgets(line, sizeof line, f) != NULL) {
You watched fio.c answer a missing file politely. Now predict what this one does with the same missing file. One warning first: count.c compiled clean under -Wall. gcc cannot warn about a check that is simply not there.
>>> 0 lines would need fgets to reach a graceful end of file, but there is no file: fopen returned NULL, and the loop hands that NULL straight to fgets. The polite message belongs to fio.c; count.c has no check, no strerror, and no return 2, so it has no way to print that line. What actually happens is the cold open: fgets tries to read the bookkeeping behind ticket 0x0, the operating system steps in, and the program dies with Segmentation fault and exit code 139.
prompt: operator@camp:~$ answer: ./count missing.txt; echo "exit: $?" output: Segmentation fault exit: 139 hint: Same shape as before: ./count missing.txt; echo "exit: $?"
Exit code 139 and not one word of help. fopen did report the failure, the only way it can, by handing back NULL; count.c never looked, and the very first fgets walked through the unchecked ticket into memory the program does not own. Same missing file both runs: fio.c said missing.txt: No such file or directory and exited 2, count.c said Segmentation fault and exited 139. The four-line check is the entire difference.
Here is main's stack frame at the fatal instant, the moment before the crash:
{ "kind": "stack", "frames": [ { "fn": "main", "locals": [ { "name": "f", "value": "0x0 (NULL)" }, { "name": "count", "value": "0" }, { "name": "line", "value": "256 bytes, not yet written" } ] } ] }
That 0x0 in f is the whole story of the 2 a.m. failure. NULL is not a special safe value. It is an address you must never read through. fio.c looks at f before using it; count.c does not. That is the entire difference between missing.txt: No such file or directory on a bad night and Segmentation fault on a bad night.
Now run the fixed program on the file that does exist, and read both channels of its answer: the printed count and the exit code.
prompt: operator@camp:~$ answer: ./fio three.txt; echo "exit: $?" output: 3 lines exit: 0 hint: Run it, then echo the code: ./fio three.txt; echo "exit: $?"
Success prints the result and exits 0. Failure prints the reason and exits 2. That pair is a contract. A nightly script can now write ./fio today.log || send-an-alert and the right thing happens without anyone parsing text. Remember from Foundations: && runs the next command only after exit 0, and || only after a failure. Real tools keep the same kind of promise; grep exits 1 for no match and 2 for a real error. Pick your codes, document them, and keep them stable.
fio.c is careful about one more thing that count.c never had to think about: WHERE each message goes. A program has two output streams. Standard output, stdout, is stream 1: it carries results. Standard error, stderr, is stream 2: it carries problems. printf always writes to stdout. fprintf(stderr, ...) writes to stderr. Here is fio.c again, with every line that produces output highlighted:
#include <stdio.h> #include <string.h> #include <errno.h>
int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "usage: %s <file>\n", argv[0]); return 1; }
FILE *f = fopen(argv[1], "r"); if (f == NULL) { fprintf(stderr, "%s: %s\n", argv[1], strerror(errno)); return 2; }
char line[256]; int count = 0; while (fgets(line, sizeof line, f) != NULL) { count = count + 1; } fclose(f);
printf("%d lines\n", count); return 0; }
Both error reports go through fprintf(stderr, ...). Only the real answer, the line count, goes through printf. Why bother? Because of what you learned in the redirection lessons: > captures stream 1 only. A pipeline reading your program's output should receive clean data, while error messages still reach the human at the screen.
>>> > grabs stream 1, stdout, and sends it to /dev/null. The error message travels on stream 2, stderr, which nothing redirected. It lands on your screen exactly as before. That is the point of the split: hide the data if you want, never the alarm.
prompt: operator@camp:~$ answer: ./fio missing.txt > /dev/null; echo "exit: $?" output: missing.txt: No such file or directory exit: 2 hint: Redirect stdout only: ./fio missing.txt > /dev/null; echo "exit: $?"
The error message survived the redirect because it rides stderr, not stdout. Now run the mirror experiment in your head: ./fio three.txt > /dev/null prints nothing at all, because 3 lines rides stdout straight into /dev/null while stderr stays silent. The rule to keep: results are data, put them on stdout with printf. Problems are alarms, put them on stderr with fprintf(stderr, ...).
Three messages account for most of what goes wrong in early file I/O work. Learn to read each one.
missing.txt: No such file or directory, with $? showing 2. This is fio.c doing its job, not breaking: fopen failed, errno held the code for a missing path, and strerror translated it. What to check first: does the file exist where you think it does? Run ls on the path, confirm your working directory with pwd, and look for a typo in the name you passed.
Segmentation fault, with $? showing 139. The program touched memory it does not own, and the operating system killed it. In file I/O code this almost always means a FILE * that was NULL when you handed it to fgets or fclose. What to check first: does every fopen in the program have an if (f == NULL) check before the first use of f? The shell reports 139 whenever a program dies this way; the signals lesson later in this module explains where that number comes from.
error: expected ';' before '}' token from gcc, in the shape you first met in Hello, machine:
broken.c: In function 'main': broken.c:5:13: error: expected ';' before '}' token
File, line, column, complaint. Adding I/O code means adding lines, and dropped semicolons come back. What to check first: the end of the line gcc names, then the line directly above it. gcc reports where it NOTICED the problem, at or just after the spot where the semicolon belongs; here line 5 is the return statement itself, and the caret marks exactly where the ; should go. The same reading habit you built in Hello, machine.
Scaffolding off. No command is shown this time; you have every piece.
A monitoring script needs to test the exit code of ./fio missing.txt, but its screen must stay clean: the error message must not appear. You know from this lesson that the message travels on stderr, stream 2. You know from Foundations how to aim a numbered stream at /dev/null. Silence only the error stream, then print the exit code with the same echo you have used all lesson.
prompt: operator@camp:~$ answer: ./fio missing.txt 2> /dev/null; echo "exit: $?" ||| ./fio missing.txt 2>/dev/null; echo "exit: $?" output: exit: 2 hint: Stream 2 is stderr: aim it with 2> /dev/null, then a semicolon and echo "exit: $?"
2> aims stderr at /dev/null, so the message vanished, but the program still failed and the contract still held: exit: 2. This is why the streams and the exit code both matter. Even with every message silenced, the one number tells the script the truth. You just used all three ideas of this lesson in one line: the check exists in the program, the message rides stderr, and the exit code carries the verdict.
You earned this table. Every row is something you just ran or read:
The habit to burn in: never use a FILE * you have not checked. fopen, then if (f == NULL), every single time. It is four lines, and it is the difference between a clear report and a 2 a.m. segfault.
Keep compiling with -Wall on every build, as you have all track. gcc warnings are the cheapest bug reports you will ever receive, and they arrive before the program has a chance to fail at night.
You now hold the front half of a real parser. You can open a file t
Practice File I/O in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.