Topic 88 of 100 File Handling Interactive Tutorial

C File Input and Output Operations: Complete Practical Guide

14 mins read Last updated: 22 Aug 2026 27,600 views
C Compiler 0.00%

What is C File Input and Output Operations? Core Concept & Direct Summary

Core Objective & Problem Statement

How do binary file functions (`fread()`, `fwrite()`, `fseek()`, `ftell()`, `rewind()`) read and write binary structures on disk?

Direct Answer Summary

Most C tutorials teach file I/O function by function: here's fopen, here's fgets, here's fwrite. That's the right way to learn the topic — but it's the wrong way to use it. When you actually sit down to write code, you don't think "I need fgets." You think:

  • "How do I copy this file?"
  • "How do I count the lines?"
  • "How do I find which line contains this word?"
  • "How do I change one record inside a file?"
  • "How do I read the whole thing into memory?"

This page is organized that way — by task. Each recipe is complete, self-contained, and correct, with the error checking that real programs need and toy examples omit.

But first, one organizing framework, because it explains which function to reach for.

(Suggested image: the hero graphic — a segmented bar of the four I/O levels (character / line / formatted / block) with the corresponding function pairs labeled under each segment)

How Does C File Input and Output Operations Work? In-Depth Mechanics & Architecture

The Four Levels of File I/O

Every file operation in C sits at one of four granularities. Identifying the level tells you the function almost automatically.

LevelOutput functionsInput functionsUnit of operationUse when
1. Characterfputc, putcfgetc, getcOne byteCounting, scanning, byte-level work, binary inspection
2. LinefputsfgetsOne line (or buffer-full)Text files, config, logs, CSV — the everyday default
3. Formattedfprintffscanf, sscanfTyped, formatted fieldsStructured text with a known layout
4. Block / directfwritefreadN items of fixed sizeBinary data, structs, images, bulk copying

How to choose:

  • Processing every byte or counting things? → Character level. fgetc in a loop.
  • Reading text records of unknown length? → Line level. fgets, then parse.
  • The file has a strict, regular structure? → Formatted. But prefer line level + sscanf, which is more robust.
  • Performance matters, or the data is binary? → Block level. fread/fwrite in large chunks.

Two notes on the near-duplicate functions:

  • getc vs fgetc and putc vs fputc are functionally identical. The difference: getc/putc may be implemented as macros (potentially faster, but you can't take their address or pass them as arguments without care), while fgetc/fputc are guaranteed real functions. Prefer fgetc/fputc for predictability.
  • ungetc(c, fp) pushes one character back onto the stream — useful when a parser reads one character too many and needs to "unread" it. Only one character of pushback is guaranteed.

And the "formatted vs unformatted" vocabulary you'll see everywhere: character, line, and block I/O are unformatted (they handle raw characters/bytes/lines); fprintf/fscanf are formatted (they interpret text according to a format string).

(Suggested image: a decision tree — "need every byte? no → records of text? yes → lines are unbounded? → fgets + sscanf" — mapping to the four levels)


Operation Reference

Opening and closing

OperationCallReturns
Openfopen(path, mode)FILE * or NULL
Open on existing fdfdopen(fd, mode)FILE * or NULL
Redirect an existing streamfreopen(path, mode, stream)FILE * or NULL
Closefclose(fp)0 on success, EOF on failure
Flush without closingfflush(fp)0 on success, EOF on failure
Create an auto-deleted temp filetmpfile()FILE * or NULL

Input (reading)

FunctionReadsReturns on successReturns at EOF
fgetc(fp)1 characterthe character (as int)EOF
fgets(buf, n, fp)≤ n-1 chars, stops at \nbufNULL
fscanf(fp, fmt, ...)formatted fieldscount of items matchedEOF
fread(p, size, n, fp)n items of size bytesitems read0
ungetc(c, fp)pushes back 1 charthe character, or EOF—

Output (writing)

FunctionWritesReturns on successReturns on failure
fputc(c, fp)1 characterthe characterEOF
fputs(s, fp)a string (no newline added)non-negativeEOF
fprintf(fp, fmt, ...)formatted outputchars writtennegative
fwrite(p, size, n, fp)n items of size bytesitems written< n

Positioning (binary streams)

FunctionPurpose
fseek(fp, offset, origin)Move to a position; origin = SEEK_SET / SEEK_CUR / SEEK_END
ftell(fp)Current position (long, or -1L on error)
rewind(fp)Back to start (also clears the error flag)
fgetpos(fp, &pos) / fsetpos(fp, &pos)Larger-file-safe position save/restore

Status and errors

FunctionMeaning
feof(fp)The last read hit end-of-file (not "you're at the end now")
ferror(fp)The last operation hit an I/O error
clearerr(fp)Reset both flags
perror("context")Print context + the OS's error description to stderr

File-level operations

FunctionPurpose
remove(path)Delete a file
rename(old, new)Rename or move (POSIX: also replaces atomically)
setvbuf(fp, buf, mode, size)Control buffering (_IOFBF, _IOLBF, _IONBF)

(Suggested image: the reference tables as a printable one-page cheat sheet — high shareability)


Recipe 1 — Does This File Exist?

main.c
Run Online
int fileExists(const char *path) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) return 0;
    fclose(fp);
    return 1;
}

