Files and Streams
Every file operation in C goes through a FILE * — a buffered stream. Understanding the buffer explains delayed output, lost data on a crash, and why checking feof in a loop condition is wrong. Understanding the error state explains how to know whether your program actually worked.
- Open, read, write, and close files with every error path handled.
- Read a text file line by line without overflowing or truncating.
- Write and read fixed-size binary records and seek directly to any one.
- Distinguish end of file from an error, and explain why
while (!feof(f))is wrong. - Explain buffering and say when you must flush.
1Opening and closing
FILE *f = fopen("data.txt", "r");
if (f == NULL) {
perror("data.txt"); /* prints the reason */
return EXIT_FAILURE;
}
…
if (fclose(f) != 0) {
perror("closing data.txt"); /* a write may fail here */
}| Mode | Meaning | If the file exists |
|---|---|---|
"r" | read | Fails if it does not exist |
"w" | write | Truncates to zero length |
"a" | append | Writes always go to the end |
"r+" | read and write | Must exist; does not truncate |
"w+" | read and write | Truncates |
"a+" | read and append | Reads anywhere, writes at the end |
Add b for binary: "rb", "wb". On Unix it changes nothing; on Windows, text mode translates \n to \r\n on write and back on read, which silently corrupts binary data. Always use b for non-text, for portability.
Two things people skip. perror prints your message followed by the system's explanation of errno — "No such file or directory" — which turns a useless error into a useful one. And fclose can fail: it flushes buffered data, and that write can hit a full disk. Ignoring its return value on a file you wrote means you may report success after losing data.
2Reading text
The pattern from week 23, now applied to a file:
char line[256];
while (fgets(line, sizeof line, f) != NULL) {
line[strcspn(line, "\n")] = '\0';
process(line);
}fgets reads at most size - 1 characters, stops at a newline, and always terminates. It returns NULL at end of input or on error — which the loop treats identically, so you must distinguish them afterwards.
while (!feof(f)) is wrong. feof reports whether a previous read already hit the end; it cannot predict. So the loop attempts one read too many, and the last iteration processes stale or uninitialized data. Test the read's return value instead, and consult feof and ferror only after the loop, to find out which ended it.
while (fgets(line, sizeof line, f) != NULL) { … }
if (ferror(f)) {
perror("read error"); /* a real failure */
} else {
/* feof(f) is true: normal end of input */
}3Writing
fprintf(f, "%s,%d\n", name, score); /* formatted, like printf */
fputs("a line\n", f); /* no formatting, no newline added */
fputc('\n', f); /* one character */fprintf returns the number of characters written, or negative on error. For a file that matters — a report, an export — check it, or at minimum check ferror before closing. A full disk or a broken pipe produces no exception; the call simply fails and your program continues.
Note that fputs does not append a newline while puts does. That asymmetry catches everyone once.
4Binary records and positioning
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *f);
size_t fread ( void *ptr, size_t size, size_t count, FILE *f);Both return the number of items transferred, not bytes. A short return means end of file or error.
typedef struct { int id; char name[32]; double score; } Record;
Record r = { .id = 1, .score = 95.5 };
if (fwrite(&r, sizeof r, 1, f) != 1) {
perror("write failed");
}Because every record is the same size, the nth record starts at offset n * sizeof(Record) — so you can jump straight to it without reading what precedes it:
fseek(f, (long)(index * sizeof(Record)), SEEK_SET);
fread(&r, sizeof r, 1, f);| Call | Does |
|---|---|
fseek(f, off, SEEK_SET) | Move to off from the start |
fseek(f, off, SEEK_CUR) | Move off from the current position |
fseek(f, 0, SEEK_END) | Move to the end — used to find the size |
ftell(f) | Current position, or −1 on error |
rewind(f) | Back to the start and clear the error flags |
This file format is not portable. It embeds your machine's integer size, byte order, and structure padding — the padding that week 22 measured. Reading it on another architecture gives nonsense. That is acceptable for a cache or a temporary index and unacceptable for anything exchanged between machines; week 37 builds the portable alternative.
5Buffering
Writes do not reach the file immediately. The C library collects them in a buffer and writes in blocks, because one large write is far cheaper than a thousand small ones.
| Mode | Flushed when | Default for |
|---|---|---|
| Fully buffered | The buffer fills | Files |
| Line buffered | A newline is written | stdout to a terminal |
| Unbuffered | Immediately | stderr |
Three consequences you will actually meet.
A prompt without a newline may not appear before the program blocks for input — week 8's fflush(stdout).
Output can be lost on a crash. Buffered data that was never flushed is gone. This is why debugging with printf can mislead: the last message before a segmentation fault may never have been written.
stdout changes behavior when redirected. To a terminal it is line buffered; to a file or a pipe it becomes fully buffered. A program whose output interleaves correctly on screen can produce a different order in a file, because stderr is never buffered while stdout now is.
fflush(f); /* flush one stream */
fflush(NULL); /* flush every output stream */
setvbuf(f, NULL, _IONBF, 0); /* switch buffering off */6Worked example: text in, binary out, seek back
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <errno.h>
#define NAME_LEN 32
#define LINE_LEN 256
typedef struct {
int id;
char name[NAME_LEN];
double score;
} Record;
static const char *TEXT_FILE = "records.csv";
static const char *BINARY_FILE = "records.dat";
/* ---------- create some input ---------- */
static bool write_sample_text(void)
{
FILE *f = fopen(TEXT_FILE, "w");
if (f == NULL) {
perror(TEXT_FILE);
return false;
}
fputs("# id,name,score\n", f);
fprintf(f, "1,Ada,95.5\n");
fprintf(f, "2,Dennis,88.0\n");
fprintf(f, "3,Ken,91.25\n");
fprintf(f, "4,Grace,78.5\n");
fputs("bad line with no commas\n", f);
fprintf(f, "5,Linus,not-a-number\n");
if (ferror(f)) {
perror("writing " );
fclose(f);
return false;
}
if (fclose(f) != 0) { /* the flush can fail here */
perror("closing " );
return false;
}
return true;
}
/* ---------- read text, one line at a time ---------- */
static bool parse_line(const char *line, Record *out)
{
char name[NAME_LEN];
int id;
double score;
/* %31[^,] reads up to 31 characters that are not a comma */
if (sscanf(line, "%d,%31[^,],%lf", &id, name, &score) != 3) {
return false;
}
out->id = id;
snprintf(out->name, sizeof out->name, "%s", name);
out->score = score;
return true;
}
static size_t read_text(Record *records, size_t capacity)
{
FILE *f = fopen(TEXT_FILE, "r");
if (f == NULL) {
perror(TEXT_FILE);
return 0;
}
char line[LINE_LEN];
size_t count = 0;
long line_number = 0;
while (count < capacity && fgets(line, sizeof line, f) != NULL) {
line_number++;
if (strchr(line, '\n') == NULL && !feof(f)) {
fprintf(stderr, " line %ld too long, skipping\n", line_number);
int c;
while ((c = fgetc(f)) != '\n' && c != EOF) { }
continue;
}
line[strcspn(line, "\n")] = '\0';
if (line[0] == '\0' || line[0] == '#') {
continue;
}
if (!parse_line(line, &records[count])) {
fprintf(stderr, " line %ld rejected: \"%s\"\n", line_number, line);
continue;
}
count++;
}
/* Only NOW ask which condition ended the loop. */
if (ferror(f)) {
perror("reading " );
}
fclose(f);
return count;
}
/* ---------- write fixed-size binary records ---------- */
static bool write_binary(const Record *records, size_t count)
{
FILE *f = fopen(BINARY_FILE, "wb"); /* b matters on Windows */
if (f == NULL) {
perror(BINARY_FILE);
return false;
}
size_t written = fwrite(records, sizeof *records, count, f);
if (written != count) {
fprintf(stderr, " wrote only %zu of %zu records\n", written, count);
fclose(f);
return false;
}
return fclose(f) == 0;
}
/* ---------- seek directly to record n ---------- */
static bool read_record_at(size_t index, Record *out)
{
FILE *f = fopen(BINARY_FILE, "rb");
if (f == NULL) {
perror(BINARY_FILE);
return false;
}
if (fseek(f, 0, SEEK_END) != 0) {
fclose(f);
return false;
}
long size = ftell(f);
long total = size / (long)sizeof(Record);
if ((long)index >= total) {
fprintf(stderr, " index %zu out of range (%ld records)\n",
index, total);
fclose(f);
return false;
}
if (fseek(f, (long)(index * sizeof(Record)), SEEK_SET) != 0) {
fclose(f);
return false;
}
bool ok = fread(out, sizeof *out, 1, f) == 1;
fclose(f);
return ok;
}
static void print_record(const Record *r)
{
printf(" id=%-3d %-10s %6.2f\n", r->id, r->name, r->score);
}
int main(void)
{
puts("== writing sample text ==");
if (!write_sample_text()) {
return EXIT_FAILURE;
}
puts(" wrote " );
puts("\n== reading it back, rejecting bad lines ==");
Record records[16];
size_t count = read_text(records, 16);
printf(" accepted %zu records:\n", count);
for (size_t i = 0; i < count; i++) {
print_record(&records[i]);
}
puts("\n== writing fixed-size binary records ==");
if (!write_binary(records, count)) {
return EXIT_FAILURE;
}
printf(" each record is %zu bytes, %zu records = %zu bytes\n",
sizeof(Record), count, sizeof(Record) * count);
puts("\n== seeking directly, without reading what precedes ==");
Record one;
for (size_t i = 0; i < count; i += 2) {
if (read_record_at(i, &one)) {
printf(" record %zu: ", i);
print_record(&one);
}
}
read_record_at(99, &one);
puts("\n== buffering ==");
printf(" this has no newline and may not appear yet...");
fflush(stdout);
puts(" [flushed]");
fprintf(stderr, " stderr is unbuffered, so this is never delayed\n");
puts(" run './files > out.txt 2>&1' and compare the order");
puts("\n== why while(!feof(f)) is wrong ==");
FILE *f = fopen(TEXT_FILE, "r");
if (f != NULL) {
char line[LINE_LEN];
int bad = 0;
while (!feof(f)) { /* deliberately wrong */
if (fgets(line, sizeof line, f) == NULL) break;
bad++;
}
rewind(f);
int good = 0;
while (fgets(line, sizeof line, f) != NULL) {
good++;
}
printf(" feof-driven loop needed a break to stay correct: %d\n", bad);
printf(" return-value-driven loop: %d lines, no special case\n", good);
fclose(f);
}
remove(TEXT_FILE);
remove(BINARY_FILE);
puts("\n temporary files removed");
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o files files.c
./filesThree details worth extracting
%31[^,] in the sscanf format. It reads up to 31 characters that are not a comma — a bounded scan set. Without the 31 it is as dangerous as a bare %s, and week 8's overflow warning applies. The bound is one less than the array size, to leave room for the terminator.
fclose is checked. On the write path its return value decides whether the function reports success. A program that ignores it can claim to have written a file that never reached the disk.
Seeking reads one record, not n. read_record_at(4, …) touches 48 bytes, not the whole file. For a million-record file that is the difference between microseconds and seconds — which is what fixed-size records buy you, and why variable-length text formats need an index.
Experiments
See the non-portability. Print the raw bytes of a record:
xxd records.dat | head -4Find the integer 1 stored as 01 00 00 00 — little-endian — and the padding bytes between name and score holding whatever was in memory. Both are why week 37 exists.
Break the buffering assumption.
./files # stdout to a terminal: line buffered
./files > out.txt 2>&1
cat out.txt # different interleaving of stdout and stderrRedirected, stdout becomes fully buffered while stderr stays unbuffered, so the error lines now appear before output that was logically earlier.
Lose data on a crash. Add printf("about to crash\n"); *(int *)0 = 1; at the end and redirect to a file. The message is in the buffer when the process dies, so the file is empty — and the same program prints it when run on a terminal, because the newline flushed a line-buffered stream.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Not checking fopen | Null dereference on the first read | Check, and use perror to say why. |
while (!feof(f)) | One iteration too many; stale data | Test the read's return value. |
Ignoring fclose's result | Reports success after a failed flush | Check it on any file you wrote. |
Opening with "w" to read | Truncates the file to nothing | "r", or "r+" to modify. |
Omitting b for binary on Windows | Newline translation corrupts data | Always "rb"/"wb". |
Assuming fread returns bytes | Off by a factor of the item size | It returns items. |
| Writing structs and reading them elsewhere | Byte order and padding differ | Serialize field by field — week 37. |
| Expecting output before a newline | Prompt invisible; program looks frozen | fflush(stdout). |
8Check yourself
Why is while (!feof(f)) the wrong loop condition?
Because feof reports whether a previous read already reached the end; it cannot look ahead. The condition is therefore still true after the last successful read, so the loop runs once more, the read fails, and the body processes whatever was left in the buffer. Drive the loop on the return value of the read itself, and use feof/ferror afterwards to find out which ended it.
Why can fclose fail, and why does that matter?
Because it flushes any buffered data, and that write can fail — a full disk, a broken pipe, a network filesystem error. Data you thought was written is then lost. Ignoring the return value on a file you wrote means your program can report success after losing the user's data.
Why is a file of raw structures not portable?
It embeds the writing machine's integer widths, byte order, and the compiler's structure padding — none of which the standard fixes. Reading it on a different architecture or with a different compiler yields wrong values. Such a format is fine for a private cache and unsuitable for anything exchanged between machines.
Your program's output appears in a different order when redirected to a file. Why?
Because stdout is line buffered when it goes to a terminal but fully buffered when it goes to a file or pipe, while stderr is always unbuffered. Redirected, the error messages are written immediately while ordinary output waits in the buffer, so the interleaving changes. Flush explicitly if the order matters.
What does fixed-size binary records buy you over a text format?
Direct access. Record n starts at offset n × sizeof(Record), so fseek can jump straight to it without reading anything before it — constant time instead of linear. A variable-length text format has no such property and needs a separate index to match it.
9Where this leads
Every function in this week's example returns a status that the caller had to check, and each one cleaned up before returning. Week 27 makes that discipline systematic: one error contract per module, one cleanup path per function, and the goto idiom that lets a function acquire five resources and release exactly the ones it got.