C · lesson 15 of 25
File I/O in C
C file I/O with fopen, fgets, fprintf, fread and fwrite: reading text and CSV, writing binary headers, seeking, and checking every error.
By SkillByExample · Updated
Opening, Writing and Closing
fopen returns a FILE * handle, or NULL if the file can’t be opened. perror turns the reason into a readable message:
#include <stdio.h>
int main(void) {
FILE *fp = fopen("data.txt", "w");
if (fp == NULL) {
perror("fopen data.txt");
return 1;
}
fprintf(fp, "Line 1\n");
fprintf(fp, "Value: %d\n", 42);
if (fclose(fp) != 0) { /* buffered data is written here */
perror("fclose");
return 1;
}
FILE *bad = fopen("no_such_dir/data.txt", "w");
if (bad == NULL) perror("fopen no_such_dir/data.txt");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o write write.c
$ ./write
fopen no_such_dir/data.txt: No such file or directory
$ cat data.txt
Line 1
Value: 42
"w" creates data.txt or silently truncates it if it exists. The second fopen failed because the directory is missing, and perror printed the system’s reason (from errno) after your label. Output is buffered in memory, and fclose pushes the final buffer to the operating system. Its return value is the last chance to learn that a write failed, for example on a full disk.
| Mode | Meaning |
|---|---|
"r" | Read; the file must exist |
"w" | Write; create, or truncate to empty |
"a" | Append; create, or write at the end |
"r+" | Read and write; the file must exist |
"w+" | Read and write; create or truncate |
add b | Binary mode: "rb", "wb", "ab", "wb+"… |
Reading Text Line by Line
fgets reads one line at a time into a fixed buffer. It never overflows the buffer, and it keeps the newline:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
const char *name = argc > 1 ? argv[1] : "test.txt";
FILE *fp = fopen(name, "r");
if (!fp) { perror(name); return 1; }
char line[256];
int lineno = 0;
while (fgets(line, sizeof line, fp) != NULL) {
lineno++;
line[strcspn(line, "\n")] = '\0'; /* strip the newline fgets keeps */
printf("%3d: %s\n", lineno, line);
}
if (ferror(fp)) perror("fgets");
fclose(fp);
return 0;
}
$ printf "Hello\nWorld\nC programming\n" > test.txt
$ gcc -Wall -Wextra -std=c17 -o readlines readlines.c
$ ./readlines
1: Hello
2: World
3: C programming
$ ./readlines missing.txt
missing.txt: No such file or directory
fgets returns NULL both at end of file and on a read error, and ferror tells the two apart. strcspn(line, "\n") gives the length up to the first newline, or the whole length if there isn’t one (a last line without \n, or a line longer than the buffer). That makes this one-liner a safe way to strip it.
Reading a Whole File into Memory
Seek to the end to learn the size, allocate, then read everything in one call:
#include <stdio.h>
#include <stdlib.h>
/* Read a whole file into a NUL-terminated heap buffer; caller frees */
char *read_entire_file(const char *filename, long *out_size) {
FILE *fp = fopen(filename, "rb");
if (!fp) return NULL;
if (fseek(fp, 0, SEEK_END) != 0) { fclose(fp); return NULL; }
long size = ftell(fp);
rewind(fp);
if (size < 0) { fclose(fp); return NULL; }
char *buf = malloc((size_t)size + 1);
if (!buf) { fclose(fp); return NULL; }
size_t got = fread(buf, 1, (size_t)size, fp);
buf[got] = '\0';
fclose(fp);
if (out_size) *out_size = (long)got;
return buf;
}
int main(void) {
long size;
char *contents = read_entire_file("test.txt", &size);
if (!contents) { perror("test.txt"); return 1; }
printf("%ld bytes:\n%s", size, contents);
free(contents);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o slurp slurp.c
$ ./slurp
26 bytes:
Hello
World
C programming
26 bytes: the 23 visible characters plus three newlines. The buffer gets one extra byte for a '\0', so the contents can be used as a C string. "rb" makes ftell’s byte count match what fread returns even on Windows, where text mode would shrink each \r\n to one character. <stdlib.h> is required for malloc. Without it, GCC 14 stops with an “implicit declaration” error.
Writing and Parsing CSV
fprintf writes records as text. Reading them back with fgets plus sscanf lets you report and skip a malformed line instead of getting stuck on it:
#include <stdio.h>
typedef struct {
char name[16];
int score;
int level;
} Player;
int save(const char *path, const Player *p, int n) {
FILE *fp = fopen(path, "w");
if (!fp) return -1;
fprintf(fp, "# name,score,level\n");
for (int i = 0; i < n; i++) fprintf(fp, "%s,%d,%d\n", p[i].name, p[i].score, p[i].level);
return fclose(fp);
}
int load(const char *path, Player *p, int max) {
FILE *fp = fopen(path, "r");
if (!fp) return -1;
char line[128];
int count = 0, lineno = 0;
while (count < max && fgets(line, sizeof line, fp)) {
lineno++;
if (line[0] == '#') continue;
if (sscanf(line, "%15[^,],%d,%d", p[count].name, &p[count].score, &p[count].level) == 3)
count++;
else
fprintf(stderr, "line %d: skipped malformed record\n", lineno);
}
fclose(fp);
return count;
}
int main(void) {
Player out[] = { {"Alice", 9800, 15}, {"Bob", 7200, 12}, {"Carol", 11500, 18} };
if (save("scores.csv", out, 3) != 0) { perror("save"); return 1; }
FILE *fp = fopen("scores.csv", "a"); /* append a bad line */
fprintf(fp, "Dave,oops,3\n");
fclose(fp);
Player in[10];
int n = load("scores.csv", in, 10);
for (int i = 0; i < n; i++) printf("%-6s score=%5d level=%d\n", in[i].name, in[i].score, in[i].level);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o csv csv.c
$ ./csv
line 5: skipped malformed record
Alice score= 9800 level=15
Bob score= 7200 level=12
Carol score=11500 level=18
$ cat scores.csv
# name,score,level
Alice,9800,15
Bob,7200,12
Carol,11500,18
Dave,oops,3
%15[^,] reads up to 15 characters that aren’t commas. The width protects the 16-byte name field, keeping one byte for the terminator. sscanf returns how many fields it filled. For Dave,oops,3 it stopped after the name (1 field, not 3), so the line was reported and skipped rather than half-loaded. Opening with "a" added to the end of the file instead of wiping it.
Binary Files
fwrite and fread copy raw bytes between memory and a file: fast, exact, and platform-shaped:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
typedef struct {
char magic[4];
uint8_t version;
int32_t count;
} FileHeader;
int main(void) {
int32_t values[] = {10, 20, 30, 40, 50};
FileHeader hdr;
memset(&hdr, 0, sizeof hdr); /* zero the padding bytes too */
memcpy(hdr.magic, "MYDB", 4);
hdr.version = 1;
hdr.count = 5;
FILE *fp = fopen("data.bin", "wb");
if (!fp) { perror("fopen"); return 1; }
fwrite(&hdr, sizeof hdr, 1, fp);
fwrite(values, sizeof values[0], 5, fp);
fclose(fp);
printf("sizeof(FileHeader) = %zu\n", sizeof hdr);
fp = fopen("data.bin", "rb");
if (!fp) { perror("fopen"); return 1; }
FileHeader in;
if (fread(&in, sizeof in, 1, fp) != 1 || memcmp(in.magic, "MYDB", 4) != 0) {
fprintf(stderr, "not a MYDB file\n");
fclose(fp);
return 1;
}
int32_t *data = malloc(in.count * sizeof *data);
size_t got = data ? fread(data, sizeof *data, in.count, fp) : 0;
fclose(fp);
printf("version %u, %zu of %d values:", in.version, got, (int)in.count);
for (size_t i = 0; i < got; i++) printf(" %d", (int)data[i]);
printf("\n");
free(data);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o binary binary.c
$ ./binary
sizeof(FileHeader) = 12
version 1, 5 of 5 values: 10 20 30 40 50
$ od -A d -t x1 data.bin
0000000 4d 59 44 42 01 00 00 00 05 00 00 00 0a 00 00 00
0000016 14 00 00 00 1e 00 00 00 28 00 00 00 32 00 00 00
0000032
The header holds 4 + 1 + 4 = 9 bytes of data, but it’s 12 bytes on disk. The dump shows MYDB (4d 59 44 42), the version 01, then three padding bytes (00 00 00) before count = 05 00 00 00, which is little-endian. Without the memset, those three bytes would be whatever garbage was on the stack, leaking memory contents into the file. The magic-number check is what lets read reject a file that isn’t in this format. Because the layout depends on this compiler’s padding and this CPU’s byte order, formats meant to be shared write each field separately at a fixed size.
Random Access with fseek
fseek moves the read/write position anywhere in the file, so record N can be read or rewritten without touching the others:
#include <stdio.h>
int main(void) {
FILE *fp = fopen("test.bin", "wb+"); /* create for write and read */
if (!fp) return 1;
for (int i = 0; i < 10; i++) fwrite(&i, sizeof i, 1, fp);
fseek(fp, 5 * sizeof(int), SEEK_SET); /* jump to record 5 */
int val = 99;
fwrite(&val, sizeof val, 1, fp);
fseek(fp, 5 * sizeof(int), SEEK_SET);
fread(&val, sizeof val, 1, fp);
printf("record 5: %d, position now %ld\n", val, ftell(fp));
fseek(fp, -(long)sizeof(int), SEEK_END); /* last record */
fread(&val, sizeof val, 1, fp);
printf("last record: %d\n", val);
fseek(fp, 0, SEEK_END);
printf("file size: %ld bytes\n", ftell(fp));
fclose(fp);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o seek seek.c
$ ./seek
record 5: 99, position now 24
last record: 9
file size: 40 bytes
Overwriting record 5 in place didn’t grow the file: it’s still 10 × 4 = 40 bytes. After reading record 5, the position is 24, the start of record 6, because every read or write advances it. The (long) cast matters in -(long)sizeof(int): negating the unsigned size_t directly would produce a huge positive offset. In a "+" mode, the standard requires an fseek (or fflush) between a write and a following read, which this code does.
Practice
- Write a mini
wcthat prints the line, word and character counts of a file, and compare it with the realwc.
Solution
#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc < 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 2; }
FILE *fp = fopen(argv[1], "r");
if (!fp) { perror(argv[1]); return 1; }
long lines = 0, words = 0, chars = 0;
int c, in_word = 0;
while ((c = fgetc(fp)) != EOF) {
chars++;
if (c == '\n') lines++;
if (c == ' ' || c == '\n' || c == '\t') in_word = 0;
else if (!in_word) { in_word = 1; words++; }
}
fclose(fp);
printf("%ld %ld %ld %s\n", lines, words, chars, argv[1]);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1 test.txt
3 4 26 test.txt
$ wc test.txt
3 4 26 test.txt
The counts match the real wc. fgetc returns int, not char, so that EOF stays distinguishable from a real 0xFF byte. The C library buffers underneath, so reading one character at a time is still fast.
- Write
copy SRC DSTthat copies any file, binary included, in 4 KB chunks.
Solution
#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc != 3) { fprintf(stderr, "usage: %s SRC DST\n", argv[0]); return 2; }
FILE *in = fopen(argv[1], "rb");
if (!in) { perror(argv[1]); return 1; }
FILE *out = fopen(argv[2], "wb");
if (!out) { perror(argv[2]); fclose(in); return 1; }
char buf[4096];
size_t n, total = 0;
while ((n = fread(buf, 1, sizeof buf, in)) > 0) {
fwrite(buf, 1, n, out);
total += n;
}
fclose(in);
if (fclose(out) != 0) { perror(argv[2]); return 1; }
printf("copied %zu bytes\n", total);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2 data.bin copy.bin
copied 32 bytes
$ cmp data.bin copy.bin && echo identical
identical
Writing n bytes, not sizeof buf, is the key detail: the final chunk is usually short (here the whole 32-byte file was one short chunk). Binary mode plus fread/fwrite copies bytes exactly, which fgets could not do for a file containing '\0' bytes.
- Write a program that appends two lines to
app.logeach time it runs. Run it twice and check the file.
Solution
#include <stdio.h>
int main(void) {
FILE *log = fopen("app.log", "a");
if (!log) { perror("app.log"); return 1; }
for (int i = 1; i <= 2; i++) fprintf(log, "event %d\n", i);
fclose(log);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
$ ./p3
$ cat app.log
event 1
event 2
event 1
event 2
Mode "a" created the file on the first run and added to its end on the second. With "w", the second run would have truncated the file and left only one pair of lines. In append mode, every write goes to the current end of file, even after an fseek.
- The CSV loader silently truncates names longer than 15 characters. Change it so a too-long name is reported as malformed instead.
Solution
Scan the name with a width one larger than the field, into a bigger temporary buffer, and reject it if it used that extra room:
#include <stdio.h>
#include <string.h>
int main(void) {
const char *lines[] = {
"Alice,9800,15\n",
"Bartholomew-Longname,7200,12\n",
};
for (int i = 0; i < 2; i++) {
char name[16], tmp[17];
int score, level;
if (sscanf(lines[i], "%16[^,],%d,%d", tmp, &score, &level) == 3
&& strlen(tmp) < sizeof name) {
strcpy(name, tmp);
printf("ok: %s %d %d\n", name, score, level);
} else {
printf("rejected: %s", lines[i]);
}
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
ok: Alice 9800 15
rejected: Bartholomew-Longname,7200,12
%16[^,] stopped after 16 characters of the 20-character name, leaving -Longname,... unread. The %d that followed then failed, so sscanf returned 1 and the line was rejected. The strlen check also rejects a name of exactly 16 characters, which sscanf would read successfully but which can’t fit in name with its terminator. Scan widths must always be at least one less than the destination size.