The straightforward approach: if you can open it for reading, it exists.

Caveats worth knowing:

  • It doesn't tell you why it failed — missing, no permission, and bad path all return "no." If you need the reason, open and keep the FILE *:

    main.c
    Run Online
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        perror(path);
        return 1;
    }
    /* tells you: Permission denied, etc. */
  • Some systems let you "open" a directory for reading, so this check can report a directory as an existing file. stat() (POSIX) distinguishes them, but it's not standard C.

  • There's a race: the file could vanish between your check and your use. So this is for reporting ("file not found, sorry"), not for guarding logic. Open once and handle NULL — that's always the safer structure.


Recipe 2 — Get the File Size

main.c
Run Online
long fileSize(const char *path, int *ok) {
    *ok = 0;
    FILE *fp = fopen(path, "rb");
    /* "rb" -> exact bytes, no translation */ if (fp == NULL) return -1;
    if (fseek(fp, 0, SEEK_END) != 0) {
        fclose(fp);
        return -1;
    }
    long size = ftell(fp);
    if (size < 0) {
        fclose(fp);
        return -1;
    }
    /* ftell can fail */
    fclose(fp);
    *ok = 1;
    return size;
}

How it works: jump to the end, ask where you are.

Three details that make it correct:

  1. Binary mode is mandatory. In text mode on Windows, \r\n translation means the reported position may not equal the byte count — you'd get 106 for a file that occupies 111 bytes on disk.
  2. Both fseek and ftell can fail — pipes, terminals, and some devices aren't seekable. Check both.
  3. Use an ok flag, not -1 alone, because -1 is a legitimate "error" but so is a zero-byte file plus an error path you didn't distinguish. Separating "value" from "success" avoids ambiguity — the same pattern as returning a count from a lookup plus a -1 not-found signal.

On non-seekable streams (a pipe, stdin from a pipe), you can't measure size this way. You have to read until EOF and count.


Recipe 3 — Copy a File (Binary-Safe)

main.c
Run Online
#define COPY_BUF 4096
int copyFile(const char *src, const char *dst) {
    FILE *in = fopen(src, "rb");
    if (in == NULL) {
        perror(src);
        return 0;
    }
    FILE *out = fopen(dst, "wb");
    if (out == NULL) {
        perror(dst);
        fclose(in);
        return 0;
    }
    /* don't leak `in` */
    char buf[COPY_BUF];
    size_t n;
    int ok = 1;
    while ((n = fread(buf, 1, sizeof buf, in)) > 0) {
        if (fwrite(buf, 1, n, out) != n) {
            perror("write");
            ok = 0;
            break;
        }
    }
    if (ferror(in)) {
        perror("read");
        ok = 0;
    }
    if (fclose(out) != 0) {
        perror("fclose out");
        ok = 0;
    }
    fclose(in);
    return ok;
}

Why this version is right, in five points:

  1. Both modes are binary. A text-mode copy on Windows can expand bytes, corrupting images, archives, and executables.
  2. fread's return value is the loop condition. It returns items read — with size = 1, that's bytes. 0 means EOF or error; the ferror check afterwards tells you which.
  3. Blocks, not bytes. Copying 4 KB at a time means one fread per 4096 bytes instead of per byte — orders of magnitude fewer function calls and syscalls.
  4. The partial-read handling.fread may return fewer items than requested, and fwrite may write fewer. Passing n (what you actually got) to fwrite and comparing its return is the correct pairing.
  5. The leaked-handle guard. If opening out fails, the input file must be closed on that path too. In a multi-file operation, every failure branch needs to release what's already open — the same ownership discipline as malloc/free.

(Suggested image: the copy loop as a pipeline — 4 KB block read from source, block written to destination, repeated until fread returns 0 — with the equal-return check marked)


Recipe 4 — Count Lines, Words, and Characters

