Home C / C++ File Handling in C Explained — fopen, fread, fwrite and Real-World Patterns

File Handling in C Explained — fopen, fread, fwrite and Real-World Patterns

In Plain English 🔥
Imagine your program's memory is a whiteboard — fast, but erased the moment you walk out of the room. A file is a notebook you leave on the desk: it survives after you leave, and anyone (or any program) can open it later and pick up exactly where you left off. File handling in C is just the set of tools that lets your program open that notebook, read what's written, add new pages, and close it properly when it's done. Forget to close it and it's like leaving the notebook soaking in the rain — you risk losing everything.
⚡ Quick Answer
Imagine your program's memory is a whiteboard — fast, but erased the moment you walk out of the room. A file is a notebook you leave on the desk: it survives after you leave, and anyone (or any program) can open it later and pick up exactly where you left off. File handling in C is just the set of tools that lets your program open that notebook, read what's written, add new pages, and close it properly when it's done. Forget to close it and it's like leaving the notebook soaking in the rain — you risk losing everything.

Every meaningful program eventually needs to outlive itself. A configuration it reads on startup, a log it writes during a crash, a CSV it exports for a client — none of that works if data only lives in RAM. File handling is the bridge between your program's fleeting in-memory world and the persistent world on disk. That's not a nice-to-have feature; it's table stakes for anything production-grade.

The C standard library solves this with a thin but powerful abstraction: the FILE pointer. Instead of dealing with raw OS system calls (which differ between Linux, Windows, and macOS), you get a portable, consistent API — fopen, fread, fwrite, fprintf, fscanf, fclose — that works identically everywhere C runs. The OS complexity is hidden behind that single FILE* handle, which is exactly why C file I/O has survived for fifty years without being replaced.

By the end of this article you'll be able to open files safely, read and write both text and binary data, handle errors the way real production code does, and avoid the three mistakes that trip up even experienced developers. You'll also walk away with the conceptual model that makes every fread/fwrite call feel obvious rather than magical.

Opening and Closing Files — The FILE* Lifecycle You Must Respect

Every file operation in C starts with fopen() and must end with fclose(). The FILE* pointer you get back isn't a file — it's a control structure the C runtime uses to buffer data, track your position, and manage the connection to the OS file descriptor. Think of it as a library card: you need it to check out books, and you must return it when you're done.

fopen() takes two arguments: the file path and a mode string. The mode tells the OS what you intend to do — 'r' for reading an existing file, 'w' for writing (creating or truncating), 'a' for appending, and their binary variants 'rb', 'wb', 'ab'. Choosing the wrong mode is one of the most common sources of data loss in C programs.

fopen() returns NULL on failure — network drive unavailable, wrong permissions, file doesn't exist in 'r' mode. You must check for NULL before doing anything else. Skipping that check is not a style issue; it's a segfault waiting to happen. Always pair your NULL check with perror() so the error message tells you exactly what went wrong, not just that something did.

file_lifecycle.c · C
1234567891011121314151617181920212223242526272829303132
#include <stdio.h>
#include <stdlib.h>  // for exit()

int main(void) {
    // Attempt to open (or create) a log file for writing
    // 'w' mode: creates the file if it doesn't exist,
    //           TRUNCATES it to zero bytes if it does — be careful!
    FILE *log_file = fopen("app_log.txt", "w");

    // ALWAYS check for NULL before using the pointer.
    // fopen returns NULL when the file can't be opened for any reason.
    if (log_file == NULL) {
        // perror prints the system error message (e.g. "Permission denied")
        // prefixed by whatever string you pass it.
        perror("ERROR: Could not open app_log.txt");
        exit(EXIT_FAILURE);  // exit cleanly rather than crash later
    }

    // Write a header line to the log
    fprintf(log_file, "=== Application Log Started ===\n");
    fprintf(log_file, "Status: Initialised successfully\n");

    // fclose flushes any buffered data to disk AND releases the FILE*
    // resource. Skip this and you may lose the last chunk of data.
    if (fclose(log_file) != 0) {
        perror("ERROR: Could not close app_log.txt");
        exit(EXIT_FAILURE);
    }

    printf("Log written successfully. Check app_log.txt\n");
    return 0;
}
▶ Output
Log written successfully. Check app_log.txt

