File Handling in C Explained — fopen, fread, fwrite and Real-World Patterns
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.
#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; }
[Contents of app_log.txt]
=== Application Log Started ===
Status: Initialised successfully
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.
#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; }
--- Leaderboard ---
#1 Alice 4200 pts
#2 Bob 3875 pts
#3 Charlie 5100 pts
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.
#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; }
--- 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
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.
#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; }
Updating James Okafor: $87000.00 -> $95000.00
Update complete.
| Aspect | Text Mode ('r', 'w', 'a') | Binary Mode ('rb', 'wb', 'ab') |
|---|---|---|
| Newline handling | Translates '\n' ↔ '\r\n' on Windows | No translation — raw bytes only |
| Human readable | Yes — open in any text editor | No — garbage in a text editor |
| Portability across OSes | Safe for text data | Safe for binary data; byte order varies by CPU |
| Portability across compilers | Fine | Risk of struct padding differences between builds |
| Use case | Config files, logs, CSV, INI | Images, audio, structs, serialised objects |
| fseek reliability | Unreliable for byte offsets | Fully reliable — offset = exact byte position |
| Performance on large data | Slower (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.
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.