FILE *fp = fopen("data.txt", "r");File Handling in C: Opening, Reading, Writing, and Closing Files
What is File Handling in C? Core Concept & Direct Summary
How does the `FILE*` stream pointer work with `fopen()`, `fclose()`, `fgetc()`, `fputc()`, and file access modes?
Everything your programs have done so far lives and dies with the process. An array of student records, a computed report, a running total — the moment main returns, it's gone. That's fine for a calculator. It's useless for anything that needs to remember.
File handling is how C programs persist data. And the good news is that the mechanism is built on something you already understand deeply: pointers.
That's the whole idea in one line. A file is a stream of bytes, and C gives you a FILE * — a handle that refers to that stream. You then use fprintf/fgets/fread exactly as you've been using printf/scanf, except the target is a file instead of the screen and keyboard.
This page covers the complete lifecycle: opening with the right mode, writing, reading, detecting the end and errors, binary vs text, repositioning, and the error handling that separates code that works from code that works reliably.
(Suggested image: the hero graphic — a program block on the left connected by a labeled FILE *fp arrow to a file on disk, with "stream of bytes" marked along the arrow)
How Does File Handling in C Work? In-Depth Mechanics & Architecture
The Stream Model and the FILE Pointer
C doesn't hand you disk sectors or file tables. It gives you an abstraction: an ordered stream of bytes that you read from or write to sequentially.
Three streams exist before your program does anything — no fopen required:
That's why printf("hi") works without opening anything — printf is convenient shorthand for fprintf(stdout, ...). And it's why fprintf(stderr, "error\n") is the right way to report problems: it reaches the console even when stdout has been redirected to a file.
To reach a file, you ask for a new stream:
fp is your handle. It's not the file — it's a reference to a runtime structure that tracks the file, the current position, buffering state, and error flags. Same mental shape as any other pointer: give it to functions, check it for NULL, don't use it after it's released.
Buffering matters and explains several "why didn't my data appear?" mysteries: C accumulates writes in a memory buffer and flushes them to disk in blocks, because a million one-byte disk writes would be catastrophically slow. The consequence: data you "wrote" may still be in memory until the buffer fills, you call fflush, or you call fclose.
(Suggested image: the program → FILE → buffer → disk chain, with the buffer highlighted to show data sitting in memory before reaching disk)*
fopen: The Mode Table You Must Know
The mode string decides everything about what's allowed and what happens to existing data. This is the most consequential table on the page — picking the wrong mode silently destroys data.
The life-saving distinction:"w"erases the file the instant you open it — before you write a single byte. Open the wrong file with "w" and its contents are already gone; no amount of careful code after that recovers it. If you mean "add to the end," that is "a", always.
Add "b" for binary:"rb", "wb", "ab", "r+b". This disables newline translation and end-of-file-character interpretation — essential for images, executables, structs, and anything where every byte must survive unchanged.
And always check the return value.fopen returns NULL for a missing file, a bad path, no permission, a read-only volume, too many open files, and more:
perror is the standard companion here — it appends the operating system's explanation of what went wrong, which is far more useful than a generic message. Using fp without that check means passing NULL into fgets, which crashes.
(Suggested image: the mode table rendered as a decision graphic — "want to keep existing data? → a; want to start fresh? → w; want to fail if missing? → r" — with the "w truncates immediately" warning called out)
fclose: More Than Cleanup
Every successful fopen needs a matching fclose. Three reasons, all of which will bite you eventually if ignored:
- It flushes the output buffer. This is the big one. Data you
fprintf'd may still be sitting in memory.fclosewrites it out. Skip it and your file can be missing data or empty entirely. - It releases the OS file handle. Operating systems limit how many files a process can hold open (often a few hundred to a few thousand). Leak handles in a loop and
fopenstarts returningNULL. - It reports deferred errors. Disk full, quota exceeded, device disconnected — these often surface only when the buffer is pushed to disk. So
fclose's return value is worth checking:
Pattern to internalize:everyfopen on every code path — including early error returns — must reach an fclose. That's the same ownership discipline as malloc/free from the dynamic memory tutorial, applied to streams.
Need to flush without closing?fflush(fp) pushes buffered output to disk and leaves the stream open — useful before reading back a file you just wrote, or for logging that must appear immediately.
(Suggested image: the write path showing data in a buffer, then the fclose arrow pushing it to disk, with an "early return without fclose = lost data" branch marked)
Writing to Files
Four functions, and the choice is about formatting level:
fprintf — formatted, like printf
Same format specifiers as printf, same rules — the only difference is the destination. This is the go-to for text files, because you control the exact layout.
fputs — a string, verbatim
Compare with puts, which does append a newline. Use fputs when you want precise control over line endings.
fputc — one character
Same relationship to putchar as fputs has to puts.
fwrite — raw bytes
Binary, unformatted: fwrite(pointer, size_of_one_item, count_of_items, fp). Returns how many items were written (not bytes) — compare it against your count to detect partial writes. This is the one for images, structs, and arrays.
A complete write cycle:
(Suggested image: the four write functions arranged by level — fputc (one char), fputs (a string), fprintf (formatted), fwrite (raw bytes))
Reading from Files
Reading has more moving parts, because you must handle "there's nothing left" correctly — and getting that wrong is the single most common file-handling bug in C.
fgets — the line reader, and the workhorse
- Reads at most
size - 1characters, then appends'\0' - Stops at a newline — and keeps it (so your buffer usually ends in
"\n") - Returns
bufferon success,NULLat end-of-file or on error
That size limit is a safety feature: fgets can never overflow your buffer. This is why fgets beats gets (which was removed from the language for exactly this reason).
The read-loop rule: never test feof()
This is worth stating flatly, because the wrong version looks so reasonable:
feof only becomes true after a read has already failed. So this loop reads past the end once, gets NULL, ignores it, and processes the stale buffer contents again — duplicating the final line. feof is a diagnostic ("did the last read hit EOF?"), not a predictive flag.
The correct rule:test the return value of the read function itself.
Every read function returns a detectable failure: fgets → NULL, fgetc → EOF, fscanf → the number of items matched, fread → items read. Trust those, not feof.
fgetc — one character, and the int trap
Why int and not char?fgetc returns either a byte value (0–255, or 0–255 reinterpreted for char) or the sentinel EOF, which is a negative int. If you store the result in a char, a legitimate byte of value 0xFF becomes indistinguishable from EOF — and your loop stops early on binary data. int is mandatory. (This is a genuine, classic bug.)
fscanf — formatted reading
fscanf returns how many items it successfully converted — so 3 means success, fewer means the input didn't match. It returns EOF at end of file.
Two hazards: a bare %s in fscanf has no width limit (buffer overflow), and if input doesn't match the format, the offending characters stay in the stream so the same failure repeats forever in a loop. Name a width — %49s — and prefer fgets + parsing for anything nontrivial.
The robust pattern: read the line, then parse it
For structured text, the most reliable approach combines both — fgets for safe, bounded reading, then sscanf to pull fields out of the line you already have:
Why this wins:
- Bounded reads — no overflow, ever, because the line length is capped
- Recoverable parse errors — a malformed line is skipped with a message, and the loop continues cleanly. A failing
fscanfloop can't do that %47[^|]reads up to 47 characters stopping at the|delimiter, so names with spaces work naturally
That three-line pattern handles CSV, pipe-delimited, log files, and config files — it's the single most reusable snippet on this page.
(Suggested image: the wrong-vs-right read loop side by side, with the duplicated final line highlighted in the wrong one)
EOF vs Error: Distinguishing the Two
Both fgets returning NULL and fgetc returning EOF can mean two different things: clean end of file, or a genuine read error (bad disk, unplugged drive, I/O fault). They look identical from the return value — so after the loop, ask which it was:
feof(fp)→ the last read stopped because the file endedferror(fp)→ the last read stopped because of an I/O error
Check ferrorfirst. A program that silently treats a disk error as "end of file" will report success on truncated data — the worst possible outcome.
(Suggested image: the loop exit split into two diagnosis branches — one labeled "clean EOF", one labeled "I/O error, report it")
Text Mode vs Binary Mode
The \r\n translation is real and affects sizes. Write three lines with "\n" endings on Windows in text mode and the file on disk contains \r\n per line — three extra bytes. Read it back in text mode and the \r is translated away, so your program sees \n again. Consistent and harmless if both sides use text mode.
The trap arrives when modes are mixed:
On Windows, every 0x0A byte in that image may be expanded to 0x0D 0x0A on write, and a 0x1A byte may end the read early — silently corrupting the data. Binary data requires "b". Always.
(Suggested image: the same logical content shown written to disk in text mode (with \r\n and Ctrl+Z marked) vs binary mode (bytes unchanged))
Binary I/O: fwrite and fread
For raw data — no formatting, no delimiters, just bytes:
Both take (data, size_of_item, count_of_items, fp) and return the item count. Arranging them this way is what makes reading an unknown-length array of records easy:
Two honest warnings about binary struct I/O:
- Padding travels with it.
fwritewrites the struct's padding bytes too, which contain whatever was in memory — so files are bigger than the data, and two logically identical records can produce different bytes.memcmpon structs is unreliable for the same reason. - The format isn't portable. Member offsets, padding, and sizes differ across compilers and platforms, and endianness flips multi-byte values between architectures. A
.binfile of structs written on one machine may be garbage on another.
So: binary struct I/O is excellent for transient, same-machine, same-build data (caches, save files, session state) and inappropriate for interchange formats. For anything crossing a boundary, serialize field by field in a defined order and width.
(Suggested image: the fread/fwrite call shape annotated — data, item size, item count, stream — with the returned item count highlighted as the thing to check)
Repositioning: fseek, ftell, rewind
Streams are sequential by default, but you can jump:
Fixed-size records + fseek is the foundation of random access — a direct analogue of array indexing. With sizeof(Record) uniform, record N starts at byte N × sizeof(Record), so you can jump to any record without reading the ones before it. That's the same offset arithmetic as in the arrays-of-structures tutorial, applied to a file.
Two cautions:
ftellon a text-mode file is not a reliable byte offset on Windows, because of\r\ntranslation. Use binary mode, or treat positions as opaque values.- After writing, you must flush or reposition before reading from the same stream — buffered output and input can otherwise interleave incorrectly.
fflush(fp)orfseek(fp, 0, SEEK_SET)first.
(Suggested image: a binary file of fixed-size records with fseek jumping directly to record 2, bypassing 0 and 1 — labeled "random access")
📋 Complete Program: Write, Read, Append, Read Again
A full lifecycle in one program — and note how every fopen is paired with a checked fclose, and every read tests the return value.
Sample Run (verified against a real file)
What's actually on disk
After step one — three records, 88 bytes:
After the append — four records, 114 bytes:
Read the run carefully — four things are being taught at once:
"w"created the file,"r"read it,"a"appended without destroying anything. The second report shows records 101–103 intact, then 104 added at the end. (Had the append used"w", all three original records would be gone and the total would read 210.00.)- Totals are computed from parsed fields, not trusted input.
Records: 3 / quantity 63 / value 1600.85, then4 / 69 / 1810.85— every number traceable toprice × quantityof a successfully parsed line. - The malformed-line guard never fired, but it's what makes the report trustworthy: a corrupt row is reported and skipped rather than silently adding nonsense to the totals.
- Byte counts are text-mode byte counts. On Windows the
\nwritten byfprintfbecomes\r\non disk, so each record costs one extra byte (88 = 85+3, 114 = 110+4). On Linux the same program produces 85 and 110 bytes — the mode, not your code, determines that.
(Suggested image: the file on disk shown twice — 3 records/88 bytes, then 4 records/114 bytes — beside the two report tables, with the append arrow marked "existing data preserved")
A Second Pattern: Counting Lines, Words, and Characters
Reading character by character with fgetc — the classic way to process a whole file, and the correct place for the int return type:
Three points worth absorbing:
int cis not optional. Withchar c, a byte of0xFFlooks likeEOFand the loop ends early — a bug that only appears on certain files.- Read a character at a time and the file's structure becomes your problem to detect. Words are counted by tracking a transition from non-space to space (
inWord), which is how everywc-style tool works. argc/argvlet the filename come from the command line —wc.exe myfile.txt— which is what makes a file utility reusable instead of hardcoded.argv[0]is the program name,argv[1]the first argument.
(Suggested image: the fgetc loop with the inWord state machine illustrated — characters flowing in, word boundaries marked where a non-space follows a space)
File Handling Cheat Sheet
(Suggested image: the cheat sheet as a two-column reference card — good candidate for a downloadable/shareable graphic)
Common Mistakes
- Not checking
fopenforNULL— passingNULLtofgets/fprintfcrashes. Check every open. while (!feof(fp))— reads once past the end and processes stale data, duplicating the last line. Test the read's return value instead.- Using
charforfgetc's return — a0xFFbyte becomes indistinguishable fromEOF; the loop stops early. - Forgetting
fcloseon some path — the output buffer is never flushed (data loss) and handles leak untilfopenfails. - Not checking
fclose's return — deferred write errors (disk full) go unreported; the program claims success. - Using
"w"when you meant"a"— the file is erased at open time, before any of your careful code runs. This is the most destructive single mistake in C file handling. - Using
"a+"and expecting to write in the middle — writes always append, regardless of the current read position. - Opening binary data in text mode — newline expansion and the Ctrl+Z EOF character corrupt the bytes silently on Windows.
fscanfin a loop without a width limit —%shas no bound (buffer overflow), and unmatched input isn't consumed, so the loop can spin forever.- Forgetting that
fputsadds no newline — all output runs together on one line. - Not stripping the newline
fgetskeeps — comparisons likeif (strcmp(line, "yes") == 0)fail because the line is"yes\n". - Assuming data is on disk before
fclose— it may still be buffered.fflushor close before another process (or anotherfopen) reads it. - Using
ftellon a text-mode file as a byte offset —\r\ntranslation makes positions unreliable. Use binary mode. fwrite-ing structs and expecting portability — padding, member offsets, and endianness differ across platforms. Serialize for interchange.- Using
fread's return as a byte count — it returns items, not bytes. Compare against the item count you asked for. - Mixing
fscanfandfgetson the same stream — a leftover newline or partial token is left unread and the next read sees it. - Two
fopens on the same file with separate buffers — interleaved writes and stale reads. Open once, use one stream.
(Suggested image: "17 File Handling Mistakes" checklist — with #2, #6, and #10 highlighted as the ones that silently corrupt or destroy data)
Final Thoughts
File handling in C is fopen / fclose plus four read functions and four write functions — the same formatted-I/O skills you already have, redirected from the console to a stream. The FILE * handle is a pointer, stdin/stdout/stderr are just pre-opened streams, and everything else follows from those two facts.
What makes the difference between working and reliable code is the discipline around it:
- Check every
fopenforNULLand report withperror - Choose the mode deliberately —
"r"to read,"a"to add,"w"only when destroying the old contents is intended - Test the read function's return value, never
feof, to end a loop - Use
intforfgetc, and bound every%sandfgetswith a size - Read the line with
fgets, parse it withsscanf— that pair survives malformed input; a barefscanfloop often doesn't fcloseon every path, and check its return — that's where buffered data actually reaches disk- Match the mode to the data —
"b"for anything that isn't text - Never
fwritea struct to a file you'll share — serialize field by field
Get those habits in place and persistence stops being the hard part of your programs. And once data can be read from and written to files, whole categories of programs open up: configuration loaders, log processors, report generators, save systems, and the CSV-and-text pipelines that connect C to everything else.
Runnable C Code Example & Practical Implementation
#include <stdio.h>
int main() {
FILE *fp = fopen("sample_output.txt", "w");
if (fp == NULL) {
perror("Failed to open file");
return 1;
}
fprintf(fp, "Hello from C File Handling!\
");
fprintf(fp, "Operating Systems rely on C file streams.\
");
fclose(fp);
printf("File written and closed successfully.\
");
return 0;
}Key Takeaways, Best Practices & Memory Safety Rules
if (fp == NULL) immediately after fopen(), and always close files with fclose(fp).Frequently Asked Questions & Technical Interview Insights
Technical Interview & Viva Questions
Frequently asked C programming interview questionsQ: What happens if a C program exits without calling `fclose()` on an open file?
Q: How do text mode and binary mode differ in file handling on Windows?
Related Tools, Tutorials & Authoritative References
Recommended Interactive Tools
- Online C Compiler & Runner — Interactive online GCC sandbox to execute C code in real time.
- Online C Code Editor & IDE — Multi-file C programming environment with code formatting.
- C++ Programming Tutorial — Transition from procedural C to Object-Oriented C++ with STL.
- JavaScript Programming Tutorial — Master modern JavaScript fundamentals, DOM APIs, and async patterns.
Authoritative Standards & Citations
- ISO/IEC 9899 (C Language Standard) — Official international C specification by ISO/IEC JTC 1/SC 22/WG 14.
- GNU GCC Documentation — Official reference manual for the GNU Compiler Collection and C libraries.
- The C Programming Language (K&R) — Definitive classic work by Brian Kernighan & Dennis Ritchie at Bell Labs.
- C Reference Documentation (cppreference.com) — Verified reference covering C89 through C23 language features.