C file I/O uses FILE* pointers for buffered access to disk files
fopen() opens a file; always check for NULL before using the pointer
fread() and fwrite() handle raw binary data with fixed-size records
fseek() provides O(1) random access when combined with sizeof arithmetic
fclose() flushes buffers and releases the file descriptor — skipping it loses data
Text mode ('r','w') and binary mode ('rb','wb') differ in newline translation on Windows
Plain-English First
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.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <stdlib.h> // forexit()
intmain(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 forNULL 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. Skipthis 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");
return0;
}
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 File
Opening 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.
Production Insight
Forgetting the NULL check on fopen leads to segfault when the code dereferences the FILE*.
In production, a missing config file or full disk silently crashes the process.
Rule: always pair fopen with perror() and exit — not printf("Error").
Key Takeaway
fopen + fclose is a resource lifecycle, like malloc/free.
Never code a path where fopen succeeds but fclose is skipped.
Last line: 'w' mode is destructive — verify intent before using it.
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.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#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
voidwrite_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
voidread_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);
}
intmain(void) {
write_scores();
read_scores();
return0;
}
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 Behind
fgets 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.
Production Insight
fscanf silently misparses lines with spaces or commas — it's a frequent source of config file corruption.
In production, a single malformed line can cause the entire file to be misread.
Rule: use fgets + sscanf or strtok for all structured text parsing.
Key Takeaway
fgets is safer than fscanf for reading lines.
Always strip the trailing newline after fgets.
Last line: never trust the file format — parse defensively.
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.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#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;
voidsave_employees(constEmployee *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);
}
voidload_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);
}
intmain(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);
return0;
}
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 Files
The 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.
Production Insight
A binary file written on one system may be unreadable on another due to padding or endianness.
A dev team using different compilers for unit tests vs production can silently corrupt data.
Rule: always run a round-trip test (write then read) on every target platform.
Key Takeaway
Binary I/O is fast but fragile — bytes are bytes.
Pack structs when portability matters, but test for performance impact.
Last line: always use 'b' in mode for binary data — especially on Windows.
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.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#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
longget_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
voidupdate_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");
}
intmain(void) {
// Update the salary of the second employee (index 1 = JamesOkafor)
update_salary(1, 95000.00);
return0;
}
Output
File size: 348 bytes | Records: 3
Updating James Okafor: $87000.00 -> $95000.00
Update complete.
Interview Gold: How to Find File Size in C
The 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).
Production Insight
fseek on text files on Windows produces unreliable offsets due to newline translation.
A record update in a text file may accidentally overwrite part of another record.
Rule: use binary mode for any random access that relies on byte offsets.
Key Takeaway
fseek + sizeof = O(1) access — the foundation of flat-file databases.
Always use binary mode for random access.
Last line: ftell after fseek(SEEK_END) is the standard file size idiom.
Error Handling and Safe File I/O Patterns for Production Code
Production file I/O isn't just about reading and writing — it's about handling failures gracefully. fread and fwrite return the number of items actually transferred, which may be less than the count you asked for. You must check these returns and then call ferror() to distinguish between an end-of-file condition and a genuine I/O error. feof() tests for end-of-file but is only valid after a failed read — don't use it to predict future reads.
Buffering is another hidden concern. The C runtime buffers output internally and only flushes when the buffer fills or fclose is called. setvbuf() lets you control the buffer size or disable buffering entirely, which is critical for writing logs in crash-prone environments. For production wrappers, use a helper function that combines fopen, error checking, and setvbuf into a single call — this reduces the surface area for mistakes.
Finally, remember that the file system is a shared resource. File locks (via flock or fcntl) become necessary when multiple processes write to the same file. C's standard I/O library doesn't provide locking, so you must use OS-specific calls or external coordination.
io_thecodeforge_safe_file.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
// Production-safe fopen with buffer control and explicit error reporting
// ReturnsNULL on failure after printing a meaningful error message.
// If setbuf_size > 0, uses a custom buffer; if0, unbuffered.
FILE *io_thecodeforge_file_open(constchar *path, constchar *mode, size_t setbuf_size) {
FILE *fp = fopen(path, mode);
if (fp == NULL) {
fprintf(stderr, "[io_thecodeforge] ERROR: fopen(\"%s\", \"%s\") failed: %s\n",
path, mode, strerror(errno));
returnNULL;
}
if (setbuf_size > 0) {
char *buf = (char *)malloc(setbuf_size);
if (buf == NULL) {
// setvbuf with NULL buffer is allowed — just use defaultfprintf(stderr, "[io_thecodeforge] WARNING: malloc(%zu) failed, using default buffer\n", setbuf_size);
} else {
if (setvbuf(fp, buf, _IOFBF, setbuf_size) != 0) {
fprintf(stderr, "[io_thecodeforge] WARNING: setvbuf failed, using default buffer\n");
free(buf);
}
}
} elseif (setbuf_size == 0) {
setvbuf(fp, NULL, _IONBF, 0); // unbuffered
}
return fp;
}
// Safely write exactly 'count' items of 'size' bytes, retrying on short writes
intio_thecodeforge_file_write(FILE *fp, constvoid *data, size_t size, size_t count) {
size_t total_written = 0;
const unsigned char *ptr = (const unsigned char *)data;
size_t total_bytes = size * count;
while (total_written < total_bytes) {
size_t written = fwrite(ptr + total_written, 1, total_bytes - total_written, fp);
if (written == 0) {
if (ferror(fp)) {
fprintf(stderr, "[io_thecodeforge] ERROR: fwrite failed: %s\n", strerror(errno));
return -1;
}
// Shouldn't happen with blocking I/O, but guard against infinite loop
break;
}
total_written += written;
}
return (total_written == total_bytes) ? 0 : -1;
}
// Usage example
intmain(void) {
FILE *log = io_thecodeforge_file_open("production.log", "a", 8192);
if (log == NULL) {
exit(EXIT_FAILURE);
}
constchar *msg = "Service started successfully\n";
if (io_thecodeforge_file_write(log, msg, 1, strlen(msg)) != 0) {
fprintf(stderr, "Failed to write startup message\n");
}
fclose(log);
return0;
}
Output
No output (success) — but check production.log for appended line.
Mental Model: File Operations Are System Calls, Not Magic
fopen can fail: file doesn't exist, permissions, full disk, network mount down.
fwrite can fail partway through: disk full, quota exceeded, interrupted.
fclose is the only way to guarantee data has been flushed to disk.
Multiple processes writing to one file need external locking — C's stdio doesn't provide it.
Error recovery should be part of the design, not an afterthought.
Production Insight
A crash mid-write without fclose leaves the file in an unknown state — partial writes are common.
Production logging libraries use unbuffered writes to ensure every line is immediately written.
Rule: use setvbuf(_IONBF) for logs, or call fflush after every critical write.
Key Takeaway
Wrap fopen/fread/fwrite/fclose in safe helpers to centralise error handling.
Always check return values — partial writes are real.
Last line: in production, unbuffered writes or explicit fflush prevent data loss.
● Production incidentPOST-MORTEMseverity: high
Production Log Truncation Caused by w Mode in File Rotation
Symptom
After a routine deployment restart, the monitoring dashboard shows 'No data available' for the past 2 hours. The log file daemon.log exists but contains only the header written after the restart.
Assumption
The log file would be appended or renamed on restart. The developer assumed fopen with 'w' would create a new file if it didn't exist, but never considered the case where the file already existed.
Root cause
fopen("daemon.log", "w") opens the file for writing and truncates any existing content to zero bytes. The deployment script restarted the daemon without rotating the log file, causing the entire previous log to be lost.
Fix
Change the mode from 'w' to 'a' (append) to preserve existing data, or implement log rotation before restart. Also add a check to exit if the file already exists when expected to be new.
Key lesson
Always verify the fopen mode matches the intended behaviour — 'w' is destructive.
Implement log rotation or appending as a deployment step to avoid data loss.
Add a startup check that logs a warning if the file already exists when opening in 'w' mode.
Production debug guideQuick symptom-to-action mapping for common file handling failures4 entries
Symptom · 01
fopen returns NULL despite file existing
→
Fix
Check file permissions (stat command on Linux), verify file path is absolute/relative correctly, check disk space with df -h, and use perror() to print the system error.
Symptom · 02
fread returns fewer items than requested
→
Fix
Check ferror() to distinguish between EOF and error. If ferror is 0, it's EOF – partial read. If ferror is non-zero, I/O error occurred. Use perror() to see the error.
Symptom · 03
Written data never appears in file until program exits
→
Fix
Add fflush() after each write if immediate persistence is required, or ensure fclose() is called. Check if buffer size is large (setvbuf can help).
Symptom · 04
Binary file read shows corrupted data on different machine
→
Fix
Check endianness and struct packing. Use fixed-size types (int32_t, etc.) and pack the struct with __attribute__((packed)) or #pragma pack(1).
★ C File I/O Quick Debug Cheat SheetWhen file operations fail, here's the exact commands and fixes to diagnose the problem.
File not found despite correct path−
Immediate action
Check current working directory with getcwd() or print realpath("app_log.txt")
Commands
ls -la app_log.txt (check file existence and permissions)
perror("fopen") — prints the system error message
Fix now
Use absolute path or ensure the file is in the directory where the process runs.
Last write never reaches disk+
Immediate action
Call fflush(your_file) immediately after the write
Commands
Check fclose() return value – non-zero means flush failed
strace -e trace=write,close ./your_program (trace system calls)
Fix now
Ensure every exit path calls fclose(). Use a cleanup macro or goto cleanup pattern.
fscanf fails to parse structured text+
Immediate action
Use fgets() instead to read whole lines, then parse with sscanf or strtok
Commands
Print the raw line with printf("Raw line: '%s'", buffer) before parsing
Check for mismatched format specifiers (e.g., %s expects char*, not int*)
Fix now
Replace fscanf with fgets + careful tokenisation.
Binary file size doesn't match expected number of records+
Immediate action
Check sizeof of struct using printf("%zu", sizeof(Employee))
Commands
ls -l employees.dat; stat employees.dat
xxd employees.dat | head -20 (hex dump to inspect raw bytes)
Fix now
Ensure struct is packed and use fixed-size types. Verify fwrite count matches records written.
Text Mode vs Binary Mode in C File I/O
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
1
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.
2
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.
3
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.
4
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
3 patterns
×
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.
×
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.
×
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 PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the difference between fread/fwrite and read/write system calls,...
Q02JUNIOR
If you open a file with fopen in 'w' mode and immediately call fclose wi...
Q03SENIOR
How would you safely update a single record in the middle of a binary fi...
Q01 of 03SENIOR
What is the difference between fread/fwrite and read/write system calls, and when would you choose one over the other?
ANSWER
fread and fwrite are part of the C standard library and operate on FILE* streams. They provide buffered I/O — the runtime reads/writes chunks internally, reducing the number of expensive system calls. read and write are OS system calls that work on raw file descriptors. Use fread/fwrite for most application-level code because they're portable and efficient. Use read/write when you need low-level control (e.g., non-blocking I/O, managing file descriptors directly, or working with sockets or pipes).
Q02 of 03JUNIOR
If 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?
ANSWER
The existing file is truncated to zero bytes. The 'w' mode opens the file for writing and clears its contents at the moment fopen() succeeds — before any data is written. Even if you never write and close immediately, the original data is lost. This is a common source of data loss and is why many production systems use 'a' (append) or implement file rotation before opening in 'w' mode.
Q03 of 03SENIOR
How 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?
ANSWER
First, open the file in read-write binary mode with fopen("file.dat", "r+b"). 1) Use fseek(file, record_index sizeof(RecordType), SEEK_SET) to jump to the start of the target record. 2) Read the existing record with fread(&record, sizeof(RecordType), 1, file) — this positions the file pointer after that record. 3) Modify the record in memory. 4) Seek back to the same offset: fseek(file, record_index sizeof(RecordType), SEEK_SET). 5) Write the updated record with fwrite(&record, sizeof(RecordType), 1, file). 6) Call fclose(file). This pattern updates only one record regardless of file size, and works because fixed-size records allow O(1) random access.
01
What is the difference between fread/fwrite and read/write system calls, and when would you choose one over the other?
SENIOR
02
If 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?
JUNIOR
03
How 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?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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).
Was this helpful?
03
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.
Was this helpful?
04
How do I truncate a file to a specific length without rewriting it?
The C standard library does not provide a portable truncate function. On POSIX systems (Linux, macOS), use truncate() or ftruncate() from <unistd.h>. On Windows, use _chsize() or SetEndOfFile(). For portable code, you can simulate truncation by reading up to the desired length and writing to a new file, then replacing the original.