File I/O
Reading and writing files in C, and the error checks that generated code omits every single time.
#include <stdio.h>
FILE *f = fopen("data.txt", "r");
if (f == NULL) { /* ALWAYS check */
perror("fopen data.txt");
return -1;
}
/* ... */
fclose(f);fopen returns NULL on failure and sets errno. perror prints your message followed by the system's description, which is almost always what you want in a small tool.
Modes#
| Mode | Meaning |
|---|---|
"r" | read; fails if the file does not exist |
"w" | write; truncates an existing file to zero length |
"a" | append; writes always go to the end |
"r+" | read and write; file must exist |
"w+" | read and write; truncates |
"a+" | read and append |
Add b for binary ("rb", "wb"). On POSIX it changes nothing; on Windows it stops newline translation corrupting binary data. Use it whenever the content is not text.
Reading lines#
char line[256];
while (fgets(line, sizeof line, f) != NULL) {
line[strcspn(line, "\n")] = '\0'; /* strip the newline */
printf("read: %s\n", line);
}
if (ferror(f)) { /* fgets returns NULL for EOF AND error */
perror("read");
return -1;
}Two details that matter:
fgetsreturnsNULLfor both end-of-file and error. Without theferrorcheck you cannot tell "finished the file" from "gave up halfway", and generated code omits this check essentially always.- A line longer than the buffer is split.
fgetsreads at mostsize - 1bytes; the rest arrives on the next call. If the distinction matters, check whether the buffer contains a\n.
getline (POSIX) allocates for you and handles any line length:
char *line = NULL;
size_t cap = 0;
ssize_t len;
while ((len = getline(&line, &cap, f)) != -1) {
if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
puts(line);
}
free(line); /* free once, at the end */Note getline reuses and grows the same buffer, so you free it once after the loop, not inside it.
Reading a whole file#
static char *read_all(const char *path, size_t *out_len) {
FILE *f = fopen(path, "rb");
if (!f) return NULL;
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return NULL; }
long size = ftell(f);
if (size < 0) { fclose(f); return NULL; }
rewind(f);
if ((unsigned long)size > MAX_FILE_BYTES) { fclose(f); return NULL; } /* bound it */
char *buf = malloc((size_t)size + 1);
if (!buf) { fclose(f); return NULL; }
size_t got = fread(buf, 1, (size_t)size, f);
if (got != (size_t)size) { free(buf); fclose(f); return NULL; }
buf[size] = '\0';
*out_len = got;
fclose(f);
return buf;
}The bound check is the security-relevant line: without it, the file's size decides your allocation, and a huge or special file exhausts memory. See the security review.
Binary I/O#
struct record { uint32_t id; char name[32]; double score; };
struct record r = { .id = 1, .name = "ada", .score = 99.5 };
if (fwrite(&r, sizeof r, 1, f) != 1) { perror("write"); return -1; }
struct record back;
if (fread(&back, sizeof back, 1, f) != 1) {
if (feof(f)) fprintf(stderr, "unexpected end of file\n");
else perror("read");
return -1;
}fread and fwrite return the number of items transferred, not bytes. Compare against the count you asked for.
Writing a struct directly is fine for a file only your program reads on one machine. It is not a portable format: struct padding, integer endianness and type sizes all vary. For anything that crosses machines, serialise field by field with fixed-width types.
fclose can fail#
if (fclose(f) != 0) {
perror("fclose"); /* buffered data may not have reached disk */
return -1;
}Output is buffered, so a fwrite can succeed while the actual disk write fails later — and fclose is where you find out. For a file you wrote, the close error is the one that tells you the write failed, and it is the one generated code always discards.
For data that must survive a crash, flush and sync before closing:
fflush(f);
fsync(fileno(f));
fclose(f);Standard streams#
fprintf(stderr, "warning: %s\n", msg); /* diagnostics go to stderr */
printf("result: %d\n", value); /* actual output to stdout */Keeping them separate is what lets a caller do ./tool > out.txt and still see errors. stderr is unbuffered, stdout is line-buffered to a terminal and block-buffered to a pipe — which is why interleaved output can look out of order when redirected.
Exercise#
#include <stdio.h>
int main(void) {
/* Write a program that:
1. reads "input.txt" line by line with getline
2. writes lines containing "ERROR" to "errors.txt"
3. checks EVERY call: fopen, getline via ferror, fclose on both files
4. writes via a temp file + rename so a failure leaves the old file intact
5. returns 0 on success, non-zero on any failure */
printf("start\n");
return 0;
}Common questions#
fgets or getline?#
getline when it is available — it handles any line length and allocates for you. fgets when you need strict C portability or want a fixed-size buffer with no allocation, and then you must handle over-long lines yourself.
Why check the return value of fclose?#
Because writes are buffered. fwrite can report success while the data is still in memory, and the real failure — a full disk, an I/O error — surfaces when the buffer is flushed at close. Discarding that return means silently losing data.
Is writing a struct with fwrite safe?#
Only for a private format read back by the same program on the same platform. Padding, endianness and type sizes differ between compilers and architectures, so a struct dumped on one machine may not read correctly on another. Serialise explicitly for anything portable.
Get the C agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for C. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.