main.c
Run Online
int countFile(const char *path, long *lines, long *words, long *chars) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) return 0;
    *lines = *words = *chars = 0;
    int c, inWord = 0;
    while ((c = fgetc(fp)) != EOF) {
        /* c MUST be int */(*chars)++;
        if (c == '\n')(*lines)++;
        if (isspace(c)) {
            inWord = 0;
        } else if (!inWord) {
            inWord = 1;
(*words)++;
        }
    }
    if (ferror(fp)) {
        fclose(fp);
        return 0;
    }
    fclose(fp);
    return 1;
}

The word-counting logic is a two-state machine: a word begins at a transition from whitespace to non-whitespace. That correctly handles multiple spaces, tabs, and leading/trailing whitespace — and it's how wc works.

Two requirements worth restating: int c (a char can't distinguish a 0xFF byte from EOF), and ferror after the loop (a disk error must not be reported as a clean end-of-file).


Recipe 5 — Read a Whole File Into Memory

main.c
Run Online
#include <stdlib.h>
char *readWholeFile(const char *path, long *outLen) {
    int ok;
    long size = fileSize(path, &ok);
    /* Recipe 2 */ if (!ok) return NULL;
    FILE *fp = fopen(path, "rb");
    if (fp == NULL) return NULL;
    char *buf = malloc((size_t)size + 1);
    /* +1 for the terminator */ if (buf == NULL) {
        fclose(fp);
        return NULL;
    }
    size_t got = fread(buf, 1,(size_t)size, fp);
    fclose(fp);
    buf[got] = '\0';
    /* so it works as a C string */ if (outLen) *outLen =(long)got;
    return buf;
    /* caller must free() */
}

Four things to get right:

  • size + 1 — you need room for the NUL terminator if the buffer is to be used as a string.
  • Trust got, not size. The file could have changed between measuring and reading. got is what actually arrived.
  • The caller owns the buffer — document it. The malloc/free pairing is the caller's responsibility, exactly as the dynamic memory tutorial describes.
  • It loads everything at once — fine for a config file or a few megabytes, a bad idea for a multi-gigabyte log. For large files, stream with fgets or fread blocks instead.

Recipe 6 — Search a File, Report Line Numbers

main.c
Run Online
#define LINE_MAX 256
int searchFile(const char *path, const char *needle) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        perror(path);
        return -1;
    }
    char line[LINE_MAX];
    int lineno = 0, matches = 0;
    while (fgets(line, sizeof line, fp) != NULL) {
        lineno++;
        if (strstr(line, needle) != NULL) {
            printf("  line %2d: %s", lineno, line);
            /* line keeps its \n */ matches++;
        }
    }
    if (ferror(fp)) {
        perror("read error");
        fclose(fp);
        return -1;
    }
    fclose(fp);
    return matches;
}

Why the loop counter is correct here:lineno increments on every fgets success, before the match test — so line numbers are true positions. The one subtlety: when a line exceeds LINE_MAX, fgets returns it in multiple pieces, and each piece counts as a "line." That's the fgets long-line behavior covered in the mistakes section — for a line-number tool, either size the buffer generously or detect the split (see mistake #12).

strstr gives substring matching. Swap in strncmp for a "starts with" test, or strcmp for an exact-match test.

(Suggested image: the search loop with lineno incrementing before the test, and the three matching lines highlighted with their numbers)


Recipe 7 — Filter Lines Into a New File

main.c
Run Online
int filterFile(const char *src, const char *dst, const char *prefix) {
    FILE *in = fopen(src, "r");
    if (in == NULL) {
        perror(src);
        return 0;
    }
    FILE *out = fopen(dst, "w");
    if (out == NULL) {
        perror(dst);
        fclose(in);
        return 0;
    }
    char line[LINE_MAX];
    size_t plen = strlen(prefix);
    int kept = 0;
    while (fgets(line, sizeof line, in) != NULL) {
        if (strncmp(line, prefix, plen) == 0) {
            fputs(line, out);
            /* line already carries its newline */ kept++;
        }
    }
    if (ferror(in)) {
        perror("read");
        fclose(out);
        fclose(in);
        return 0;
    }
    if (fclose(out) != 0) {
        perror("fclose");
        fclose(in);
        return 0;
    }
    fclose(in);
    return kept;
}

fputs writes the line including the newline it still holds from fgets — so the output file keeps proper line structure. Using fprintf(out, "%s\n", line) here would double the newline.

This is the shape of grep-style tools, log extractors, and config submitters.


Recipe 8 — Read a Delimited File Into Structs

