Phase 3 · Structured & Modular CModule 13~44 min read

File Handling & Persistent Data

Read and write text and binary files with streams, handle errors, navigate files, and preserve structured data.

What you'll learn

Files turn in-memory values into persistent byte sequences. Correct file code chooses a format, validates every operation, distinguishes end-of-file from failure, and closes every opened stream.

By the end, you'll be able to:

  • Open text or binary streams with the correct mode
  • Read and write complete records while checking short operations
  • Navigate seekable streams and diagnose I/O failures
  • Design cleanup paths and portable file formats
Persistence requires an explicit representation

Program objects

struct · int · string

→ encode →

Byte sequence

text or binary format

→ stream →

Persistent file

closed and durable

Open & close streams

ModeMeaning
r / rbRead an existing text/binary file
w / wbCreate or truncate for writing
a / abCreate or append at the end
r+ / w+ / a+Permit both reading and writing with mode-specific behavior

fopen returns a FILE * or NULL. A successfully opened stream must eventually reach fclose, whose failure matters for output because buffered bytes may be written during closing.

Text files

Text mode translates C characters to the platform's text representation. Use formatted or line-based functions and define a grammar that can reject malformed records.

write_scores.c
#include <stdio.h>

int main(void) {
    FILE *file = fopen("scores.txt", "w");
    if (file == NULL) {
        perror("scores.txt");
        return 1;
    }

    if (fprintf(file, "%s,%d\n", "Maya", 93) < 0 ||
        fprintf(file, "%s,%d\n", "Omar", 88) < 0) {
        fprintf(stderr, "Could not write scores\n");
        fclose(file);
        return 1;
    }

    if (fclose(file) == EOF) {
        perror("closing scores.txt");
        return 1;
    }
    return 0;
}
read_scores.c
#include <stdio.h>

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

    char line[100];
    while (fgets(line, sizeof line, file) != NULL) {
        char name[40];
        int score;
        if (sscanf(line, "%39[^,],%d", name, &score) == 2) {
            printf("%-10s %d\n", name, score);
        } else {
            fprintf(stderr, "Skipped malformed line: %s", line);
        }
    }

    int failed = ferror(file);
    fclose(file);
    return failed ? 1 : 0;
}

Watch out

Never write untrusted text as the format argument, such asfprintf(file, user_text). Use fprintf(file, "%s", user_text)to prevent format-string vulnerabilities.

Binary files

fread and fwrite transfer blocks and return the number of complete elements processed. Always compare that count with the request.

write_binary.c
#include <stdint.h>
#include <stdio.h>

int main(void) {
    uint32_t values[] = {10, 20, 30, 40};
    size_t count = sizeof values / sizeof values[0];

    FILE *file = fopen("values.bin", "wb");
    if (file == NULL) return 1;

    size_t written = fwrite(values, sizeof values[0], count, file);
    if (written != count) {
        fprintf(stderr, "Short write\n");
        fclose(file);
        return 1;
    }
    if (fclose(file) == EOF) return 1;
    return 0;
}

Note

Binary mode prevents platform text transformations. It does not make raw integer or structure layouts portable across byte order, type widths, padding, or program versions.

Seek & position

Seekable streams support fseek, ftell, and rewind. Use SEEK_SET, SEEK_CUR, or SEEK_END rather than assuming numeric origin values.

file_size.c
#include <stdio.h>

int main(void) {
    FILE *file = fopen("values.bin", "rb");
    if (file == NULL) return 1;

    if (fseek(file, 0, SEEK_END) != 0) {
        fclose(file);
        return 1;
    }
    long bytes = ftell(file);
    if (bytes < 0) {
        fclose(file);
        return 1;
    }

    rewind(file);
    printf("File size: %ld bytes\n", bytes);
    fclose(file);
    return 0;
}
Typical size for four 32-bit integers.

Watch out

Portable text-stream seeking has stricter rules than binary seeking. For arbitrary text navigation, save positions returned by ftell and seek back to those positions.

Errors & end-of-file

A read function often uses one return value for both end-of-file and error. Attempt the read first, then use ferror to determine whether failure occurred.

read_characters.c
#include <stdio.h>

int main(void) {
    FILE *file = fopen("input.txt", "r");
    if (file == NULL) return 1;

    int ch;
    while ((ch = fgetc(file)) != EOF) {
        putchar(ch);
    }

    if (ferror(file)) {
        perror("reading input.txt");
        fclose(file);
        return 1;
    }

    fclose(file);
    return 0;
}

Key idea

Do not loop on while (!feof(file)). End-of-file is set only after a read tries to go past the available input. Make the read operation the loop condition.

Reliable cleanup

Multi-resource functions need one path that releases everything acquired so far. Initialize handles to null, acquire in order, and release in reverse order.

copy_file.c
#include <stdio.h>

int copy_file(const char *source_path, const char *target_path) {
    int result = 1;
    FILE *source = NULL;
    FILE *target = NULL;
    unsigned char buffer[4096];

    source = fopen(source_path, "rb");
    if (source == NULL) goto cleanup;
    target = fopen(target_path, "wb");
    if (target == NULL) goto cleanup;

    size_t count;
    while ((count = fread(buffer, 1, sizeof buffer, source)) > 0) {
        if (fwrite(buffer, 1, count, target) != count) goto cleanup;
    }
    if (ferror(source)) goto cleanup;
    result = 0;

cleanup:
    if (target != NULL && fclose(target) == EOF) result = 1;
    if (source != NULL) fclose(source);
    return result;
}

Tip

Closing an input stream rarely changes success, but closing an output stream can reveal a delayed write failure. Preserve that error in the final result.

Portable formats

  • Define field order, encoding, delimiters, and escaping for text
  • Define byte order and exact-width fields for binary data
  • Include a magic value and format version when appropriate
  • Validate lengths before allocating or reading payloads
  • Reject trailing, truncated, contradictory, or unsupported data

Key idea

A file parser is a trust boundary. Treat every length, count, tag, and offset as hostile until it has been checked against the format and actual file size.

Recap & quick check

Key takeaways

  • fopen returns a stream or NULL; every successful open needs a closing path.
  • Read and write functions report completed work, which may be shorter than requested.
  • Test ferror after a read loop to distinguish failure from normal end-of-file.
  • Raw structure bytes are not a portable persistent format.
  • Reliable code defines a format, validates it, and releases resources on every path.

Quick check

1. What does mode "w" do to an existing file?

2. Why is while (!feof(file)) incorrect?

3. What does fwrite return?

4. Why not persist raw structs as a portable format?

Your data can now persist safely. Next up: Module 14 — Function Pointers & Callbacks, where functions become configurable behavior.