File Handling in C — 'w' Mode Truncated Our Logs
A daemon restart with fopen("daemon.log", "w") wiped 2 hours of logs.
20+ years shipping performance-critical C and C++ systems. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
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.
What File Handling in C Actually Does — and Why 'w' Mode Destroys Your Data
File handling in C is the set of standard library functions (fopen, fclose, fprintf, fscanf, etc.) that allow a program to read from and write to persistent storage via a FILE* stream. The core mechanic is that fopen returns a pointer to a FILE structure that holds the stream state, buffer, and file descriptor; you then use that pointer for all subsequent I/O operations until fclose flushes the buffer and releases the resource.
In practice, the mode string you pass to fopen determines the initial state of the file pointer and whether the file is truncated or appended. 'w' opens the file for writing, creates it if it doesn't exist, and — critically — truncates the file to zero length immediately upon success. This happens before your first fprintf call. There is no undo. The file's previous content is gone the moment fopen returns. 'a' (append) does not truncate; 'r+' opens for read/write without truncation but requires the file to exist.
Use 'w' only when you intend to replace the entire file content from scratch — for example, writing a fresh configuration file or a new log file per run. Never use 'w' for appending logs, updating records in place, or any scenario where existing data must survive. In production systems, a single 'w' where 'a' was intended can silently erase hours of critical log data, making this one of the most common and costly C I/O mistakes.
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.
perror() and exit — not printf("Error").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.
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.
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.
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).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.
- 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.
File Opening Modes — The Silent Data Killer Is ios::trunc
Every file open call carries a mode. No mode means defaults. And defaults will destroy your data without warning.
When you open an ofstream without specifying a mode, C++ applies ios::out | ios::trunc by default. That trunc flag erases the file's existing content the moment the stream constructs. One misconfigured constructor, and your customer's configuration file is gone.
ios::app is your friend when you need to append. ios::ate positions the cursor at the end but still lets you seek backward. ios::binary turns off newline translations that corrupt binary data on Windows.
Always be explicit. Write std::ofstream logfile("transactions.log", std::ios::app) and never rely on implicit behavior. Mode flags compose with bitwise OR — that's not a coincidence, it's a design choice that gives you fine-grained control. Use it.
File Open Failure — Checking Is Cheap, Silently Corrupting Is Not
A file open failure is not an exception. It's a silent state change in the stream object. If you don't check, you'll read garbage, write to nowhere, and wonder why your report is empty.
The method or the boolean conversion operator on the stream will tell you the truth. Use one of them. Every time. Before reading or writing a single byte.is_open()
Common failure causes: file doesn't exist for ios::in, permissions insufficient for write, path is a directory, or the disk is full. These aren't edge cases — they happen in production daily.
Pattern: open, check, bail or handle. Never assume. If opening a config file fails, fall back to defaults, log the error, and continue. If opening a database file fails, stop and scream. The response depends on criticality, but the check is mandatory.
if (file)) works on all stream states, not just open. But is_open() is more precise — it only fails if the open call itself failed. Use it for open checks.is_open() or the stream's boolean operator before any read or write.Asynchronous I/O with io_uring on Linux
Asynchronous I/O (AIO) allows your program to perform file operations without blocking the calling thread. On Linux, io_uring is a modern, high-performance interface for asynchronous I/O that reduces system call overhead and enables efficient submission and completion of I/O requests. Unlike traditional blocking I/O or even POSIX AIO, io_uring uses shared ring buffers between user space and kernel, minimizing context switches.
To use io_uring, you need the liburing library. Here's a simple example that reads from a file asynchronously:
```c #include
int main() { struct io_uring ring; io_uring_queue_init(32, &ring, 0);
int fd = open("example.txt", O_RDONLY); if (fd < 0) { perror("open"); exit(1); }
char buf[4096]; struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0); io_uring_submit(&ring);
struct io_uring_cqe cqe; io_uring_wait_cqe(&ring, &cqe); if (cqe->res < 0) { fprintf(stderr, "Async read failed: %s ", strerror(-cqe->res)); } else { printf("Read %d bytes: %.s ", cqe->res, cqe->res, buf); } io_uring_cqe_seen(&ring, cqe);
close(fd); io_uring_queue_exit(&ring); return 0; } ```
This code initializes an io_uring instance, prepares a read operation, submits it, and waits for completion. The result is stored in the completion queue entry. Error handling is crucial: check cqe->res for negative values indicating errors.
io_uring supports many operations: read, write, open, close, fsync, and more. It's ideal for high-throughput servers, databases, and any application that needs to overlap I/O with computation. However, it's Linux-specific and requires a recent kernel (5.1+).
Memory-Mapped Files with mmap
Memory-mapped files allow you to map a file's contents directly into the process's address space, enabling file I/O via memory operations. This can be more efficient than traditional read/write for large files because it avoids intermediate buffering and system call overhead. The mmap function in POSIX systems creates a mapping.
Here's an example that maps a file and modifies it in place:
```c #include
int main() { int fd = open("data.bin", O_RDWR); if (fd < 0) { perror("open"); exit(1); }
struct stat sb; if (fstat(fd, &sb) == -1) { perror("fstat"); exit(1); } size_t length = sb.st_size;
char *map = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (map == MAP_FAILED) { perror("mmap"); exit(1); }
// Modify the first byte map[0] = 'A';
// Ensure changes are written back msync(map, length, MS_SYNC);
munmap(map, length); close(fd); return 0; } ```
PROT_READ | PROT_WRITEallows reading and writing.MAP_SHAREDmeans changes are visible to other processes and written back to file.msyncflushes changes to disk (optional but recommended for durability).munmapunmaps the region.
Memory-mapped files are excellent for random access, large files, and sharing data between processes. However, they have limitations: file size is limited by address space, and mapping very large files may cause page faults. Also, mmap is not suitable for files that grow dynamically (use ftruncate to extend).
Error handling: always check return values of mmap, msync, and munmap. Use MAP_FAILED to detect mapping failure.
File System Operations with
The header provides functions for directory traversal and file system operations in POSIX systems. It allows you to list directory contents, get file information, and navigate the file system tree. This is essential for building tools like file managers, backup utilities, or any application that needs to scan directories.
Here's an example that lists all entries in a directory:
```c #include
int main() { DIR *dir = opendir("."); if (dir == NULL) { perror("opendir"); exit(1); }
struct dirent *entry; while ((entry = readdir(dir)) != NULL) { printf("%s ", entry->d_name); }
closedir(dir); return 0; } ```
To get file metadata like size and permissions, use stat or lstat:
``c struct stat file_stat; if (stat(entry->d_name, &file_stat) == -1) { perror("stat"); } else { printf("Size: %ld bytes ", file_stat.st_size); } ``
opendir: opens a directory stream.readdir: reads next directory entry (returnsstruct dirent *).closedir: closes the stream.rewinddir: resets position to beginning.scandir: filters and sorts entries (not POSIX but common).
Error handling: always check opendir for NULL, and readdir returns NULL on end or error (use errno to distinguish).
This API is thread-safe per directory stream, but not reentrant for readdir (use readdir_r for reentrancy).
For recursive directory traversal, implement a function that calls opendir on subdirectories. Be careful with symbolic links to avoid infinite loops.
Production Log Truncation Caused by w Mode in File Rotation
- 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.
perror() to print the system error.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.fflush() after each write if immediate persistence is required, or ensure fclose() is called. Check if buffer size is large (setvbuf can help).ls -la app_log.txt (check file existence and permissions)perror("fopen") — prints the system error message| File | Command / Code | Purpose |
|---|---|---|
| file_lifecycle.c | int main(void) { | Opening and Closing Files |
| text_file_rw.c | void write_scores(void) { | Reading and Writing Text Files |
| binary_file_rw.c | typedef struct { | Binary File I/O |
| random_access.c | typedef struct { | Random Access and File Positioning |
| io_thecodeforge_safe_file.c | FILE *io_thecodeforge_file_open(const char *path, const char *mode, size_t setbu... | Error Handling and Safe File I/O Patterns for Production Cod |
| FileOpenModes.cpp | int main() { | File Opening Modes |
| SafeFileOpen.cpp | int main() { | File Open Failure |
| io_uring_example.c | int main() { | Asynchronous I/O with io_uring on Linux |
| mmap_example.c | int main() { | Memory-Mapped Files with mmap |
| dirent_example.c | int main() { | File System Operations with |
Key takeaways
fopen()'s return value for NULL and call perror() immediatelyInterview Questions on This Topic
What is the difference between fread/fwrite and read/write system calls, and when would you choose one over the other?
Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Everything here is grounded in real deployments.
That's C Basics. Mark it forged?
9 min read · try the examples if you haven't