main.c
Run Online
#define NAME_MAX 48
typedef struct {
    int id;
    char name[NAME_MAX];
    double price;
    int quantity;
}
Product;
int loadProducts(const char *path, Product *out, int maxItems) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        perror(path);
        return -1;
    }
    char line[LINE_MAX];
    int n = 0;
    while (n < maxItems && fgets(line, sizeof line, fp) != NULL) {
        line[strcspn(line, "\r\n")] = '\0';
        /* strip the newline */ if (line[0] == '\0') continue;
        /* skip blank lines */
        if (sscanf(line, "%d|%47[^|]|%lf|%d", &out[n].id, out[n].name, &out[n].price, &out[n].quantity) == 4) {
            n++;
        } else {
            fprintf(stderr, "Skipping malformed line: %s\n", line);
        }
    }
    if (ferror(fp)) {
        perror("read error");
        fclose(fp);
        return -1;
    }
    fclose(fp);
    return n;
    /* records loaded */
}

The details that make this production-grade:

  • n < maxItems is checked first — never write past the caller's buffer, no matter how many lines the file has.
  • %47[^|] — reads up to 47 characters and stops at the |, so names with spaces work, and the width prevents overflow into name[48].
  • == 4 — the parse succeeded only if all four fields converted. Anything else means a corrupt row.
  • Skip-and-continue on bad lines, with a message to stderr. One malformed row doesn't abort the load or poison the data.
  • Returns the count loaded, and -1 only for a genuine open/read failure — distinguishing "couldn't open" from "opened but the file was empty."

Recipe 9 — Write Structs Back Out

main.c
Run Online
int saveProducts(const char *path, const Product *items, int n) {
    FILE *fp = fopen(path, "w");
    if (fp == NULL) {
        perror(path);
        return 0;
    }
    for (int i = 0; i < n; i++) {
        if (fprintf(fp, "%d|%s|%.2f|%d\n", items[i].id, items[i].name, items[i].price, items[i].quantity) < 0) {
            perror("write");
            /* disk full, etc. */ fclose(fp);
            return 0;
        }
    }
    if (fclose(fp) != 0) {
        perror("fclose");
        return 0;
    }
    /* flush can fail! */ return 1;
}

Two checks that matter more than they look:

  • fprintf's return is negative on failure. Disks fill up and quotas get hit mid-loop. Checking each write turns a silent truncation into a reported error.
  • fclose's return is where buffered writes finally land. This is the last chance to detect that the data never made it — and skipping the check means your program reports success on a partial file.

And use %s with a delimited format for human-readable, diff-friendly, portable files. For binary output you'd use fwrite, with the portability caveats from the file handling pillar page.


Recipe 10 — Update a Record In Place

There's no "seek to line 3 and rewrite it" when lines have variable length. The reliable technique: write a new file, then replace the old one atomically.

main.c
Run Online
int updateQuantity(const char *path, int targetId, int newQty) {
    FILE *in = fopen(path, "r");
    if (in == NULL) {
        perror(path);
        return 0;
    }
    const char *tmpName = "products.tmp";
    FILE *tmp = fopen(tmpName, "w");
    if (tmp == NULL) {
        perror(tmpName);
        fclose(in);
        return 0;
    }
    char line[LINE_MAX];
    int updated = 0;
    while (fgets(line, sizeof line, in) != NULL) {
        line[strcspn(line, "\r\n")] = '\0';
        Product p;
        if (sscanf(line, "%d|%47[^|]|%lf|%d", &p.id, p.name, &p.price, &p.quantity) == 4) {
            if (p.id == targetId) {
                p.quantity = newQty;
                updated = 1;
            }
            fprintf(tmp, "%d|%s|%.2f|%d\n", p.id, p.name, p.price, p.quantity);
        } else {
            fputs(line, tmp);
            /* pass through anything unparseable */ fputc('\n', tmp);
        }
    }
    int readOk = !ferror(in);
    fclose(in);
    if (fclose(tmp) != 0) {
        perror("fclose");
        remove(tmpName);
        return 0;
    }
    if (!readOk) {
        remove(tmpName);
        return 0;
    }
    /* don't replace good data with partial */ if (!updated) {
        remove(tmpName);
        return 0;
    }
    /* nothing changed — leave it alone */
    remove(path);
    /* Windows needs this before rename */ if (rename(tmpName, path) != 0) {
        perror("rename");
        return 0;
    }
    return 1;
}

