Skip to main content
C · lesson 15 of 25
C intermediate 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.

ModeMeaning
"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 bBinary 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

  1. Write a mini wc that prints the line, word and character counts of a file, and compare it with the real wc.
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.

  1. Write copy SRC DST that 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.

  1. Write a program that appends two lines to app.log each 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.

  1. 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.

Frequently Asked Questions

What is the difference between text mode and binary mode?
In text mode the C library may translate line endings: on Windows, \n is written as \r\n and converted back on reading. In binary mode bytes are read and written exactly as stored. On Linux and macOS the two modes behave identically, but always use "rb"/"wb" for non-text data so the code stays correct on Windows.
Do I always need to close files?
Yes. Output is buffered, and fclose is what flushes the last buffer to the operating system and releases the file descriptor. Unclosed files can lose their final writes if the program crashes, and a long-running program that forgets to close will eventually hit the per-process limit on open files. Check fclose's return value, because a failed write may only be reported there.
What is the difference between fgets and fscanf for reading text?
fgets reads a whole line, up to a size limit, into a buffer. fscanf parses formatted fields directly from the stream and stops at whitespace, and on bad input it can leave the stream stuck mid-line. Reading lines with fgets and parsing each with sscanf is more robust, because a malformed line can be reported and skipped.
Can I fwrite a struct directly to a file?
You can, but the bytes include the struct's padding and use the machine's own endianness and type sizes. A file written that way may not read back correctly on another compiler or architecture. For portable formats, write each field explicitly with a fixed size and byte order.