File Handling in C

File handling is how a C program reads and writes data that outlives the program itself - config files, logs, saved game state, CSVs, binary formats, anything that needs to exist before the program starts or after it ends. C's file handling comes in two flavors: the portable, buffered standard I/O library (<stdio.h> - FILE *, fopen, fread, etc.) and the lower-level, OS-specific POSIX I/O (<unistd.h>/<fcntl.h> - open, read, write). This guide focuses mainly on standard I/O, since that's what's portable across every C compiler and OS, with POSIX I/O covered where it matters.


0. Why it's needed, why industry leans on it, and why your syllabus barely scratches it

Why it's needed at all

Every variable in a running C program lives in RAM, and RAM is wiped the moment the program exits. The only way for a program to remember anything between runs - or to exchange data with another program, another machine, or a human opening a file later - is to write that data somewhere durable: a file. File handling is the interface between your program's in-memory world and everything outside it.

Why real projects lean on it so heavily

Why your syllabus (probably) stopped early

Once you've actually shipped something that reads a multi-gigabyte log file a chunk at a time, or serialized a struct straight to disk and read it back byte-for-byte, file I/O stops feeling like "the fopen chapter" and starts feeling like one of the most directly useful things C ever taught you.


1. Opening and closing a file

Every standard I/O operation happens through a FILE * handle, obtained from fopen and released with fclose.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("example.txt", "w");
    if (fp == NULL) {
        perror("fopen failed");
        return 1;
    }

    fprintf(fp, "Hello, file!\n");

    fclose(fp);   // always close what you open
    return 0;
}

fclose flushes any buffered data to disk and releases the OS file handle. Forgetting to call it (section 24) is one of the most common file handling bugs.


2. File modes

The second argument to fopen controls whether you're reading, writing, or appending, and whether existing content is kept or wiped.

#include <stdio.h>

int main(void) {
    FILE *fp;

    fp = fopen("data.txt", "r");   // read: file MUST already exist
    if (fp) fclose(fp);

    fp = fopen("data.txt", "w");   // write: creates the file, or TRUNCATES
                                     // (erases) it if it already exists
    if (fp) fclose(fp);

    fp = fopen("data.txt", "a");   // append: creates if missing, writes
                                     // are always added at the end
    if (fp) fclose(fp);

    fp = fopen("data.txt", "r+");  // read AND write, file must exist
    if (fp) fclose(fp);

    fp = fopen("data.txt", "w+");  // read and write, TRUNCATES existing file
    if (fp) fclose(fp);

    fp = fopen("data.bin", "rb");  // "b" = binary mode, no text translation
    if (fp) fclose(fp);

    return 0;
}
ModeFile must exist?Truncates?Can read?Can write?
"r"yesnoyesno
"w"no (created)yesnoyes
"a"no (created)nonoyes (always at end)
"r+"yesnoyesyes
"w+"no (created)yesyesyes
"a+"no (created)noyesyes (writes always at end)

Add b to any mode ("rb", "wb", "ab+", etc.) for binary mode (section 13).


3. Always check whether fopen succeeded

fopen returns NULL on failure - file doesn't exist, no permission, disk full, path invalid, etc. Using a NULL FILE * is undefined behavior, so this check is not optional.

#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(void) {
    FILE *fp = fopen("does_not_exist.txt", "r");
    if (fp == NULL) {
        // perror prints your message plus the OS's reason, using errno
        perror("fopen failed");
        // equivalent manually:
        fprintf(stderr, "fopen failed: %s\n", strerror(errno));
        return 1;
    }

    fclose(fp);
    return 0;
}

4. Writing text to a file

fprintf, fputs, and fputc are the text-writing equivalents of printf, puts, and putchar, just aimed at a specific FILE * instead of stdout.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("output.txt", "w");
    if (!fp) { perror("fopen"); return 1; }

    fprintf(fp, "Score: %d\n", 95);        // formatted output, like printf
    fputs("A plain line of text\n", fp);   // a whole string, no formatting
    fputc('X', fp);                        // a single character
    fputc('\n', fp);

    fclose(fp);
    return 0;
}

5. Reading text from a file