Why this pattern is the correct one:

  1. Variable-length lines can't be rewritten in place. Changing 3 to 3000 shifts every following byte, so you'd have to rewrite the remainder anyway. Writing a fresh file is simpler and safer.
  2. The original survives until the end. If anything fails — read error, disk full, write error — the old file is untouched. With in-place rewriting, a crash mid-way leaves you with neither version.
  3. rename is the atomic swap. On POSIX it replaces the destination in one operation. On Windows you must remove first, and the window between remove and rename is non-atomic — worth knowing if power loss is a concern.
  4. Unparseable lines are passed through, not dropped. A data file with a comment line shouldn't lose it during an update.
  5. Nothing changed? Don't touch the file. Skipping the replace avoids needless writes and keeps modification times meaningful.

This is how editors, databases, and config tools do it. It's also why the pattern shows up in interviews.

(Suggested image: the temp-file-and-rename flow — original file read, new file written, original removed, temp renamed into place, with "original intact until success" labeled)


Recipe 11 — Append a Timestamped Log Line

main.c
Run Online
#include <time.h>
int logEvent(const char *path, const char *level, const char *message) {
    FILE *fp = fopen(path, "a");
    /* "a": never truncates */ if (fp == NULL) {
        perror(path);
        return 0;
    }
    time_t now = time(NULL);
    char stamp[32];
    strftime(stamp, sizeof stamp, "%Y-%m-%d %H:%M:%S", localtime(&now));
    fprintf(fp, "[%s] %-8s %s\n", stamp, level, message);
    return fclose(fp) == 0;
}

Mode "a" is the whole point — it cannot destroy existing content, and every write goes to the end regardless of where the read position is.

On buffering: a log that must survive a crash should be flushed promptly:

main.c
Run Online
fflush(fp);
/* push it out now *//* or disable buffering on the stream: */setvbuf(fp, NULL, _IONBF, 0);
/* unbuffered — slower, but immediate */

Real logging often wants the file opened once and kept open, rather than reopened per message.

(Suggested image: the append path — existing log lines preserved above, a new timestamped line added at the end, with mode "a" labeled "never truncates")


Recipe 12 — Clear or Truncate a File

main.c
Run Online
int clearFile(const char *path) {
    FILE *fp = fopen(path, "w");
    /* opening in "w" truncates to zero bytes */ if (fp == NULL) {
        perror(path);
        return 0;
    }
    return fclose(fp) == 0;
}

Note the subtlety: you don't need to write anything. Opening a file with "w" truncates it immediately, in the fopen call itself. That's why "w" is dangerous by accident — and convenient by intent.

To avoid creating the file if it doesn't exist, check first:

main.c
Run Online
if (fileExists(path)) {
    /* Recipe 1 */ /* truncate */
}

Recipe 13 — Merge Several Files Into One

main.c
Run Online
int mergeFiles(const char *dst, const char *const sources[], int count) {
    FILE *out = fopen(dst, "wb");
    if (out == NULL) {
        perror(dst);
        return 0;
    }
    char buf[COPY_BUF];
    int ok = 1;
    for (int i = 0; i < count; i++) {
        FILE *in = fopen(sources[i], "rb");
        if (in == NULL) {
            /* skip an unreadable source */ perror(sources[i]);
            ok = 0;
            continue;
        }
        size_t n;
        while ((n = fread(buf, 1, sizeof buf, in)) > 0) {
            if (fwrite(buf, 1, n, out) != n) {
                perror("write");
                ok = 0;
                break;
            }
        }
        if (ferror(in)) {
            perror(sources[i]);
            ok = 0;
        }
        fclose(in);
    }
    if (fclose(out) != 0) {
        perror("fclose");
        ok = 0;
    }
    return ok;
}

Binary mode and block copying make this work for text and binary sources. The loop opens, drains, and closes one source at a time — so you never hold more than two handles open, no matter how many files you merge.


Recipe 14 — Redirect stdout to a File

Instead of passing a FILE * to every printing function, redirect the stream itself:

main.c
Run Online
if (freopen("report.txt", "w", stdout) == NULL) {
    perror("freopen");
    return 1;
}
printf("This line goes to report.txt, not the console\n");
fprintf(stdout, "So does this\n");
/* Restore stdout if you need the console back:   freopen("CON", "w", stdout);        (Windows)   freopen("/dev/tty", "w", stdout);   (POSIX)*/

The uses: making a program's normal printf output land in a file with zero code changes, which is exactly what shell redirection does (myprog > report.txt) — except freopen does it inside the program, so it works even when the program is launched by double-clicking.