[Contents of app_log.txt]
=== Application Log Started ===
Status: Initialised successfully
⚠️
Watch Out: 'w' Mode Silently Destroys Your FileOpening an existing file with 'w' immediately truncates it to zero bytes — before you write a single character. If you want to add to an existing file without destroying it, use 'a' (append) mode. This mistake has wiped production log files and cost engineers their evenings.

Reading and Writing Text Files — fprintf, fscanf, fgets in Practice

Text file I/O is the most common form of file handling you'll write. Configuration files, CSV exports, log files, INI parsers — they're all text. C gives you three main tools: fprintf/fscanf (formatted, mirrors printf/scanf), fgets (line-by-line reading, the safe choice), and fputc/fgetc (character by character, useful for parsers).

fscanf looks tempting but has a reputation for being fragile — it stops at whitespace, which means names with spaces silently break it. fgets is almost always the better choice for reading: it reads an entire line up to a newline or the buffer limit, whichever comes first, so it never overflows your buffer (unlike gets, which was removed from C11 for that exact reason).

The pattern for reading a text file line by line is a while loop with fgets as the condition. When fgets hits end-of-file it returns NULL, ending the loop cleanly. Inside the loop you can parse, tokenise, or process each line however you need. This pattern powers everything from simple config readers to lightweight CSV parsers in embedded systems.

text_file_rw.c · C
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_LINE_LENGTH 256
#define SCORES_FILE     "scores.txt"

// Writes three player scores to a text file
void write_scores(void) {
    FILE *scores_file = fopen(SCORES_FILE, "w");
    if (scores_file == NULL) {
        perror("ERROR: Cannot create scores file");
        exit(EXIT_FAILURE);
    }

    // fprintf works exactly like printf but targets a file
    fprintf(scores_file, "Alice,4200\n");
    fprintf(scores_file, "Bob,3875\n");
    fprintf(scores_file, "Charlie,5100\n");

    fclose(scores_file);
    printf("Scores written to %s\n", SCORES_FILE);
}

// Reads every line back and prints it with a rank number
void read_scores(void) {
    FILE *scores_file = fopen(SCORES_FILE, "r");
    if (scores_file == NULL) {
        perror("ERROR: Cannot open scores file for reading");
        exit(EXIT_FAILURE);
    }

    char line_buffer[MAX_LINE_LENGTH];  // holds one line at a time
    int rank = 1;

    printf("\n--- Leaderboard ---\n");

    // fgets reads up to (MAX_LINE_LENGTH - 1) chars, stops at '\n' or EOF.
    // It returns NULL when there's nothing left to read.
    while (fgets(line_buffer, sizeof(line_buffer), scores_file) != NULL) {
        // fgets keeps the trailing '\n' — strip it for clean display
        line_buffer[strcspn(line_buffer, "\n")] = '\0';

        // strtok splits the line on the comma delimiter
        char *player_name  = strtok(line_buffer, ",");
        char *score_string = strtok(NULL, ",");

        if (player_name != NULL && score_string != NULL) {
            printf("  #%d  %-10s  %s pts\n", rank++, player_name, score_string);
        }
    }

    fclose(scores_file);
}

int main(void) {
    write_scores();
    read_scores();
    return 0;
}
▶ Output
Scores written to scores.txt

--- Leaderboard ---
#1 Alice 4200 pts
#2 Bob 3875 pts
#3 Charlie 5100 pts
⚠️
Pro Tip: Strip the Newline fgets Leaves Behindfgets includes the '\n' character in the buffer. Use line_buffer[strcspn(line_buffer, "\n")] = '\0' to strip it cleanly — this is safer than manually indexing strlen()-1, which breaks on empty lines.