fgets (a whole line), fscanf (formatted input), and fgetc (one character) are the standard ways to read text.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("output.txt", "r");
    if (!fp) { perror("fopen"); return 1; }

    char line[256];
    if (fgets(line, sizeof(line), fp) != NULL) {
        printf("First line: %s", line);   // fgets keeps the trailing '\n'
    }

    int score;
    // (re-open or seek as needed for a real program; kept simple here)
    fclose(fp);

    fp = fopen("output.txt", "r");
    if (fp && fscanf(fp, "Score: %d", &score) == 1) {
        printf("Parsed score: %d\n", score);
    }
    if (fp) fclose(fp);

    return 0;
}

fgets is strongly preferred over gets (which was removed from the C standard entirely in C11) because fgets takes a buffer size and can't overflow it; gets had no way to know the buffer size and was a classic source of buffer-overflow vulnerabilities.


6. Reading a file line by line

The standard pattern for processing a whole text file: loop fgets until it returns NULL (which means EOF or an error).

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("output.txt", "r");
    if (!fp) { perror("fopen"); return 1; }

    char line[256];
    int lineNumber = 1;
    while (fgets(line, sizeof(line), fp) != NULL) {
        printf("%d: %s", lineNumber, line);
        lineNumber++;
    }

    fclose(fp);
    return 0;
}

If a line is longer than your buffer, fgets stops at the buffer limit without a trailing \n on that call, and the rest of the line comes through on the next fgets call - worth knowing if you're checking for a newline character to confirm you got a whole line.


7. Detecting end-of-file with feof

Most of the time you don't need feof at all - checking the return value of fgets/fscanf/fread (NULL, not 1, short count) already tells you when to stop. feof is for confirming why a read loop ended: true end-of-file, versus an actual error.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("output.txt", "r");
    if (!fp) { perror("fopen"); return 1; }

    int c;
    while ((c = fgetc(fp)) != EOF) {
        putchar(c);
    }

    if (feof(fp)) {
        printf("\n-- reached end of file normally --\n");
    } else if (ferror(fp)) {
        printf("\n-- a read error occurred --\n");
    }

    fclose(fp);
    return 0;
}

A common bug: checking feof(fp) as the loop condition itself (while (!feof(fp))), which processes one extra bogus iteration after the real data ends. Always let the read call's own return value drive the loop, as shown above.


8. Binary I/O: fwrite and fread

For non-text data - raw bytes, structs, arrays - fwrite/fread copy exact bytes with no text interpretation or translation at all.

#include <stdio.h>

int main(void) {
    int numbers[5] = {10, 20, 30, 40, 50};

    FILE *fp = fopen("numbers.bin", "wb");
    if (!fp) { perror("fopen"); return 1; }
    fwrite(numbers, sizeof(int), 5, fp);   // (pointer, size of ONE item, count, file)
    fclose(fp);

    int loaded[5] = {0};
    fp = fopen("numbers.bin", "rb");
    if (!fp) { perror("fopen"); return 1; }
    size_t itemsRead = fread(loaded, sizeof(int), 5, fp);
    fclose(fp);

    printf("read %zu items: ", itemsRead);
    for (int i = 0; i < 5; i++) printf("%d ", loaded[i]);
    printf("\n");

    return 0;
}

Always check fread's return value (the number of items actually read)


9. Writing and reading whole structs

Binary I/O is most useful for saving structured records directly, without manually formatting/parsing text - but struct padding (see the structs guide) means this isn't automatically portable across compilers/platforms unless you control the layout.

#include <stdio.h>

#pragma pack(push, 1)   // avoid padding so the on-disk layout is predictable
typedef struct {
    int id;
    char name[32];
    float score;
} Record;
#pragma pack(pop)

int main(void) {
    Record r1 = {1, "Alice", 92.5f};
    Record r2 = {2, "Bob", 81.0f};

    FILE *fp = fopen("records.bin", "wb");
    if (!fp) { perror("fopen"); return 1; }
    fwrite(&r1, sizeof(Record), 1, fp);
    fwrite(&r2, sizeof(Record), 1, fp);
    fclose(fp);

    Record loaded[2];
    fp = fopen("records.bin", "rb");
    if (!fp) { perror("fopen"); return 1; }
    size_t count = fread(loaded, sizeof(Record), 2, fp);
    fclose(fp);

    for (size_t i = 0; i < count; i++) {
        printf("%d: %s (%.1f)\n", loaded[i].id, loaded[i].name, loaded[i].score);
    }
    return 0;
}