Two cautions:

  • Flush stdout before redirecting away (fflush(stdout)), or buffered console output may end up in the file.
  • Keep errors on stderr. Redirecting stdout to a file means any diagnostics you send there are invisible; fprintf(stderr, ...) still reaches the console.

Recipe 15 — Temporary Files

main.c
Run Online
FILE *tmp = tmpfile();
/* in the temp directory */if (tmp == NULL) {
    perror("tmpfile");
    return 1;
}
fprintf(tmp, "scratch data\n");
rewind(tmp);
/* rewind to read what you wrote */
char buf[64];
if (fgets(buf, sizeof buf, tmp) != NULL) {
    printf("Read back: %s", buf);
}
fclose(tmp);
/* the file is deleted automatically */

tmpfile() creates a file and arranges for it to be removed automatically on close or normal program termination — no name to manage, no cleanup to forget, no chance of colliding with a real file. Ideal for scratch space you need to read back during a single run.

Note the rewind: after writing, the position is at the end. You must reposition before reading (fflush + rewind, or fseek).

And a caveat:tmpfile() may fail if the temp directory is unwritable or full — always check for NULL.


Buffering and Performance

Buffering is why fgetc in a loop isn't as slow as it looks: C reads a large block from the OS, then serves your single-character requests from memory. Understanding it explains performance surprises:

PatternRelative costNotes
fgetc character by characterFineThe buffer does the real work
fgets line by lineFineSame buffering benefit
fread in 4–64 KB blocksFastest for bulkFewest function calls
setvbuf(fp, NULL, _IONBF, 0)Much slowerOnly for logs that must not lag
Many tiny fopen/fclose callsSlowCost is per-open, and handle-limited

When to intervene:

  • Bulk copying or processing megabytes → use fread/fwrite with a 4 KB–64 KB buffer (Recipe 3).
  • A log or progress indicator that must appear instantly → fflush at the right moments, or _IONBF if correctness demands it.
  • Thousands of small writes → batch them into one buffer and do one fwrite. The single biggest file-I/O performance mistake is one fprintf per byte.
  • Reading the same file repeatedly → read it once into memory (Recipe 5) if it fits.

(Suggested image: the buffering diagram — many small program calls served from one memory buffer, with large periodic reads from the OS crossing the boundary)


📋 Complete Program: A File Operations Tool

All the core operations in one runnable tool — create, measure, count, search, and copy.