Binary File I/O — fread and fwrite for Structs and Raw Data

Text files are human-readable but inefficient for structured data. If you're storing a thousand employee records, writing each integer as a string of ASCII digits wastes space and requires parsing on the way back in. Binary mode writes the raw bytes from memory directly to disk — what's in the struct goes in, what's on disk comes back out. Same layout, no conversion.

fwrite(pointer, size_of_one_element, count, file) is the workhorse. It returns the number of elements successfully written — if that's less than count, something went wrong and you need to check ferror(). fread mirrors it exactly: same four arguments, returns how many elements it actually read.

The critical thing to understand is that binary files are not portable across different CPU architectures. A file written on a little-endian x86 machine will have its integer bytes in reverse order compared to what a big-endian SPARC machine expects. For embedded systems or single-platform tools this doesn't matter. For cross-platform data exchange, use text or a defined binary format like protobuf. Know this tradeoff before you choose binary.

binary_file_rw.c · C
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define RECORDS_FILE "employees.dat"
#define MAX_NAME_LEN 50

// A fixed-size struct — perfect for binary I/O because the layout is predictable
typedef struct {
    int    employee_id;
    char   full_name[MAX_NAME_LEN];
    double annual_salary;
} Employee;

void save_employees(const Employee *employees, int count) {
    // 'wb' — write binary. Without the 'b', Windows may corrupt data
    // by translating '\n' bytes in your struct to '\r\n'.
    FILE *data_file = fopen(RECORDS_FILE, "wb");
    if (data_file == NULL) {
        perror("ERROR: Cannot open employees.dat for writing");
        exit(EXIT_FAILURE);
    }

    // fwrite returns the number of items written.
    // If it doesn't match 'count', the disk might be full.
    size_t items_written = fwrite(employees, sizeof(Employee), count, data_file);
    if ((int)items_written != count) {
        fprintf(stderr, "ERROR: Only wrote %zu of %d records\n", items_written, count);
        fclose(data_file);
        exit(EXIT_FAILURE);
    }

    fclose(data_file);
    printf("Saved %d employee record(s) to %s\n", count, RECORDS_FILE);
}

void load_and_print_employees(int expected_count) {
    FILE *data_file = fopen(RECORDS_FILE, "rb");  // 'rb' — read binary
    if (data_file == NULL) {
        perror("ERROR: Cannot open employees.dat for reading");
        exit(EXIT_FAILURE);
    }

    Employee record;  // reuse a single struct for each read

    printf("\n--- Employee Records ---\n");

    // Read one Employee at a time until fread returns 0 (EOF or error)
    int rank = 1;
    while (fread(&record, sizeof(Employee), 1, data_file) == 1) {
        printf("  [%d] ID: %04d  Name: %-20s  Salary: $%.2f\n",
               rank++,
               record.employee_id,
               record.full_name,
               record.annual_salary);
    }

    // Distinguish between EOF (normal) and a real read error
    if (ferror(data_file)) {
        perror("ERROR: Failed while reading employees.dat");
    }

    fclose(data_file);
}

int main(void) {
    Employee team[3] = {
        {1001, "Sarah Mitchell",  92500.00},
        {1002, "James Okafor",   87000.00},
        {1003, "Lin Wei",       105000.00}
    };

    save_employees(team, 3);
    load_and_print_employees(3);

    return 0;
}
▶ Output
Saved 3 employee record(s) to employees.dat

--- Employee Records ---
[1] ID: 1001 Name: Sarah Mitchell Salary: $92500.00
[2] ID: 1002 Name: James Okafor Salary: $87000.00
[3] ID: 1003 Name: Lin Wei Salary: $105000.00
⚠️
Watch Out: Struct Padding Can Corrupt Binary FilesThe compiler may insert padding bytes between struct members for alignment. Two builds with different compiler flags can produce structs of different sizes — meaning a file written by one binary can't be read correctly by another. Use __attribute__((packed)) on GCC or #pragma pack(1) on MSVC if cross-version portability matters, but be aware this can hurt performance.