For a format that must be read by a different compiler, platform, or program written in a different language, don't rely on raw struct layout at all - write an explicit, documented binary format (fixed field sizes, known byte order) instead.


10. Moving around in a file: fseek, ftell, rewind

fseek moves the file position indicator; ftell reports the current position; rewind resets it to the start. These let you jump around a file instead of only reading it start-to-finish.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("output.txt", "r");
    if (!fp) { perror("fopen"); return 1; }

    fseek(fp, 5, SEEK_SET);   // move to byte 5 from the START of the file
    int c = fgetc(fp);
    printf("byte at offset 5: %c\n", c);

    fseek(fp, -1, SEEK_CUR);  // move back 1 byte from the CURRENT position
    printf("current offset: %ld\n", ftell(fp));

    fseek(fp, 0, SEEK_END);   // move to the END of the file
    printf("file size (via ftell at EOF): %ld bytes\n", ftell(fp));

    rewind(fp);                // equivalent to fseek(fp, 0, SEEK_SET), also clears error flags
    printf("back to start, offset: %ld\n", ftell(fp));

    fclose(fp);
    return 0;
}

SEEK_SET (from the start), SEEK_CUR (from the current position), and SEEK_END (from the end) are the three anchor points fseek supports.


11. Getting a file's size

There's no direct filesize() function in standard C - the common portable idiom is seeking to the end and reading the offset there.

#include <stdio.h>

long getFileSize(const char *path) {
    FILE *fp = fopen(path, "rb");
    if (!fp) return -1;

    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);
    fclose(fp);
    return size;
}

int main(void) {
    long size = getFileSize("output.txt");
    if (size >= 0) {
        printf("file size: %ld bytes\n", size);
    } else {
        perror("could not get file size");
    }
    return 0;
}

For very large files (bigger than what a long can represent on some platforms), POSIX's stat() with st_size (a wider type) is more robust than this fseek/ftell trick.


12. Buffering and flushing

By default, stdio buffers your writes for performance instead of hitting the disk on every single call. fflush forces buffered data out immediately - important before a crash-prone operation, or when writing to stdout right before reading from stdin in the same terminal.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("log.txt", "w");
    if (!fp) { perror("fopen"); return 1; }

    fprintf(fp, "This might sit in a buffer for a while...\n");
    fflush(fp);   // force it to disk NOW, e.g. before a risky operation

    printf("Enter your name: ");
    fflush(stdout);   // ensure the prompt is visible before scanf blocks waiting for input
    // (stdout is often line-buffered when attached to a terminal, so this
    // particular fflush may be unnecessary there -- but NOT when stdout is
    // redirected to a file or a pipe, where it's fully buffered instead)

    fclose(fp);
    return 0;
}

setvbuf lets you change a stream's buffering mode entirely (fully buffered, line buffered, or unbuffered) if the defaults don't fit your use case.


13. Text mode vs. binary mode

On Unix-like systems, text and binary mode behave identically. On Windows, text mode silently translates \n to \r\n on write, and \r\n back to \n on read. This is invisible for plain text files but corrupts binary data if you forget the "b" flag.

#include <stdio.h>

int main(void) {
    /* WRONG on Windows for binary data: text mode may translate bytes
       that happen to match the \n / \r\n patterns, corrupting the file. */
    FILE *bad = fopen("image.dat", "w");   // no 'b' -- risky for binary content

    /* RIGHT: always use binary mode for non-text data, on every platform,
       even though it only matters on Windows -- it's a harmless no-op
       elsewhere, so there's no reason not to always include it. */
    FILE *good = fopen("image.dat", "wb");

    if (bad) fclose(bad);
    if (good) fclose(good);
    return 0;
}

Rule of thumb: use "b" for anything that isn't human-readable text - images, compiled binaries, structs, serialized data - regardless of what platform you're currently developing on.


14. Reading an entire file into memory

A common need: load a whole file into a single buffer, e.g. for a config file or small dataset, using the file-size trick from section 11.

#include <stdio.h>
#include <stdlib.h>

char *readEntireFile(const char *path, long *outSize) {
    FILE *fp = fopen(path, "rb");
    if (!fp) return NULL;

    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);
    rewind(fp);

    char *buffer = malloc(size + 1);   // +1 for a null terminator
    if (!buffer) { fclose(fp); return NULL; }

    size_t bytesRead = fread(buffer, 1, size, fp);
    fclose(fp);

    buffer[bytesRead] = '\0';   // safe to treat as a C string if it's text
    if (outSize) *outSize = (long)bytesRead;
    return buffer;
}

