Chapter 11: Files and Input/Output
Working with files in C means operating through the standard I/O library. You obtain a FILE* handle with fopen(), read or write using the formatted and unformatted routines, then release resources with fclose(). This chapter focuses on opening files safely, choosing the right reading and writing functions, handling binary data, and detecting errors reliably so programs behave predictably.
NULL on failure with a clear message. This keeps the happy path readable and the error path consistent.
Using file pointers with fopen() and fclose()
The type FILE represents a stream. You work with a pointer to this structure, which is returned by fopen() when a file is opened successfully. On failure fopen() returns NULL. Always check this return before continuing; then close the stream with fclose() when finished.
#include <stdio.h>
int main(void) {
const char *path = "log.txt";
FILE *fp = fopen(path, "w"); /* write text; truncates if exists */
if (!fp) {
perror("fopen");
return 1;
}
fputs("Hello file\n", fp);
if (fclose(fp) == EOF) {
perror("fclose");
return 1;
}
return 0;
}
File modes determine how the stream behaves. Common modes include "r" (read), "w" (write; truncate), "a" (append). Add "+ for update, and add "b" for binary where the platform distinguishes text from binary.
| Mode | Meaning |
"r" | Open for reading |
"w" | Open for writing; create or truncate |
"a" | Open for appending; writes go to end |
"r+" | Open for reading and writing |
"w+" | Read and write; create or truncate |
"a+" | Read and append; create if missing |
"rb", "wb", "ab" | Binary variants |
'\n' to "\r\n" and may treat Ctrl+Z as end of file. Use a "b" mode for portable binary I/O.
Understanding FILE* buffering and performance
Streams are buffered by default. stdin and stdout may be line buffered for terminals; other files are usually fully buffered. You can flush output explicitly with fflush(fp). For special needs you can adjust buffering with setvbuf(), although the defaults are suitable for most programs.
Choosing file modes for safer operations
Pick the narrowest mode that fits your intent. For example, prefer "rb" and "wb" when working with binary formats; prefer "a" if another process may be writing and you want to keep existing content intact. Update modes like "r+" allow reads and writes on the same stream, which requires careful seeking to avoid surprising results.
Reading and writing files
Text I/O comes in two flavors. Unformatted functions like fgets() and fputs() move strings reliably. Formatted functions like fprintf() and fscanf() parse and produce structured text. Favor fgets() for robust line input; then parse with strtol() or sscanf() where helpful.
#include <stdio.h>
#include <string.h>
int main(void) {
char line[128];
FILE *in = fopen("input.txt", "r");
if (!in) { perror("input"); return 1; }
FILE *out = fopen("output.txt", "w");
if (!out) { perror("output"); fclose(in); return 1; }
while (fgets(line, sizeof line, in)) {
size_t n = strlen(line);
if (n > 0 && line[n - 1] == '\n') line[n - 1] = '\0'; /* trim newline */
fprintf(out, "line: %s\n", line);
}
if (ferror(in)) { perror("read error"); }
fclose(in);
fclose(out);
return 0;
}
fscanf() can fail partially and leave the stream position mid token. Check its return count and clear errors carefully. A safer pattern is reading a line with fgets() then parsing fields.
Handling long lines by incrementally reading
If a line can exceed your buffer, read repeatedly and accumulate. Continue until you see a newline or end of file.
#include <stdio.h>
#include <string.h>
int read_line(FILE *fp, char *buf, size_t cap) {
size_t used = 0;
for (;;) {
if (!fgets(buf + used, (int)(cap - used), fp)) {
return used > 0 ? (int)used : -1; /* -1 means no data read */
}
used += strlen(buf + used);
if (used > 0 && buf[used - 1] == '\n') {
buf[used - 1] = '\0';
return (int)(used - 1);
}
if (used == cap - 1) return (int)used; /* truncated line */
}
}
Formatting output predictably with fprintf
Use width and precision specifiers to align columns and constrain output. For example %10s right aligns a string in ten characters; %.2f prints two digits after the decimal.
fprintf(stdout, "%-10s %8d %10.2f\n", "item", 42, 3.14159);
/* result: left aligned name; integer column; fixed two decimal places */
Working with binary files
Binary I/O moves raw bytes without any text translation. You pass a pointer to memory, the size of each object, and the number of objects to transfer. Always verify the number of items read or written; then handle short transfers accordingly.
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint32_t values[3] = { 10u, 20u, 30u };
FILE *fp = fopen("vals.bin", "wb");
if (!fp) { perror("wb"); return 1; }
size_t wrote = fwrite(values, sizeof values[0], 3, fp);
if (wrote != 3) { perror("fwrite"); fclose(fp); return 1; }
fclose(fp);
fp = fopen("vals.bin", "rb");
if (!fp) { perror("rb"); return 1; }
uint32_t readback[3] = {0};
size_t got = fread(readback, sizeof readback[0], 3, fp);
if (got != 3) {
if (ferror(fp)) perror("fread");
fclose(fp);
return 1;
}
fclose(fp);
return 0;
}
Seeking within a file using fseek and ftell
Random access requires moving the file position indicator. Use fseek() with an origin of SEEK_SET, SEEK_CUR, or SEEK_END. Query the position in bytes with ftell().
#include <stdio.h>
long size_of_file(FILE *fp) {
long pos = ftell(fp);
if (pos < 0) return -1;
if (fseek(fp, 0, SEEK_END) != 0) return -1;
long end = ftell(fp);
if (end < 0) return -1;
(void)fseek(fp, pos, SEEK_SET);
return end;
}
ftell() returns a long. Very large files may not fit in long on some platforms. The standard library does not provide a portable 64 bit variant everywhere; consult your platform when handling very large files.
Addressing endianness and structure layout
Writing raw structures with fwrite() is simple; however it can break between compilers due to padding, alignment, and byte order. A portable approach serializes fields individually into a byte buffer using fixed width types like uint32_t then writes that buffer.
#include <stdint.h>
void put_u32be(uint8_t *b, uint32_t x) {
b[0] = (uint8_t)((x >> 24) & 0xFF);
b[1] = (uint8_t)((x >> 16) & 0xFF);
b[2] = (uint8_t)((x >> 8) & 0xFF);
b[3] = (uint8_t)( x & 0xFF);
}
Performing error checking and handling EOF correctly
Every stdio function communicates success or failure. Check these results consistently. For character oriented input fgetc() returns int; a value of EOF indicates end of file or error. Distinguish the two using feof() and ferror(). For block I/O compare the count from fread() or fwrite() with the requested count.
#include <stdio.h>
int copy_file(const char *src, const char *dst) {
FILE *in = fopen(src, "rb");
if (!in) { perror("open src"); return 1; }
FILE *out = fopen(dst, "wb");
if (!out) { perror("open dst"); fclose(in); return 1; }
unsigned char buf[4096];
for (;;) {
size_t n = fread(buf, 1, sizeof buf, in);
if (n > 0) {
size_t m = fwrite(buf, 1, n, out);
if (m != n) { perror("write"); fclose(in); fclose(out); return 1; }
}
if (n < sizeof buf) {
if (feof(in)) break; /* clean end of file */
if (ferror(in)) { perror("read"); fclose(in); fclose(out); return 1; }
}
}
if (fclose(in) == EOF) { perror("close in"); }
if (fclose(out) == EOF) { perror("close out"); }
return 0;
}
while (!feof(fp)). The EOF flag becomes set only after a read attempt goes past the end, so this pattern often processes an extra stale iteration. Drive the loop by successful reads; then check feof() or ferror() when the read returns short.
Reporting errors
When a library call fails it may set errno. Use perror() for a simple message that includes the corresponding text, or call strerror(errno) to format your own messages with context.
#include <stdio.h>
#include <errno.h>
#include <string.h>
void open_or_report(const char *path) {
FILE *fp = fopen(path, "r");
if (!fp) {
fprintf(stderr, "cannot open %s: %s\n", path, strerror(errno));
return;
}
fclose(fp);
}
Practising error handling and defensive programming
Defensive file I/O means validating inputs, checking every return value, and cleaning up reliably even when something fails. Favor a single cleanup block controlled by a status variable; keep ownership clear for each resource you allocate, and release everything once.
#include <stdio.h>
#include <errno.h>
int write_report(const char *path, const char *msg) {
int rc = 1; /* assume failure until success */
FILE *fp = NULL;
if (!path || !msg) return 1; /* validate arguments */
fp = fopen(path, "w");
if (!fp) { perror("fopen"); goto cleanup; }
if (fprintf(fp, "Report: %s\n", msg) < 0) {
perror("fprintf");
goto cleanup;
}
if (fflush(fp) == EOF) { perror("fflush"); goto cleanup; }
rc = 0; /* success */
cleanup:
if (fp && fclose(fp) == EOF) {
perror("fclose");
rc = 1;
}
return rc;
}
For reliability in the presence of partial writes or crashes, write to a temporary file in the same directory, flush its buffers with fflush(), then replace the destination using a platform safe rename. The C standard defines rename(); consult your platform for atomic replacement details. If you must read sensitive input do not echo it; handle buffers carefully; clear sensitive buffers after use where appropriate.