Random Access and File Positioning — fseek, ftell and rewind

Sequential reading is fine for logs and config files, but a database that re-reads the whole file to update one record is a performance disaster. C gives you fseek() for jumping to any byte position, ftell() for finding out where you currently are, and rewind() as a shortcut for jumping back to the start.

fseek(file, offset, origin) takes three arguments. The origin can be SEEK_SET (from the start of the file), SEEK_CUR (relative to current position), or SEEK_END (relative to the end — useful for finding the file size). The offset is a signed long, so you can move forward or backward.

The real power of fseek emerges with fixed-size structs. Because every Employee record is exactly sizeof(Employee) bytes, you can jump to the nth record with fseek(file, n * sizeof(Employee), SEEK_SET). This is how simple flat-file databases work — and understanding this pattern demystifies a lot of file-based data storage you'll encounter in embedded systems and legacy codebases.

random_access.c · C
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define RECORDS_FILE "employees.dat"  // reuses file from previous example
#define MAX_NAME_LEN 50

typedef struct {
    int    employee_id;
    char   full_name[MAX_NAME_LEN];
    double annual_salary;
} Employee;

// Returns file size in bytes using fseek/ftell — a classic C idiom
long get_file_size(FILE *file) {
    fseek(file, 0, SEEK_END);    // jump to the very end
    long size = ftell(file);     // ftell returns current byte position
    rewind(file);                // rewind back to the start for subsequent reads
    return size;
}

// Update a single record by index without touching other records
void update_salary(int record_index, double new_salary) {
    // 'r+b' = read-write binary (file must already exist)
    FILE *data_file = fopen(RECORDS_FILE, "r+b");
    if (data_file == NULL) {
        perror("ERROR: Cannot open employees.dat for update");
        exit(EXIT_FAILURE);
    }

    long file_size    = get_file_size(data_file);
    int  total_records = (int)(file_size / sizeof(Employee));

    printf("File size: %ld bytes | Records: %d\n", file_size, total_records);

    if (record_index < 0 || record_index >= total_records) {
        fprintf(stderr, "ERROR: Record index %d out of range (0-%d)\n",
                record_index, total_records - 1);
        fclose(data_file);
        exit(EXIT_FAILURE);
    }

    // Jump directly to the target record — no need to read what's before it
    long byte_offset = (long)record_index * sizeof(Employee);
    fseek(data_file, byte_offset, SEEK_SET);

    // Read the current record first so we can update just the salary field
    Employee target_record;
    if (fread(&target_record, sizeof(Employee), 1, data_file) != 1) {
        perror("ERROR: Failed to read target record");
        fclose(data_file);
        exit(EXIT_FAILURE);
    }

    printf("Updating %s: $%.2f -> $%.2f\n",
           target_record.full_name,
           target_record.annual_salary,
           new_salary);

    target_record.annual_salary = new_salary;

    // Seek back to the same position — fread advanced the pointer past the record
    fseek(data_file, byte_offset, SEEK_SET);
    fwrite(&target_record, sizeof(Employee), 1, data_file);

    fclose(data_file);
    printf("Update complete.\n");
}

int main(void) {
    // Update the salary of the second employee (index 1 = James Okafor)
    update_salary(1, 95000.00);
    return 0;
}
▶ Output
File size: 348 bytes | Records: 3
Updating James Okafor: $87000.00 -> $95000.00
Update complete.
🔥
Interview Gold: How to Find File Size in CThe fseek(SEEK_END) + ftell() + rewind() trio is the standard C idiom for getting a file's size, since C has no dedicated file-size function in the standard library. Interviewers love asking this — and the follow-up is whether this works on binary vs text files (it's only guaranteed accurate for binary mode).
AspectText Mode ('r', 'w', 'a')Binary Mode ('rb', 'wb', 'ab')
Newline handlingTranslates '\n' ↔ '\r\n' on WindowsNo translation — raw bytes only
Human readableYes — open in any text editorNo — garbage in a text editor
Portability across OSesSafe for text dataSafe for binary data; byte order varies by CPU
Portability across compilersFineRisk of struct padding differences between builds
Use caseConfig files, logs, CSV, INIImages, audio, structs, serialised objects
fseek reliabilityUnreliable for byte offsetsFully reliable — offset = exact byte position
Performance on large dataSlower (conversion overhead)Faster (memcpy-style direct read/write)

