File Handling
File streams, modes, opening and closing, text and binary files, formatted and block I/O, positioning, end-of-file detection, and error handling.
Files let data outlive one program execution and connect programs through defined formats. C exposes files through the same stream abstraction used for console I/O, but persistent data raises additional obligations: paths can be invalid, permissions can change, disks can fill, writes can remain buffered, and an in-memory representation may not be a portable file format.
File Streams
FILE is an implementation-defined type declared by <stdio.h>. A FILE * identifies an open stream and carries state such as buffering, current position, end-of-file indication, and error indication.
FILE *input = fopen("readings.txt", "r");
The pointer is not a direct pointer to file bytes. It is an interface handle and must be used only with stream functions while the stream remains open.
The predefined stdin, stdout, and stderr streams are already open at program startup. Streams returned by fopen become resources owned by the code that successfully opened them and must normally be closed.
File Modes
The mode string states intended access and whether text or binary translation applies.
| Mode | Meaning | Existing content | Initial position |
|---|---|---|---|
"r" | read text | required | beginning |
"w" | write text | discarded; file created if needed | beginning |
"a" | append text | preserved; file created if needed | writes forced to end |
"r+" | read and write text | required | beginning |
"w+" | read and write text | discarded; file created | beginning |
"a+" | read and append text | preserved; file created | beginning for reading; every write at end |
Add b for binary mode: "rb", "wb", "ab", and corresponding update forms. On some systems text and binary modes behave identically; on others text mode translates line endings or treats particular byte sequences specially.
Choose the least powerful mode that meets the need. Opening with "w" truncates an existing file immediately after a successful open, so a mistaken path can destroy data before the first fprintf call.
Update Streams
Streams opened with + allow both reading and writing. Switching direction has rules: an output operation must be followed by fflush, fseek, fsetpos, or rewind before input; input must generally be followed by a positioning operation before output unless the input reached end-of-file. Simpler designs separate reading and writing or perform explicit positioning at each transition.
File Opening
Always check fopen:
#include <errno.h>
#include <stdio.h>
#include <string.h>
FILE *input = fopen("readings.txt", "r");
if (input == NULL) {
fprintf(stderr, "cannot open readings.txt: %s\n",
strerror(errno));
return 1;
}
Many library failures set errno. Save or report it before another library call can change it. perror offers a convenient equivalent:
if (input == NULL) {
perror("readings.txt");
return 1;
}
Do not reveal sensitive paths or operating-system details blindly in user-facing services. Diagnostics suitable for a local command-line program may need sanitizing in another environment.
Opening a file successfully does not guarantee later reads or writes will succeed. Check every operation whose success matters.
File Closing
fclose flushes buffered output, dissociates the stream from the file, and releases stream resources:
if (fclose(output) == EOF) {
fputs("failed to finish output\n", stderr);
return 1;
}
output = NULL;
Closing an output stream can fail because buffered bytes are written only then. Ignoring fclose can report success even when the final data never reached the destination.
After fclose, the FILE * value must not be used. Closing the same stream twice is undefined behaviour. Setting the owner variable to NULL helps locally but does not repair aliases.
On abnormal program termination, buffered output may be lost. Normal return from main or exit closes streams as part of termination, but explicit close checks are necessary when output integrity matters and allow errors to influence the program status.
Text Files
Text files represent values as characters according to a defined grammar:
2026-08-03,24.75
2026-08-04,25.10
Advantages include human readability, inspectability, and easier interchange across systems. Costs include parsing, formatting choices, and potentially larger representations.
A text format must define:
- character encoding;
- line structure and line endings at the logical level;
- field delimiters and escaping;
- numeric syntax and locale policy;
- treatment of missing or extra fields;
- versioning and invalid-record behaviour.
“Comma separated” is not complete if fields may themselves contain commas, quotes, or newlines.
Line Processing
enum { LINE_CAPACITY = 256 };
char line[LINE_CAPACITY];
size_t line_number = 0;
while (fgets(line, sizeof line, input) != NULL) {
line_number++;
/* validate complete line, parse fields, validate values */
}
if (ferror(input)) {
fprintf(stderr, "read error near line %zu\n", line_number + 1);
}
fgets may return a partial logical line when the buffer is too small. Apply the same overlong-line policy used for console input.
Binary Files
Binary mode treats a file as bytes without text-mode translation. It is suitable for a deliberately specified binary format, not automatically for dumping memory.
unsigned char header[4] = {'D', 'A', 'T', '1'};
if (fwrite(header, sizeof header[0], sizeof header, output)
!= sizeof header) {
fputs("could not write header\n", stderr);
}
A portable binary format defines byte order, integer widths, floating representation or encoding, alignment-independent field placement, valid ranges, and version evolution.
This is generally not portable:
fwrite(&student, sizeof student, 1, output);
The structure can contain padding, implementation-specific integer sizes, pointer values with no persistent meaning, and a platform-specific byte order. Even on one machine, changing the compiler or structure definition can break compatibility.
Encode fields explicitly. For a 32-bit unsigned integer in big-endian order:
bool write_u32_be(FILE *stream, uint32_t value) {
unsigned char bytes[4] = {
(unsigned char)(value >> 24),
(unsigned char)(value >> 16),
(unsigned char)(value >> 8),
(unsigned char)value
};
return fwrite(bytes, 1, sizeof bytes, stream) == sizeof bytes;
}
The file contract now describes four exact bytes independent of host byte order.
Formatted File I/O
fprintf writes formatted text to a chosen stream:
if (fprintf(output, "%lu,%s,%.2f\n",
student.id, student.name, student.average) < 0) {
/* output error */
}
fscanf reads formatted fields from a chosen stream and returns the number of assignments:
unsigned long id;
double average;
int converted = fscanf(input, "%lu %lf", &id, &average);
Direct fscanf can be useful for a tightly controlled whitespace-separated format, but malformed input leaves the stream near the failure and makes line-number diagnostics difficult. For human-edited or record-oriented files, fgets plus in-memory parsing gives better control over whole-record validation.
The assignment count does not make an out-of-range numeric conversion recoverable. Each converted result must be representable in its destination type. Use fscanf numeric conversions only when the file contract already constrains magnitudes; otherwise read a bounded line and parse numeric fields with suitable strto* functions so syntax and range failures can be reported deliberately.
Formatting is affected by locale for some conversions. An interchange format should define and enforce a locale-independent numeric representation rather than assuming decimal punctuation.
Character File I/O
fgetc reads one byte represented as unsigned char converted to int, or returns EOF. fputc writes one byte value:
int ch;
while ((ch = fgetc(input)) != EOF) {
if (fputc(ch, output) == EOF) {
fputs("write failed\n", stderr);
break;
}
}
if (ferror(input)) {
fputs("read failed\n", stderr);
}
Store the result in int so a valid byte remains distinguishable from EOF. On a text stream, the program sees translated logical characters where the implementation performs text translation.
ungetc can push one character back for a later read when a simple parser reads one character too far. Only one character of pushback is guaranteed, and pushing back EOF fails.
Block File I/O
fread and fwrite transfer arrays of objects:
bool copy_stream(FILE *input, FILE *output) {
unsigned char buffer[4096];
for (;;) {
size_t received = fread(buffer, 1, sizeof buffer, input);
size_t written = 0;
while (written < received) {
size_t amount = fwrite(buffer + written, 1,
received - written, output);
if (amount == 0) {
return false;
}
written += amount;
}
if (received < sizeof buffer) {
return !ferror(input);
}
}
}
The return value counts complete elements, not bytes unless element size is one. A short read can mean end-of-file or error; the function checks ferror when a block is short. A short write is retried until all received bytes are written, while zero progress reports failure.
For byte copying, element size one makes partial progress easy to express. Reading structures directly has the portability limitations already described.
Multiplication Contracts
The library conceptually transfers size * count bytes. Ensure dimensions are sensible and that application calculations surrounding buffer offsets cannot overflow. Passing a null pointer is permitted only when no access occurs under the function’s exact zero-size rules; clearer code handles empty transfers separately.
File Positioning
fseek changes a stream position, ftell obtains a position indicator representable as long, and rewind returns to the beginning while clearing error and end-of-file indicators.
if (fseek(input, 0L, SEEK_SET) != 0) {
perror("seek");
}
Origins are SEEK_SET, SEEK_CUR, and SEEK_END. Not every stream is seekable; terminals and pipes commonly are not.
Binary Streams
For binary streams, positions support byte-oriented seeking under implementation limits. SEEK_END is not required to work meaningfully for every binary stream, and long may not represent very large file positions on all platforms.
Text Streams
Text-stream positions can be opaque because line-ending translations may occur. Portable fseek use on text streams is restricted: offset zero may be used with any origin, or a value previously returned by ftell may be used with SEEK_SET.
long checkpoint = ftell(input);
if (checkpoint == -1L) {
/* position unavailable */
}
/* read ahead */
if (fseek(input, checkpoint, SEEK_SET) != 0) {
/* restoration failed */
}
For robust arbitrary positions and wider support where available, C also provides fgetpos and fsetpos with fpos_t.
Rewind
rewind(input);
returns to the beginning and clears indicators but has no return value. When failure reporting matters, prefer fseek(input, 0L, SEEK_SET) and check it.
End-of-File Detection
End-of-file is not predicted; it is discovered when a read attempts to move beyond available input.
This loop is wrong:
while (!feof(input)) {
fgets(line, sizeof line, input);
process(line); /* may process stale data after the failed read */
}
Drive the loop with the read itself:
while (fgets(line, sizeof line, input) != NULL) {
process(line);
}
After a read fails, distinguish causes:
if (ferror(input)) {
fputs("input error\n", stderr);
} else if (feof(input)) {
/* ordinary end of input */
}
The indicators remain set until cleared by clearerr, rewind, or some successful repositioning operations under their rules.
Error Handling
File processing typically acquires a stream, performs many fallible operations, then closes it. Preserve the first meaningful failure while still attempting required cleanup.
bool write_report(const char *path) {
bool success = false;
FILE *output = fopen(path, "w");
if (output == NULL) {
return false;
}
if (fprintf(output, "status: complete\n") < 0) {
goto cleanup;
}
success = true;
cleanup:
if (fclose(output) == EOF) {
success = false;
}
return success;
}
For valuable output, writing directly over the destination can leave a partial file after failure. A safer update pattern is:
- create a temporary file in the same trusted directory;
- write and validate all content;
- flush and close successfully;
- atomically replace the destination where the platform contract supports it.
Portable ISO C alone cannot guarantee every filesystem safety property, such as secure temporary creation, atomic replacement semantics, or durable storage after power loss. State platform assumptions when those properties matter.
Stream Operation Trace
For a three-line text file whose last line ends without \n:
| read attempt | fgets result | buffer | indicator afterward |
|---|---|---|---|
| 1 | non-null | first line including newline | neither EOF nor error |
| 2 | non-null | second line including newline | neither EOF nor error |
| 3 | non-null | final characters plus \0 | EOF not necessarily set yet |
| 4 | NULL | no new record | EOF set if no error |
The final unterminated line is still valid input if the file format allows it. EOF becomes observable only after a read discovers no further data.
Binary Copy Case
This complete program copies source.bin to copy.bin. The fixed names keep the required entry point as main(void); a reusable application would obtain paths from a validated interface and pass them to the same copy function.
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
static bool copy_bytes(FILE *source, FILE *destination) {
unsigned char buffer[4096];
for (;;) {
size_t received = fread(buffer, 1, sizeof buffer, source);
size_t offset = 0;
while (offset < received) {
size_t written = fwrite(
buffer + offset, 1, received - offset, destination);
if (written == 0) {
return false;
}
offset += written;
}
if (received < sizeof buffer) {
return !ferror(source);
}
}
}
int main(void) {
bool success = false;
FILE *source = fopen("source.bin", "rb");
FILE *destination = NULL;
if (source == NULL) {
perror("source.bin");
goto cleanup;
}
destination = fopen("copy.bin", "wb");
if (destination == NULL) {
perror("copy.bin");
goto cleanup;
}
if (!copy_bytes(source, destination)) {
fputs("copy operation failed\n", stderr);
goto cleanup;
}
success = true;
cleanup:
if (destination != NULL && fclose(destination) == EOF) {
perror("closing copy.bin");
success = false;
}
if (source != NULL && fclose(source) == EOF) {
perror("closing source.bin");
success = false;
}
return success ? EXIT_SUCCESS : EXIT_FAILURE;
}
The destination is opened only after the source succeeds. Reversing that order could truncate an existing copy.bin even when source.bin does not exist. The cleanup region accepts partially acquired state because both stream owners start as null.
copy_bytes treats each successful read as a sequence that must be written completely. Although regular-file fwrite often writes the full requested count, the interface permits a short write. The inner loop preserves this invariant:
buffer[0..offset) has been written
buffer[offset..received) still must be written
A zero write means no progress and reports failure. Once a read returns fewer than the buffer capacity, the function distinguishes input error from ordinary end-of-file. It does not call feof before attempting the read.
Resource Trace
| Failure point | source | destination | cleanup performed |
|---|---|---|---|
| source open | null | null | none |
| destination open | open | null | close source |
| read/write | open | open, perhaps partial file | close both; report failure |
| destination close | already copied | close reports error | close source; failure status |
| complete success | open | open | close destination, close source |
The program can leave a partial copy.bin after a write error. Its contract is “best-effort direct copy,” not transactional replacement. If the destination must remain unchanged on failure, write to a securely created temporary file, close and verify it, then replace the destination using documented platform facilities.
Record Grammar
Text files are sequences of bytes interpreted by a grammar, not self-validating records. A student line might be specified as:
version<TAB>id<TAB>mark<NEWLINE>
That short description must answer more questions before implementation:
- Is a final line without newline valid?
- May fields contain tabs or require escaping?
- Are leading zeros accepted in the ID?
- Which numeric ranges are valid?
- Is whitespace around numbers data or decoration?
- What happens to unknown versions?
- Does one malformed line reject the file or only that record?
Read a complete bounded line, detect truncation, split according to the exact delimiter rule, parse each field with range reporting, reject unwanted trailing bytes, validate relationships, and only then publish the record. fscanf can be concise for trusted simple files, but its whitespace rules and partial assignments make transaction boundaries harder to see.
Line numbers and field names make diagnostics actionable:
students.tsv:18: mark 127 is outside 0..100
Do not print the same failure at every layer. A low-level parser can return a status; the layer that knows file name and line number adds context once.
Update Durability
“The function returned success” can mean several levels of persistence:
- bytes were accepted by the C library buffer;
fflushtransferred them toward the host environment;fclosecompleted without a reported error;- the operating system accepted the data;
- storage hardware made it durable against power loss;
- a replacement became atomic and preserved permissions as required.
ISO C describes streams but does not guarantee all later levels. Configuration editors, databases, and security-sensitive tools need platform-specific contracts for synchronization, directory updates, atomic rename behaviour, temporary-file permissions, and crash recovery. State the required level rather than using “saved” as an undefined promise.
Append mode also has semantics beyond starting at the end: writes are forced to the end under the implementation’s append rules, which matters when multiple actors write. It does not automatically make multi-call records indivisible or coordinated. Concurrency requires an explicit environment-level policy.
Update-Stream Direction
Modes containing + permit both reading and writing, but the direction cannot change arbitrarily. Between writing and reading, perform a flush or positioning operation. Between reading and writing, perform a positioning operation unless the read reached end-of-file under the rule’s allowed case.
A clear edit trace is:
open with "r+"
read a record
fseek to the record's saved position
write replacement bytes
fflush or reposition before next read
read again
Failure to synchronize can produce undefined behaviour even though each individual fread or fwrite call has valid arguments. Keep a stream-direction state in the design, or use separate read and write streams/files when that makes the workflow clearer.
In-place text editing is difficult when replacement length changes. Overwriting a five-byte field with eight bytes shifts no later data automatically; it overwrites subsequent bytes. Rewriting to a new file is often simpler and safer than moving a file tail in place.
Explicit Binary Integers
A portable binary format chooses byte order. To encode a 32-bit unsigned value in big-endian order:
#include <stdint.h>
static void encode_u32_be(unsigned char output[4], uint32_t value) {
output[0] = (unsigned char)(value >> 24);
output[1] = (unsigned char)(value >> 16);
output[2] = (unsigned char)(value >> 8);
output[3] = (unsigned char)value;
}
static uint32_t decode_u32_be(const unsigned char input[4]) {
return ((uint32_t)input[0] << 24) |
((uint32_t)input[1] << 16) |
((uint32_t)input[2] << 8) |
(uint32_t)input[3];
}
The decode converts each byte to uint32_t before shifting. Otherwise integer promotions could produce a signed int, and shifting into its sign range could be invalid. The functions describe format bytes independently of host endianness and structure padding.
Writing the four-byte array still requires checking that fwrite(bytes, 1, 4, stream) == 4. Reading requires four complete bytes; a short read at record boundary is either a truncated format or a stream error, not a smaller valid integer.
Version the surrounding format and define limits before allocating from decoded lengths. A syntactically valid 32-bit length can request more memory than the application permits.
Stream Ownership
Standard streams are borrowed from the hosted environment; ordinary code does not close stdin, stdout, or stderr unless a larger ownership contract explicitly transfers them. A successful fopen result is owned and should be closed exactly once on every path.
A helper that accepts FILE * should state whether it borrows the stream and leaves it open. Most processing helpers do. Closing a caller-owned stream inside a low-level parser surprises callers and can make later cleanup double-close an invalid handle.
After fclose, the FILE * value must not be used, even for another close. Set an owner variable to null when cleanup code might otherwise revisit it. As with allocated memory, aliases do not become safe merely because one owner variable was cleared.
Error Context
perror appends a message for the current errno, which is meaningful only when the failed operation documents setting it. Capture or report promptly because later library calls may change it. Stream indicators such as ferror are separate from errno; check the interface’s documented channel.
Useful context identifies operation and target without exposing secret content:
opening configuration "settings.conf": permission denied
reading record 42 from "students.tsv": input/output error
closing temporary output: no space left on device
The close error example explains why checking only writes is insufficient: buffered bytes may reach the environment late.
File Failures
- Using
"w"when preservation was intended: a successful open truncates existing content. - Ignoring
fopen, read, write, orfcloseresults: failure becomes silent data loss. - Looping on
!feof: process only data returned by a successful read. - Storing
fgetcinchar: valid bytes can collide withEOF. - Treating a short
freadas EOF automatically: inspect the error indicator. - Dumping structures as a portable format: layout and representation vary.
- Seeking arbitrarily in text streams: only positions allowed by the text-stream contract are portable.
- Switching update-stream direction without positioning or flushing: behaviour can be undefined.
File Reasoning
- A
FILE *is a stream handle with buffering, position, and status state; ownership depends on how the handle was obtained. - Mode strings determine reading, writing, append, truncation, update access, and text/binary treatment.
- Every open and significant I/O operation can fail; close can reveal delayed write failure.
- Text formats need grammars; binary formats need explicit byte-level representations.
fgetsplus parsing gives record-level validation, whilefreadandfwritetransfer element blocks.- Stream positions are not universally byte offsets, especially in text mode.
- EOF is detected by a failed read, then distinguished from error with stream indicators.
- Reliable file replacement can require platform guarantees beyond ISO C.
File Problems
Read the Mode
- Contrast modes
r,w,a, and their update variants. - Why can
fclosefail after every precedingfprintfappeared successful? - Explain why a raw structure dump is not a portable binary format.
- When does the EOF indicator become set?
Follow the Position
- Trace the wrong
while (!feof(file))pattern through a two-line file and identify the stale processing step. - Track stream position and direction in an update stream that writes, seeks, reads, seeks, then writes.
- Trace partial
freadresults for a 10-byte file read into 4-byte blocks.
Repair the Stream
- Repair a file-copy loop that stores
fgetcinchar. - Correct code that treats every short block read as ordinary EOF.
- Find all unchecked operations in a report writer and make its process exit status reflect failure.
Process a File
- Write a line-numbering filter that reads from
stdinand writes tostdout, preserving a final line without a newline. - Implement a checked binary copier using
freadandfwrite, with distinct messages for open, read, write, and close failure. - Define a versioned text format for a fixed-capacity student record, then implement serialization and strict parsing.
- Implement big-endian read and write functions for 16-bit and 32-bit unsigned integers without assuming host byte order.
Protect the Data
- Design a policy for malformed records: fail-fast, skip with diagnostics, or accumulate errors. Explain when each is appropriate.
- Specify which guarantees a configuration-file updater needs beyond ISO C, including temporary-file creation, permissions, replacement, and durability.