int main(void) {
    long size;
    char *contents = readEntireFile("output.txt", &size);
    if (contents) {
        printf("read %ld bytes:\n%s\n", size, contents);
        free(contents);   // caller owns this memory -- must free it
    } else {
        perror("could not read file");
    }
    return 0;
}

This approach doesn't scale to files larger than available memory - for those, process the file in fixed-size chunks with fread in a loop instead of loading it all at once.


15. Temporary files

tmpfile() creates a file that's automatically deleted when closed (or when the program exits) - useful for scratch data you never want left behind.

#include <stdio.h>

int main(void) {
    FILE *fp = tmpfile();
    if (!fp) { perror("tmpfile"); return 1; }

    fprintf(fp, "scratch data\n");
    rewind(fp);

    char line[64];
    fgets(line, sizeof(line), fp);
    printf("read back: %s", line);

    fclose(fp);   // the file is deleted automatically here
    return 0;
}

tmpnam() (which only generates a unique name without creating the file) is considered unsafe - another process can create a file with that same name in the gap between generating it and opening it - and is deprecated. Prefer tmpfile(), or platform-specific safe alternatives (mkstemp on POSIX).


16. Renaming and deleting files

rename and remove operate on file paths directly, with no FILE * needed.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("draft.txt", "w");
    if (fp) { fprintf(fp, "content\n"); fclose(fp); }

    if (rename("draft.txt", "final.txt") != 0) {
        perror("rename failed");
    } else {
        printf("renamed successfully\n");
    }

    if (remove("final.txt") != 0) {
        perror("remove failed");
    } else {
        printf("deleted successfully\n");
    }

    return 0;
}

Both return 0 on success and a non-zero value on failure - always check, since a failed rename/delete is easy to silently ignore otherwise.


17. Checking whether a file exists

Standard C has no dedicated "does this file exist" function; the portable idiom is attempting to open it for reading and checking for NULL.

#include <stdio.h>
#include <stdbool.h>

bool fileExists(const char *path) {
    FILE *fp = fopen(path, "r");
    if (fp) {
        fclose(fp);
        return true;
    }
    return false;
}

int main(void) {
    printf("%s\n", fileExists("output.txt") ? "exists" : "missing");
    return 0;
}

This has a small race condition (the file could be created/deleted between the check and whatever you do next) and doesn't tell you why it failed (missing vs. no permission) - for that, POSIX's stat() or access() give more detail.


18. Error handling: errno, perror, strerror

Almost every file operation that can fail sets the global errno variable to indicate why. perror and strerror translate that number into a human-readable message.

#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(void) {
    FILE *fp = fopen("/root/no_permission.txt", "w");
    if (!fp) {
        perror("fopen");                          // prints: fopen: Permission denied
        printf("errno value: %d\n", errno);
        printf("errno message: %s\n", strerror(errno));
        return 1;
    }
    fclose(fp);
    return 0;
}

errno is only meaningful immediately after a failing call - a subsequent successful call can overwrite it, so check/use it right away.


19. Standard streams: stdin, stdout, stderr

Three FILE * streams are open automatically at program start, without any fopen call - every C program has these for free.

#include <stdio.h>

int main(void) {
    fprintf(stdout, "normal output goes here\n");   // same as printf
    fprintf(stderr, "error output goes here\n");    // separate stream, for errors/diagnostics

    char name[64];
    fprintf(stdout, "Enter your name: ");
    fflush(stdout);
    fgets(name, sizeof(name), stdin);                // reading user input, same as scanf's source

    printf("Hello, %s", name);
    return 0;
}

Separating stdout and stderr matters because a user (or another program) can redirect them independently at the command line - ./program > output.txt 2> errors.txt sends normal output and errors to different files, which is exactly why logging/error messages should go to stderr, not stdout.


20. Low-level POSIX I/O vs. standard I/O