main.c
Run Online
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#define LINE_MAX 256
#define COPY_BUF 4096
/* ---- Recipe 2: size on disk (binary read = exact bytes) ---- */long fileSize(const char *path, int *ok) {
    *ok = 0;
    FILE *fp = fopen(path, "rb");
    if (fp == NULL) return -1;
    if (fseek(fp, 0, SEEK_END) != 0) {
        fclose(fp);
        return -1;
    }
    long size = ftell(fp);
    if (size < 0) {
        fclose(fp);
        return -1;
    }
    fclose(fp);
    *ok = 1;
    return size;
}
/* ---- Recipe 4: counts (text read) ---- */int countFile(const char *path, long *lines, long *words, long *chars) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) return 0;
    *lines = *words = *chars = 0;
    int c, inWord = 0;
    while ((c = fgetc(fp)) != EOF) {
(*chars)++;
        if (c == '\n')(*lines)++;
        if (isspace(c)) {
            inWord = 0;
        } else if (!inWord) {
            inWord = 1;
(*words)++;
        }
    }
    if (ferror(fp)) {
        fclose(fp);
        return 0;
    }
    fclose(fp);
    return 1;
}
/* ---- Recipe 6: search with line numbers ---- */int searchFile(const char *path, const char *needle) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        perror(path);
        return -1;
    }
    char line[LINE_MAX];
    int lineno = 0, matches = 0;
    while (fgets(line, sizeof line, fp) != NULL) {
        lineno++;
        if (strstr(line, needle) != NULL) {
            printf("  line %2d: %s", lineno, line);
            matches++;
        }
    }
    if (ferror(fp)) {
        perror("read error");
        fclose(fp);
        return -1;
    }
    fclose(fp);
    return matches;
}
/* ---- Recipe 3: binary-safe copy ---- */int copyFile(const char *src, const char *dst) {
    FILE *in = fopen(src, "rb");
    if (in == NULL) {
        perror(src);
        return 0;
    }
    FILE *out = fopen(dst, "wb");
    if (out == NULL) {
        perror(dst);
        fclose(in);
        return 0;
    }
    char buf[COPY_BUF];
    size_t n;
    int ok = 1;
    while ((n = fread(buf, 1, sizeof buf, in)) > 0) {
        if (fwrite(buf, 1, n, out) != n) {
            perror("write");
            ok = 0;
            break;
        }
    }
    if (ferror(in)) {
        perror("read");
        ok = 0;
    }
    if (fclose(out) != 0) {
        perror("fclose out");
        ok = 0;
    }
    fclose(in);
    return ok;
}
int main(void) {
    const char *path = "report.txt";
    long lines = 0, words = 0, chars = 0, size;
    int ok;
    puts("=== FILE I/O OPERATIONS DEMO ===");
    /* ---- 1) create the file ---- */ puts("\n--- 1) Create the file (mode \"w\") ---");
    FILE *fp = fopen(path, "w");
    if (fp == NULL) {
        perror(path);
        return 1;
    }
    fputs("Error: disk full\n", fp);
    fputs("Warning: low memory\n", fp);
    fputs("Error: file not found\n", fp);
    fputs("Info: backup complete\n", fp);
    fputs("Error: permission denied\n", fp);
    if (fclose(fp) != 0) {
        perror("fclose");
        return 1;
    }
    puts("Wrote 5 lines to report.txt");
    /* ---- 2) size on disk ---- */ puts("\n--- 2) Size on disk (binary read) ---");
    size = fileSize(path, &ok);
    if (!ok) {
        perror(path);
        return 1;
    }
    printf("On-disk size: %ld bytes\n", size);
    /* ---- 3) counts ---- */ puts("\n--- 3) Counts (text read) ---");
    if (!countFile(path, &lines, &words, &chars)) {
        perror(path);
        return 1;
    }
    printf("Lines: %ld   Words: %ld   Characters (text mode): %ld\n", lines, words, chars);
    /* ---- 4) search ---- */ puts("\n--- 4) Search for \"Error\" ---");
    int matches = searchFile(path, "Error");
    if (matches >= 0) {
        printf("Lines containing \"Error\": %d\n", matches);
    }
    /* ---- 5) copy ---- */ puts("\n--- 5) Copy the file (binary-safe) ---");
    if (copyFile(path, "report_copy.txt")) {
        long csize = fileSize("report_copy.txt", &ok);
        printf("Copied to report_copy.txt (%ld bytes)\n", ok ? csize : -1L);
    } else {
        puts("Copy failed");
    }
    return 0;
}

Sample Run (every value verified by executing the operations)

main.c
Run Online
=== FILE I/O OPERATIONS DEMO ===
--- 1) Create the file(mode "w") ---Wrote 5 lines to report.txt
--- 2) Size on disk(binary read) ---On-disk size: 111 bytes
--- 3) Counts(text read) ---Lines: 5 Words: 16 Characters(text mode): 106
--- 4) Search for "Error" --- line 1: Error: disk full line 3: Error: file not found line 5: Error: permission deniedLines containing "Error": 3
--- 5) Copy the file(binary-safe) ---Copied to report_copy.txt(111 bytes)

The number that teaches the most: 111 vs 106

Same file. fileSize reports 111 bytes; the text-mode count reports 106 characters. That 5-byte gap is not a bug — it's one \r per line.

The program wrote five "\n" characters in text mode. On Windows, text mode translated each to \r\n on disk:

main.c
Run Online
Line chars + CRLF = on disk"Error: disk full" 16 2 18"Warning: low memory" 19 2 21"Error: file not found" 21 2 23"Info: backup complete" 21 2 23"Error: permission denied" 24 2 26 --- --- 101 + 5 = 106 ... plus 5 CR = 111

Those two functions reached different byte counts because they used different modes — "rb" for the exact on-disk size, "r" to get content back with newlines normalized to \n. On Linux, both would report 106.

That's the practical value of the four-level framework: the level you choose decides whether you're counting storage bytes or content characters, and those are legitimately different numbers.

(Suggested image: the same file shown twice — as disk bytes with visible \r\n markers (111) and as logical content lines (106) — with the 5-byte difference attributed to newline translation)


Common Mistakes

These are the operational pitfalls — complementary to the open/close lifecycle mistakes covered in the file handling guide.

  1. Sizing a "read whole file" buffer as size instead of size + 1 — no room for the NUL terminator; string functions run off the end.

  2. Trusting the ftell size for how many bytes fread will return — the file can change in between. Use fread's return value.

  3. Assuming fread returns bytes — it returns items. With size > 1, the number is smaller. Compare against the item count you requested.

  4. Copying in text mode — expansion corrupts binary files. Use "rb"/"wb".

  5. fgets with too small a buffer on long lines — a 1000-character line into a char[64] returns 16 "lines." Line counts inflate, records split, and each piece looks like valid data. Not reported as an error. Size buffers generously or detect the missing newline:

    main.c
    Run Online
    if (fgets(line, sizeof line, fp) != NULL && strchr(line, '\n') == NULL) {
        /* line was truncated — handle or report it */
    }
  6. Leaking an open handle when a later open fails — in copy/merge operations, every failure branch must close what's already open.

  7. Using rename to overwrite on Windows without remove first — the call fails, leaving a stray temp file and an unmodified original.

  8. Replacing the original file before verifying the new one was written completely — check your write andfclose returns, then swap.

  9. fseek on a non-seekable stream — pipes, terminals, and some devices can't be positioned; fseek fails. Check its return and fall back to sequential reading.

  10. Ignoring that ftell can return -1 — a bad position treated as a size silently produces nonsense.

  11. Not flushing stdout before freopen or exit — buffered output can be lost or land in the wrong place.

  12. Redirecting stdout and losing error messages — keep diagnostics on stderr.

  13. Reading a file you're still writing to — unless you fflush or fclose first, the data may not be on disk yet, so the reader sees a short or empty file.

  14. Opening a temp file by a fixed name in a shared directory — another process can clobber it. Prefer tmpfile(), or a name including a PID/timestamp.

  15. One fprintf per byte in a loop — the dominant performance mistake. Batch into a buffer and write once.

  16. ferror never checked — a disk error reported as a clean end-of-file means you silently accept truncated data as complete.

  17. Assuming file operations are atomic — a crash between "remove original" and "rename temp" on Windows leaves no file. Keep backups where that matters.

(Suggested image: "17 File I/O Operations Mistakes" checklist — with #5, #8, and #13 highlighted as the ones that produce plausible-but-wrong results)


Final Thoughts

File I/O operations in C reduce to a small, learnable set of moves once you organize them by level — character, line, formatted, block — and by task.

The recipes on this page cover the overwhelming majority of what real programs need: measure a file, count its contents, search it, filter it, load it into structs, write it back, update one record safely, append to a log, merge several files, copy it in blocks, and clean up after yourself. Each one is short. What makes them reliable rather than merely working is a consistent set of habits applied every time:

  • Check every fopen for NULL, and close what you opened on every exit path
  • Trust the read function's return value, never feof — and check ferror to tell an error from an ending
  • Match the mode to the data — "b" for anything that isn't text
  • Use int for fgetc, and bound every buffer and every %s
  • Read a line, then parse it — fgets + sscanf survives malformed input; a bare fscanf loop often doesn't
  • Check fprintf/fwrite returns and fclose's return — that's where buffered failures surface
  • Never rewrite a file in place — write a temp file and swap it, so a failure leaves the original intact

Get those right and the difference between code that works on your machine and code that works everywhere, on any input, becomes small and mechanical — which is exactly what you want from the most mundane and most depended-upon part of a program.

Runnable C Code Example & Practical Implementation

main.c
Run Online
#include <stdio.h>
typedef struct {
    int id;
    char name[20];
    double balance;
}
Account;
int main() {
    Account accWrite = {
        1001, "John Doe", 4500.75
    };
    // Write binary record
    FILE *fp = fopen("account.bin", "wb");
    if (fp) {
        fwrite(&accWrite, sizeof(Account), 1, fp);
        fclose(fp);
    }
    // Read binary record back
    Account accRead;
    fp = fopen("account.bin", "rb");
    if (fp) {
        fread(&accRead, sizeof(Account), 1, fp);
        fclose(fp);
        printf("Binary Record: ID: %d | Name: %s | Balance: $%.2f\
",
        accRead.id, accRead.name, accRead.balance);
    }
    return 0;
}

Key Takeaways, Best Practices & Memory Safety Rules

Best Practice & Key Takeaways
Always open binary files with "rb" or "wb" modes to prevent Windows newline translation from corrupting binary image/data files.

Frequently Asked Questions & Technical Interview Insights

Technical Interview & Viva Questions

Frequently asked C programming interview questions
Q

Q: How do you determine the total size of a file in bytes using standard C library functions?

A: Open in binary mode `"rb"`, seek to the end with `fseek(fp, 0, SEEK_END)`, get byte offset with `long size = ftell(fp);`, and reset with `rewind(fp);`.
Q

Q: What do `fread` and `fwrite` return?

A: The number of complete items (not bytes) successfully read or written.

Related Tools, Tutorials & Authoritative References

Recommended Interactive Tools

Authoritative Standards & Citations