File I/O in C

fopen, fread, fwrite, fclose, reading a file line by line with fgets, and checking every error properly.

Why file I/O in C looks the way it does

C has no built-in concept of a "file object" with methods on it the way many higher-level languages do — instead, the standard library (<stdio.h>) gives you a FILE *, an opaque handle representing an open stream, plus a set of plain functions (fopen, fread, fwrite, fclose, and friends) that all take that handle as an argument. Everything about reading and writing files in C follows from this one idea: open a stream, do some number of reads/writes against it, then close it — and check the return value at every one of those steps, because C will not do it for you.

Opening and closing a file: fopen and fclose

C
#include <stdio.h>

int main(void) {
    FILE *fp = fopen("greeting.txt", "w"); // "w" = write, truncating any existing content
    if (fp == NULL) {
        perror("fopen failed"); // perror prints a human-readable reason, e.g. "Permission denied"
        return 1;
    }

    fprintf(fp, "Hello, file!\n"); // fprintf works on a FILE*, same formatting as printf
    fclose(fp); // flushes any buffered output and releases the OS file handle

    return 0;
}
Mode Meaning
"r" Read; file must already exist
"w" Write; creates the file if missing, truncates it to zero length if it exists
"a" Append; creates the file if missing, writes always go to the end
"r+" Read and write; file must already exist
"w+" Read and write; truncates if it exists
"a+" Read and append

Add a b (e.g. "rb", "wb") to open in binary mode — on Windows this matters, since text mode silently translates \n to \r\n on write and back on read; on Linux/macOS binary and text mode behave identically, but writing "rb"/"wb" explicitly whenever the content isn't plain text is good practice on every platform.

fopen returns NULL on failure (file doesn't exist, no permission, disk full, and so on) rather than throwing an exception or crashing — you must check for NULL before doing anything else with the pointer, since dereferencing a NULL FILE * is undefined behavior.

Reading a file line by line with fgets

fgets is the standard, safe way to read one line at a time — it reads up to a given number of bytes (or until a newline, whichever comes first), which makes it immune to the kind of buffer overflow that plagues its unsafe cousin gets (removed from the language entirely in C11).

C
#include <stdio.h>

int main(void) {
    FILE *fp = fopen("greeting.txt", "r");
    if (fp == NULL) {
        perror("fopen failed");
        return 1;
    }

    char line[256];
    while (fgets(line, sizeof(line), fp) != NULL) {
        printf("Read: %s", line); // fgets keeps the trailing '\n', so no extra newline needed here
    }

    if (ferror(fp)) {
        fprintf(stderr, "A read error occurred\n");
    }

    fclose(fp);
    return 0;
}

fgets returns NULL for two different reasons: reaching end-of-file, or an actual read error — that's exactly why the loop condition alone can't distinguish them, and ferror(fp) is checked afterward to see which one actually happened.

fgets keeps the newline character in the buffer if one was read (unlike, say, Python's readline stripped by convention in many idioms) — strip it explicitly if you don't want it:

C
line[strcspn(line, "\n")] = '\0'; // find the newline (if any) and cut the string there

Reading and writing fixed-size binary data with fread/fwrite

fread/fwrite move raw bytes between memory and a stream — the natural tool once you're dealing with structured binary data rather than plain text:

C
#include <stdio.h>
#include <string.h>

typedef struct {
    char name[50];
    int  age;
} Person;

int main(void) {
    Person p1;
    strncpy(p1.name, "Ada", sizeof(p1.name) - 1);
    p1.name[sizeof(p1.name) - 1] = '\0';
    p1.age = 30;

    FILE *fp = fopen("people.bin", "wb");
    if (fp == NULL) { perror("fopen"); return 1; }

    size_t written = fwrite(&p1, sizeof(Person), 1, fp); // one record of sizeof(Person) bytes
    fclose(fp);

    if (written != 1) {
        fprintf(stderr, "Failed to write the record\n");
        return 1;
    }

    Person p2;
    fp = fopen("people.bin", "rb");
    if (fp == NULL) { perror("fopen"); return 1; }

    size_t itemsRead = fread(&p2, sizeof(Person), 1, fp);
    fclose(fp);

    if (itemsRead != 1) {
        fprintf(stderr, "Failed to read the record\n");
        return 1;
    }

    printf("%s is %d\n", p2.name, p2.age); // Ada is 30
    return 0;
}

Both fread and fwrite take the same four arguments: a pointer to the data, the size of one element, the number of elements, and the stream — and both return the number of complete elements actually transferred, which can be less than requested on a short read/write or an error. That return value is the only reliable signal something went wrong; a program that ignores it and assumes the full transfer always succeeds will silently work with garbage or partial data the moment a real I/O error occurs.

Checking for errors properly

Function What it tells you
fopen return value NULL means the open itself failed — check this before anything else
perror(msg) Prints msg followed by a human-readable reason, based on the global errno set by the failing call
ferror(fp) Returns non-zero if the stream's error indicator is set (something went wrong on a previous operation)
feof(fp) Returns non-zero only after a read has already tried and failed to read past the end — never a reliable loop condition on its own
Return value of fread/fwrite The number of complete elements actually transferred — compare it against what you asked for

The single most common mistake in this area deserves special emphasis: feof() only becomes true after a failed read attempt already happened, not before — so using it as a loop's condition (while (!feof(fp))) reads one iteration too many, typically processing a spurious, garbage "extra" line at the end of the file. The correct pattern is always to loop on the read function's own return value, and use feof/ferror only afterward, to find out why the loop ended.

C
// WRONG — double-processes the last line
while (!feof(fp)) {
    fgets(line, sizeof(line), fp);
    printf("%s", line);
}

// RIGHT — loop condition IS the read's success/failure
while (fgets(line, sizeof(line), fp) != NULL) {
    printf("%s", line);
}

Common mistakes

  • Using while (!feof(fp)) as a loop condition instead of checking the read function's own return value — this reliably processes a stale/garbage extra iteration at the end of the file.
  • Forgetting to check fopen's return value for NULL before using the FILE *, causing a crash (or worse, silently undefined behavior) the first time the file genuinely can't be opened.
  • Forgetting fclose on every exit path — this can leave the last buffered writes never actually flushed to disk, so the file appears truncated or empty even though fprintf/fwrite "succeeded."
  • Ignoring the return value of fread/fwrite and assuming the full requested amount of data was always transferred.
  • Opening a file in text mode ("r"/"w") for binary data on Windows, where \n/\r\n translation can silently corrupt bytes that were never meant to be interpreted as line endings.

Interview questions

Q: Why is while (!feof(fp)) considered a bug when reading a file line by line? feof() only becomes true after a read has already been attempted and has already failed to find more data — it's not a look-ahead check. Using it as the loop condition means the loop body runs one extra time after the real content is exhausted, typically re-processing the last line or a garbage empty read. The fix is to loop on the read function's own return value (e.g., fgets(...) != NULL) and use feof/ferror only afterward to determine why the loop ended.

Q: What's the difference between fread/fwrite and fprintf/fgets, and when would you choose one over the other? fread/fwrite move raw, untranslated bytes and are the right tool for structured binary data (a struct written directly to disk, an image format, a custom file format) where the exact byte layout matters. fprintf/fgets (and fputs) work with formatted or line-oriented text, are portable across platforms in terms of content, and are the right tool whenever the file is meant to be human-readable or line-structured, like a log file or a CSV.