On Unix-like systems, <unistd.h>/<fcntl.h> provide unbuffered, OS-level file descriptors (open, read, write, close) underneath stdio's buffered FILE * abstraction. Standard I/O is portable and usually faster for typical use (fewer system calls, thanks to buffering); POSIX I/O is for when you need OS-specific control.

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(void) {
    int fd = open("posix_example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) { perror("open"); return 1; }

    const char *msg = "written with POSIX I/O\n";
    write(fd, msg, 23);   // no automatic buffering -- this is a direct system call

    close(fd);

    // Reading it back:
    fd = open("posix_example.txt", O_RDONLY);
    if (fd == -1) { perror("open"); return 1; }

    char buf[64] = {0};
    ssize_t bytesRead = read(fd, buf, sizeof(buf) - 1);
    printf("read %zd bytes: %s", bytesRead, buf);

    close(fd);
    return 0;
}

stdio's FILE * functions are actually implemented on top of these same POSIX calls on Unix-like systems - fopen calls open internally, fread/fwrite call read/write in batches according to the buffer.


21. Listing directory contents (POSIX)

<dirent.h> provides opendir/readdir/closedir for iterating over a directory's contents - not part of standard C, but available on essentially every Unix-like system (Linux, macOS, BSD).

#include <stdio.h>
#include <dirent.h>

int main(void) {
    DIR *dir = opendir(".");
    if (!dir) { perror("opendir"); return 1; }

    struct dirent *entry;
    while ((entry = readdir(dir)) != NULL) {
        printf("%s\n", entry->d_name);
    }

    closedir(dir);
    return 0;
}

On Windows, the equivalent is FindFirstFile/FindNextFile from <windows.h> - genuinely different APIs, which is exactly why cross-platform C projects often wrap directory listing behind their own portable function.


22. A practical example: parsing a simple CSV file

Combining several techniques from this guide - line-by-line reading, strtok for splitting fields, and basic error handling - to parse a small structured text file.

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main(void) {
    FILE *fp = fopen("scores.csv", "r");
    if (!fp) { perror("fopen"); return 1; }

    char line[256];
    while (fgets(line, sizeof(line), fp) != NULL) {
        line[strcspn(line, "\n")] = '\0';   // strip the trailing newline

        char *name = strtok(line, ",");
        char *scoreStr = strtok(NULL, ",");

        if (name && scoreStr) {
            int score = atoi(scoreStr);
            printf("%s scored %d\n", name, score);
        }
    }

    fclose(fp);
    return 0;
}

For a scores.csv containing Alice,90\nBob,75\n, this prints each name/score pair. Real-world CSV parsing needs more care (quoted fields, embedded commas, escaped quotes) - this is the basic pattern, not a production-grade parser.


23. Common pitfalls checklist


Quick reference

FeatureFunction(s)PurposeSection
Open / closefopen, fcloseGet/release a FILE * handle1
File modes"r" "w" "a" "r+" "w+" "a+" (+"b")Control read/write/truncate/append behavior2
Write textfprintf, fputs, fputcFormatted or plain text output4
Read textfgets, fscanf, fgetcLine, formatted, or character input5
Line-by-line loopfgets in a while loopStandard whole-file text processing pattern6
EOF / error checkfeof, ferrorDistinguish normal end-of-file from a read error7
Binary I/Ofwrite, freadExact byte copy, no text translation8
Repositionfseek, ftell, rewindJump to/report a position within a file10
File size idiomfseek(...SEEK_END) + ftellPortable way to get a file's byte size11
Force write to diskfflushPush buffered data out immediately12
Change bufferingsetvbufChoose full/line/no buffering for a stream12
Whole-file readfread into a malloc'd bufferLoad an entire file into memory at once14
Auto-deleting scratch filetmpfileCreate a temp file removed on close/exit15
Rename / deleterename, removeOperate on a file by path, no FILE * needed16
Error diagnosticserrno, perror, strerrorDiscover and report why an operation failed18
Built-in streamsstdin, stdout, stderrPre-opened I/O streams, no fopen needed19
Low-level POSIX I/Oopen, read, write, closeUnbuffered, OS-level file descriptors20
Directory listing (POSIX)opendir, readdir, closedirIterate over a directory's contents21

Coverage note

This guide covers standard C file I/O end to end - opening, modes, text and binary reading/writing, struct serialization, positioning, buffering, platform-specific text-mode translation, temp files, renaming/deleting, error handling, and the standard streams - plus the POSIX-level facilities (low-level file descriptors, directory listing) that sit just outside the C standard but are used constantly in real systems code. If you've read every section here, you have everything needed to read, write, and debug file handling in essentially any C program you'll encounter.

Previous:
Miscellaneous Real-World C
Next:
Macros in C