File handling is how a C program reads and writes data that outlives the
program itself - config files, logs, saved game state, CSVs, binary
formats, anything that needs to exist before the program starts or after
it ends. C's file handling comes in two flavors: the portable, buffered
standard I/O library (<stdio.h> - FILE *, fopen, fread, etc.)
and the lower-level, OS-specific POSIX I/O (<unistd.h>/<fcntl.h> -
open, read, write). This guide focuses mainly on standard I/O, since
that's what's portable across every C compiler and OS, with POSIX I/O
covered where it matters.
0. Why it's needed, why industry leans on it, and why your syllabus barely scratches it
Why it's needed at all
Every variable in a running C program lives in RAM, and RAM is wiped the moment the program exits. The only way for a program to remember anything between runs - or to exchange data with another program, another machine, or a human opening a file later - is to write that data somewhere durable: a file. File handling is the interface between your program's in-memory world and everything outside it.
Why real projects lean on it so heavily
- Persistence. Any program with settings, save data, a database, or logs needs files. There's no other mechanism in standard C for data to survive past the program's own lifetime.
- Interoperability. File formats (JSON, CSV, images, executables, archives) are how independently-written programs exchange data without either one knowing anything about the other's internals - file I/O is the parsing/serialization boundary.
- Logging and diagnostics. Production systems write logs to files constantly - it's often the only way to debug something that happened on a server you don't have live access to.
- Working with data larger than memory. Streaming a file in chunks (rather than loading it all at once) is how programs process files far bigger than available RAM - databases, video encoders, and log processors all depend on this.
- Talking to the OS and hardware. On Unix-like systems, "everything is
a file" - devices, pipes, sockets, and
/procentries are all accessed through the sameopen/read/writecalls used for ordinary files. Understanding file I/O is a prerequisite for understanding a huge part of systems programming.
Why your syllabus (probably) stopped early
- Courses usually cover
fopen,fprintf/fscanf, andfcloseand call it done - enough to pass an assignment that reads a few numbers from a file, but nowhere near what's needed to handle binary formats, large files, or real error conditions. - Binary I/O and struct serialization are skipped because they require
padding/alignment knowledge first (see the structs guide) - you can't
safely
fwritea struct to a file without understanding what's actually in its memory layout. - Buffering, flushing, and text/binary mode differences are subtle and platform-dependent (the classic Windows CRLF-translation gotcha, section 13) - exactly the kind of "it works on my machine" topic that's hard to teach consistently in a classroom, so it's often left out entirely.
- POSIX-level I/O (
open/read/write, directory listing, file locking) is OS-specific, not part of standard C at all, so a general "C programming" course has a legitimate reason not to teach it - even though it's exactly what real systems code uses constantly.
Once you've actually shipped something that reads a multi-gigabyte log
file a chunk at a time, or serialized a struct straight to disk and read
it back byte-for-byte, file I/O stops feeling like "the fopen chapter"
and starts feeling like one of the most directly useful things C ever
taught you.
1. Opening and closing a file
Every standard I/O operation happens through a FILE * handle, obtained
from fopen and released with fclose.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("example.txt", "w");
if (fp == NULL) {
perror("fopen failed");
return 1;
}
fprintf(fp, "Hello, file!\n");
fclose(fp); // always close what you open
return 0;
}
fclose flushes any buffered data to disk and releases the OS file
handle. Forgetting to call it (section 24) is one of the most common file
handling bugs.
2. File modes
The second argument to fopen controls whether you're reading, writing,
or appending, and whether existing content is kept or wiped.
#include <stdio.h>
int main(void) {
FILE *fp;
fp = fopen("data.txt", "r"); // read: file MUST already exist
if (fp) fclose(fp);
fp = fopen("data.txt", "w"); // write: creates the file, or TRUNCATES
// (erases) it if it already exists
if (fp) fclose(fp);
fp = fopen("data.txt", "a"); // append: creates if missing, writes
// are always added at the end
if (fp) fclose(fp);
fp = fopen("data.txt", "r+"); // read AND write, file must exist
if (fp) fclose(fp);
fp = fopen("data.txt", "w+"); // read and write, TRUNCATES existing file
if (fp) fclose(fp);
fp = fopen("data.bin", "rb"); // "b" = binary mode, no text translation
if (fp) fclose(fp);
return 0;
}
| Mode | File must exist? | Truncates? | Can read? | Can write? |
|---|---|---|---|---|
"r" | yes | no | yes | no |
"w" | no (created) | yes | no | yes |
"a" | no (created) | no | no | yes (always at end) |
"r+" | yes | no | yes | yes |
"w+" | no (created) | yes | yes | yes |
"a+" | no (created) | no | yes | yes (writes always at end) |
Add b to any mode ("rb", "wb", "ab+", etc.) for binary mode
(section 13).
3. Always check whether fopen succeeded
fopen returns NULL on failure - file doesn't exist, no permission,
disk full, path invalid, etc. Using a NULL FILE * is undefined
behavior, so this check is not optional.
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void) {
FILE *fp = fopen("does_not_exist.txt", "r");
if (fp == NULL) {
// perror prints your message plus the OS's reason, using errno
perror("fopen failed");
// equivalent manually:
fprintf(stderr, "fopen failed: %s\n", strerror(errno));
return 1;
}
fclose(fp);
return 0;
}
4. Writing text to a file
fprintf, fputs, and fputc are the text-writing equivalents of
printf, puts, and putchar, just aimed at a specific FILE * instead
of stdout.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("output.txt", "w");
if (!fp) { perror("fopen"); return 1; }
fprintf(fp, "Score: %d\n", 95); // formatted output, like printf
fputs("A plain line of text\n", fp); // a whole string, no formatting
fputc('X', fp); // a single character
fputc('\n', fp);
fclose(fp);
return 0;
}
5. Reading text from a file
fgets (a whole line), fscanf (formatted input), and fgetc (one
character) are the standard ways to read text.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("output.txt", "r");
if (!fp) { perror("fopen"); return 1; }
char line[256];
if (fgets(line, sizeof(line), fp) != NULL) {
printf("First line: %s", line); // fgets keeps the trailing '\n'
}
int score;
// (re-open or seek as needed for a real program; kept simple here)
fclose(fp);
fp = fopen("output.txt", "r");
if (fp && fscanf(fp, "Score: %d", &score) == 1) {
printf("Parsed score: %d\n", score);
}
if (fp) fclose(fp);
return 0;
}
fgets is strongly preferred over gets (which was removed from the
C standard entirely in C11) because fgets takes a buffer size and can't
overflow it; gets had no way to know the buffer size and was a classic
source of buffer-overflow vulnerabilities.
6. Reading a file line by line
The standard pattern for processing a whole text file: loop fgets until
it returns NULL (which means EOF or an error).
#include <stdio.h>
int main(void) {
FILE *fp = fopen("output.txt", "r");
if (!fp) { perror("fopen"); return 1; }
char line[256];
int lineNumber = 1;
while (fgets(line, sizeof(line), fp) != NULL) {
printf("%d: %s", lineNumber, line);
lineNumber++;
}
fclose(fp);
return 0;
}
If a line is longer than your buffer, fgets stops at the buffer limit
without a trailing \n on that call, and the rest of the line comes
through on the next fgets call - worth knowing if you're checking for
a newline character to confirm you got a whole line.
7. Detecting end-of-file with feof
Most of the time you don't need feof at all - checking the return value
of fgets/fscanf/fread (NULL, not 1, short count) already tells
you when to stop. feof is for confirming why a read loop ended: true
end-of-file, versus an actual error.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("output.txt", "r");
if (!fp) { perror("fopen"); return 1; }
int c;
while ((c = fgetc(fp)) != EOF) {
putchar(c);
}
if (feof(fp)) {
printf("\n-- reached end of file normally --\n");
} else if (ferror(fp)) {
printf("\n-- a read error occurred --\n");
}
fclose(fp);
return 0;
}
A common bug: checking feof(fp) as the loop condition itself
(while (!feof(fp))), which processes one extra bogus iteration after
the real data ends. Always let the read call's own return value drive the
loop, as shown above.
8. Binary I/O: fwrite and fread
For non-text data - raw bytes, structs, arrays - fwrite/fread copy
exact bytes with no text interpretation or translation at all.
#include <stdio.h>
int main(void) {
int numbers[5] = {10, 20, 30, 40, 50};
FILE *fp = fopen("numbers.bin", "wb");
if (!fp) { perror("fopen"); return 1; }
fwrite(numbers, sizeof(int), 5, fp); // (pointer, size of ONE item, count, file)
fclose(fp);
int loaded[5] = {0};
fp = fopen("numbers.bin", "rb");
if (!fp) { perror("fopen"); return 1; }
size_t itemsRead = fread(loaded, sizeof(int), 5, fp);
fclose(fp);
printf("read %zu items: ", itemsRead);
for (int i = 0; i < 5; i++) printf("%d ", loaded[i]);
printf("\n");
return 0;
}
Always check fread's return value (the number of items actually read)
- it can be less than requested if the file was shorter than expected.
9. Writing and reading whole structs
Binary I/O is most useful for saving structured records directly, without manually formatting/parsing text - but struct padding (see the structs guide) means this isn't automatically portable across compilers/platforms unless you control the layout.
#include <stdio.h>
#pragma pack(push, 1) // avoid padding so the on-disk layout is predictable
typedef struct {
int id;
char name[32];
float score;
} Record;
#pragma pack(pop)
int main(void) {
Record r1 = {1, "Alice", 92.5f};
Record r2 = {2, "Bob", 81.0f};
FILE *fp = fopen("records.bin", "wb");
if (!fp) { perror("fopen"); return 1; }
fwrite(&r1, sizeof(Record), 1, fp);
fwrite(&r2, sizeof(Record), 1, fp);
fclose(fp);
Record loaded[2];
fp = fopen("records.bin", "rb");
if (!fp) { perror("fopen"); return 1; }
size_t count = fread(loaded, sizeof(Record), 2, fp);
fclose(fp);
for (size_t i = 0; i < count; i++) {
printf("%d: %s (%.1f)\n", loaded[i].id, loaded[i].name, loaded[i].score);
}
return 0;
}
For a format that must be read by a different compiler, platform, or program written in a different language, don't rely on raw struct layout at all - write an explicit, documented binary format (fixed field sizes, known byte order) instead.
10. Moving around in a file: fseek, ftell, rewind
fseek moves the file position indicator; ftell reports the current
position; rewind resets it to the start. These let you jump around a
file instead of only reading it start-to-finish.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("output.txt", "r");
if (!fp) { perror("fopen"); return 1; }
fseek(fp, 5, SEEK_SET); // move to byte 5 from the START of the file
int c = fgetc(fp);
printf("byte at offset 5: %c\n", c);
fseek(fp, -1, SEEK_CUR); // move back 1 byte from the CURRENT position
printf("current offset: %ld\n", ftell(fp));
fseek(fp, 0, SEEK_END); // move to the END of the file
printf("file size (via ftell at EOF): %ld bytes\n", ftell(fp));
rewind(fp); // equivalent to fseek(fp, 0, SEEK_SET), also clears error flags
printf("back to start, offset: %ld\n", ftell(fp));
fclose(fp);
return 0;
}
SEEK_SET (from the start), SEEK_CUR (from the current position), and
SEEK_END (from the end) are the three anchor points fseek supports.
11. Getting a file's size
There's no direct filesize() function in standard C - the common
portable idiom is seeking to the end and reading the offset there.
#include <stdio.h>
long getFileSize(const char *path) {
FILE *fp = fopen(path, "rb");
if (!fp) return -1;
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
fclose(fp);
return size;
}
int main(void) {
long size = getFileSize("output.txt");
if (size >= 0) {
printf("file size: %ld bytes\n", size);
} else {
perror("could not get file size");
}
return 0;
}
For very large files (bigger than what a long can represent on some
platforms), POSIX's stat() with st_size (a wider type) is more robust
than this fseek/ftell trick.
12. Buffering and flushing
By default, stdio buffers your writes for performance instead of hitting
the disk on every single call. fflush forces buffered data out
immediately - important before a crash-prone operation, or when writing
to stdout right before reading from stdin in the same terminal.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("log.txt", "w");
if (!fp) { perror("fopen"); return 1; }
fprintf(fp, "This might sit in a buffer for a while...\n");
fflush(fp); // force it to disk NOW, e.g. before a risky operation
printf("Enter your name: ");
fflush(stdout); // ensure the prompt is visible before scanf blocks waiting for input
// (stdout is often line-buffered when attached to a terminal, so this
// particular fflush may be unnecessary there -- but NOT when stdout is
// redirected to a file or a pipe, where it's fully buffered instead)
fclose(fp);
return 0;
}
setvbuf lets you change a stream's buffering mode entirely (fully
buffered, line buffered, or unbuffered) if the defaults don't fit your
use case.
13. Text mode vs. binary mode
On Unix-like systems, text and binary mode behave identically. On
Windows, text mode silently translates \n to \r\n on write, and
\r\n back to \n on read. This is invisible for plain text files but
corrupts binary data if you forget the "b" flag.
#include <stdio.h>
int main(void) {
/* WRONG on Windows for binary data: text mode may translate bytes
that happen to match the \n / \r\n patterns, corrupting the file. */
FILE *bad = fopen("image.dat", "w"); // no 'b' -- risky for binary content
/* RIGHT: always use binary mode for non-text data, on every platform,
even though it only matters on Windows -- it's a harmless no-op
elsewhere, so there's no reason not to always include it. */
FILE *good = fopen("image.dat", "wb");
if (bad) fclose(bad);
if (good) fclose(good);
return 0;
}
Rule of thumb: use "b" for anything that isn't human-readable text -
images, compiled binaries, structs, serialized data - regardless of what
platform you're currently developing on.
14. Reading an entire file into memory
A common need: load a whole file into a single buffer, e.g. for a config file or small dataset, using the file-size trick from section 11.
#include <stdio.h>
#include <stdlib.h>
char *readEntireFile(const char *path, long *outSize) {
FILE *fp = fopen(path, "rb");
if (!fp) return NULL;
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
rewind(fp);
char *buffer = malloc(size + 1); // +1 for a null terminator
if (!buffer) { fclose(fp); return NULL; }
size_t bytesRead = fread(buffer, 1, size, fp);
fclose(fp);
buffer[bytesRead] = '\0'; // safe to treat as a C string if it's text
if (outSize) *outSize = (long)bytesRead;
return buffer;
}
int main(void) {
long size;
char *contents = readEntireFile("output.txt", &size);
if (contents) {
printf("read %ld bytes:\n%s\n", size, contents);
free(contents); // caller owns this memory -- must free it
} else {
perror("could not read file");
}
return 0;
}
This approach doesn't scale to files larger than available memory - for
those, process the file in fixed-size chunks with fread in a loop
instead of loading it all at once.
15. Temporary files
tmpfile() creates a file that's automatically deleted when closed (or
when the program exits) - useful for scratch data you never want left
behind.
#include <stdio.h>
int main(void) {
FILE *fp = tmpfile();
if (!fp) { perror("tmpfile"); return 1; }
fprintf(fp, "scratch data\n");
rewind(fp);
char line[64];
fgets(line, sizeof(line), fp);
printf("read back: %s", line);
fclose(fp); // the file is deleted automatically here
return 0;
}
tmpnam() (which only generates a unique name without creating the
file) is considered unsafe - another process can create a file with that
same name in the gap between generating it and opening it - and is
deprecated. Prefer tmpfile(), or platform-specific safe alternatives
(mkstemp on POSIX).
16. Renaming and deleting files
rename and remove operate on file paths directly, with no FILE *
needed.
#include <stdio.h>
int main(void) {
FILE *fp = fopen("draft.txt", "w");
if (fp) { fprintf(fp, "content\n"); fclose(fp); }
if (rename("draft.txt", "final.txt") != 0) {
perror("rename failed");
} else {
printf("renamed successfully\n");
}
if (remove("final.txt") != 0) {
perror("remove failed");
} else {
printf("deleted successfully\n");
}
return 0;
}
Both return 0 on success and a non-zero value on failure - always check,
since a failed rename/delete is easy to silently ignore otherwise.
17. Checking whether a file exists
Standard C has no dedicated "does this file exist" function; the portable
idiom is attempting to open it for reading and checking for NULL.
#include <stdio.h>
#include <stdbool.h>
bool fileExists(const char *path) {
FILE *fp = fopen(path, "r");
if (fp) {
fclose(fp);
return true;
}
return false;
}
int main(void) {
printf("%s\n", fileExists("output.txt") ? "exists" : "missing");
return 0;
}
This has a small race condition (the file could be created/deleted
between the check and whatever you do next) and doesn't tell you why it
failed (missing vs. no permission) - for that, POSIX's stat() or
access() give more detail.
18. Error handling: errno, perror, strerror
Almost every file operation that can fail sets the global errno variable
to indicate why. perror and strerror translate that number into a
human-readable message.
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void) {
FILE *fp = fopen("/root/no_permission.txt", "w");
if (!fp) {
perror("fopen"); // prints: fopen: Permission denied
printf("errno value: %d\n", errno);
printf("errno message: %s\n", strerror(errno));
return 1;
}
fclose(fp);
return 0;
}
errno is only meaningful immediately after a failing call - a
subsequent successful call can overwrite it, so check/use it right away.
19. Standard streams: stdin, stdout, stderr
Three FILE * streams are open automatically at program start, without
any fopen call - every C program has these for free.
#include <stdio.h>
int main(void) {
fprintf(stdout, "normal output goes here\n"); // same as printf
fprintf(stderr, "error output goes here\n"); // separate stream, for errors/diagnostics
char name[64];
fprintf(stdout, "Enter your name: ");
fflush(stdout);
fgets(name, sizeof(name), stdin); // reading user input, same as scanf's source
printf("Hello, %s", name);
return 0;
}
Separating stdout and stderr matters because a user (or another
program) can redirect them independently at the command line -
./program > output.txt 2> errors.txt sends normal output and errors to
different files, which is exactly why logging/error messages should go to
stderr, not stdout.
20. Low-level POSIX I/O vs. standard I/O
On Unix-like systems, <unistd.h>/<fcntl.h> provide unbuffered,
OS-level file descriptors (open, read, write, close) underneath
stdio's buffered FILE * abstraction. Standard I/O is portable and
usually faster for typical use (fewer system calls, thanks to buffering);
POSIX I/O is for when you need OS-specific control.
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd = open("posix_example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) { perror("open"); return 1; }
const char *msg = "written with POSIX I/O\n";
write(fd, msg, 23); // no automatic buffering -- this is a direct system call
close(fd);
// Reading it back:
fd = open("posix_example.txt", O_RDONLY);
if (fd == -1) { perror("open"); return 1; }
char buf[64] = {0};
ssize_t bytesRead = read(fd, buf, sizeof(buf) - 1);
printf("read %zd bytes: %s", bytesRead, buf);
close(fd);
return 0;
}
stdio's FILE * functions are actually implemented on top of these
same POSIX calls on Unix-like systems - fopen calls open internally,
fread/fwrite call read/write in batches according to the buffer.
21. Listing directory contents (POSIX)
<dirent.h> provides opendir/readdir/closedir for iterating over a
directory's contents - not part of standard C, but available on
essentially every Unix-like system (Linux, macOS, BSD).
#include <stdio.h>
#include <dirent.h>
int main(void) {
DIR *dir = opendir(".");
if (!dir) { perror("opendir"); return 1; }
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
printf("%s\n", entry->d_name);
}
closedir(dir);
return 0;
}
On Windows, the equivalent is FindFirstFile/FindNextFile from
<windows.h> - genuinely different APIs, which is exactly why
cross-platform C projects often wrap directory listing behind their own
portable function.
22. A practical example: parsing a simple CSV file
Combining several techniques from this guide - line-by-line reading,
strtok for splitting fields, and basic error handling - to parse a
small structured text file.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(void) {
FILE *fp = fopen("scores.csv", "r");
if (!fp) { perror("fopen"); return 1; }
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
line[strcspn(line, "\n")] = '\0'; // strip the trailing newline
char *name = strtok(line, ",");
char *scoreStr = strtok(NULL, ",");
if (name && scoreStr) {
int score = atoi(scoreStr);
printf("%s scored %d\n", name, score);
}
}
fclose(fp);
return 0;
}
For a scores.csv containing Alice,90\nBob,75\n, this prints each
name/score pair. Real-world CSV parsing needs more care (quoted fields,
embedded commas, escaped quotes) - this is the basic pattern, not a
production-grade parser.
23. Common pitfalls checklist
Forgetting to check
fopen's return value - using aNULLFILE *is undefined behavior, and it's the single most common file handling bug (section 3).Forgetting
fclose- leaks the OS file handle and, worse, can leave buffered writes never flushed to disk, so your data appears to "disappear" even though the code looked correct (section 1).Using
"w"when you meant"a"(or vice versa) -"w"silently truncates an existing file's contents; this has destroyed real data more than once (section 2).Looping on
while (!feof(fp))instead of checking the read function's own return value - causes one extra bogus iteration after the real data ends (section 7).Forgetting binary mode (
"b") for non-text data - invisible on Unix-like systems, silently corrupts data on Windows due to\n/\r\ntranslation (section 13).Assuming a struct's raw memory layout is a portable file format without controlling padding - works only as long as the exact same compiler/platform reads it back (section 9).
Not checking
fread's/fwrite's return value - a short read/write (fewer items than requested) usually means something went wrong, and ignoring it silently corrupts downstream logic.Using
gets()- removed from the C standard in C11 for good reason (unbounded buffer overflow); always usefgetsinstead (section 5).Race conditions with "check then open" patterns (like
fileExists()in section 17) - the file can change state between the check and the subsequent operation; acceptable for casual use, not for anything security-sensitive.
Quick reference
| Feature | Function(s) | Purpose | Section |
|---|---|---|---|
| Open / close | fopen, fclose | Get/release a FILE * handle | 1 |
| File modes | "r" "w" "a" "r+" "w+" "a+" (+"b") | Control read/write/truncate/append behavior | 2 |
| Write text | fprintf, fputs, fputc | Formatted or plain text output | 4 |
| Read text | fgets, fscanf, fgetc | Line, formatted, or character input | 5 |
| Line-by-line loop | fgets in a while loop | Standard whole-file text processing pattern | 6 |
| EOF / error check | feof, ferror | Distinguish normal end-of-file from a read error | 7 |
| Binary I/O | fwrite, fread | Exact byte copy, no text translation | 8 |
| Reposition | fseek, ftell, rewind | Jump to/report a position within a file | 10 |
| File size idiom | fseek(...SEEK_END) + ftell | Portable way to get a file's byte size | 11 |
| Force write to disk | fflush | Push buffered data out immediately | 12 |
| Change buffering | setvbuf | Choose full/line/no buffering for a stream | 12 |
| Whole-file read | fread into a malloc'd buffer | Load an entire file into memory at once | 14 |
| Auto-deleting scratch file | tmpfile | Create a temp file removed on close/exit | 15 |
| Rename / delete | rename, remove | Operate on a file by path, no FILE * needed | 16 |
| Error diagnostics | errno, perror, strerror | Discover and report why an operation failed | 18 |
| Built-in streams | stdin, stdout, stderr | Pre-opened I/O streams, no fopen needed | 19 |
| Low-level POSIX I/O | open, read, write, close | Unbuffered, OS-level file descriptors | 20 |
| Directory listing (POSIX) | opendir, readdir, closedir | Iterate over a directory's contents | 21 |
Coverage note
This guide covers standard C file I/O end to end - opening, modes, text and binary reading/writing, struct serialization, positioning, buffering, platform-specific text-mode translation, temp files, renaming/deleting, error handling, and the standard streams - plus the POSIX-level facilities (low-level file descriptors, directory listing) that sit just outside the C standard but are used constantly in real systems code. If you've read every section here, you have everything needed to read, write, and debug file handling in essentially any C program you'll encounter.
Miscellaneous Real-World C
Macros in C