🎯 Key Takeaways

  • FILE* is a buffered abstraction over OS file descriptors — fclose() isn't optional, it's the flush trigger that ensures your last writes actually hit the disk.
  • Always check fopen()'s return value for NULL and call perror() immediately — silent NULL dereferences turn a fixable I/O error into an untraceable crash.
  • Use 'rb'/'wb' for any non-text data — on Windows, text mode silently mutates bytes in your structs by translating newline sequences, which corrupts binary reads.
  • fseek + fixed-size structs = O(1) random access — this is the pattern behind flat-file databases and most embedded data storage, and it's built entirely on sizeof arithmetic.

⚠ Common Mistakes to Avoid

  • Mistake 1: Not checking if fopen() returned NULL — Symptom: immediate segfault when the program tries to use the NULL FILE* pointer, with a crash message like 'Segmentation fault (core dumped)' and no useful context — Fix: always wrap fopen() in a NULL check followed by perror() and a clean exit; treating file I/O as infallible is the most common C file-handling bug.
  • Mistake 2: Forgetting fclose() or only calling it on the happy path — Symptom: the last few kilobytes of written data never appear in the file, or appear only after the process exits, because the C runtime buffers writes internally and flushes on close — Fix: use a pattern where fclose() is called in every exit path (including error exits), or structure code so a single cleanup block always runs before returning; in complex functions, consider goto cleanup: at the end.
  • Mistake 3: Using 'w' mode when 'a' or 'r+' was intended — Symptom: the file exists and has data, but after running the program it's empty or contains only the new data; the original content is silently gone — Fix: before choosing a mode, ask yourself three questions: 'Does the file need to exist already?' (r), 'Do I want to preserve existing content?' (a or r+), 'Am I okay starting fresh?' (w); when in doubt, open with 'r' first to verify existence before writing.

Interview Questions on This Topic

  • QWhat is the difference between fread/fwrite and read/write system calls, and when would you choose one over the other?
  • QIf you open a file with fopen in 'w' mode and immediately call fclose without writing anything, what happens to an existing file at that path?
  • QHow would you safely update a single record in the middle of a binary file without loading the entire file into memory — walk me through the exact sequence of fseek and fwrite calls?

Frequently Asked Questions

What is the difference between text mode and binary mode in C file handling?

In text mode, the C runtime translates newline characters between the platform's native format and the C standard ' ' — on Windows this means '\r ' on disk becomes ' ' in memory and vice versa. In binary mode no translation happens, so what you write is exactly what appears on disk byte-for-byte. Always use binary mode ('rb', 'wb') when reading or writing structs, images, or any non-text data to avoid silent byte corruption.

Why does my file appear empty even though I wrote data to it in C?

The most likely cause is that you never called fclose() — or called it only on the success path. The C runtime buffers writes in memory and only flushes them to disk when the buffer fills up or the file is closed. Without fclose(), the last (and sometimes only) chunk of data never makes it to disk. You can also force a flush without closing by calling fflush(your_file_pointer).

Can I read and write the same file at the same time in C?

Yes, using the 'r+b' or 'w+b' mode, which opens a file for both reading and writing. The key rule is that you must call fseek() or fflush() when switching between a read and a write operation on the same file — the C standard requires this to avoid undefined behaviour caused by the internal buffer state being inconsistent between the two directions.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousMemory Management in C — malloc calloc freeNext →Introduction to C++
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged