C file I/O basics with fopen fclose fprintf fgets examples
|

C File I/O Basics: Read & Write Files Like a Pro in 2026

Every serious C program eventually needs to work with files. Whether you’re saving user data, reading configuration, parsing logs, or processing datasets — C file I/O is the gateway between your program and the outside world. Unlike languages with built-in file abstractions, C gives you direct, low-level control over every byte that enters or leaves a file.

If you’ve been following our C Programming Roadmap, you’ve already mastered C Pointers, C Dynamic Memory Allocation, and C Structs. Now it’s time to make your programs persistent — to read and write data that survives after your program exits.

What Is File I/O in C?

File I/O (Input/Output) refers to reading data from files and writing data to files on disk. In C, the standard library provides a set of functions in <stdio.h> that handle file operations through a concept called streams.

Think of a stream as a pipeline between your program and a file. Data flows through this pipeline — either from the file into your program (reading) or from your program into the file (writing). The C standard library manages buffering, encoding, and OS-level system calls behind the scenes, so you work with a clean, portable API.

There are two levels of file I/O in C:

// High-level (buffered) I/O — what we'll learn first
FILE *fp = fopen("data.txt", "r");  // uses stdio.h

// Low-level (unbuffered) I/O — OS-specific
int fd = open("data.txt", O_RDONLY);  // uses fcntl.h (POSIX)

This lesson focuses on high-level buffered I/O — the portable, standard approach that works on every platform. Low-level I/O is platform-specific and rarely needed for most applications.

Streams and FILE Pointers

Every file operation in C revolves around the FILE pointer. When you open a file, the system creates a FILE structure that tracks everything about that open file — its position, buffering mode, error state, and more.

#include <stdio.h>

FILE *fp;  // pointer to a FILE structure

// fp doesn't point to the file's contents
// It points to a structure that MANAGES the file

You never access the FILE structure’s members directly. Instead, you pass the FILE * pointer to library functions like fprintf(), fgets(), and fclose(). This is one of the earliest examples of opaque data types in C — a design pattern where implementation details are hidden behind a pointer.

The standard library pre-opens three streams for every program:

stdin   — standard input (keyboard by default)
stdout  — standard output (screen by default)
stderr  — standard error (screen by default, unbuffered)

Every time you’ve used printf() or scanf(), you’ve been using file I/O without realizing it. printf("hello") is equivalent to fprintf(stdout, "hello").

Opening Files with fopen()

The fopen() function opens a file and returns a FILE * pointer. If the operation fails (file doesn’t exist, permission denied, etc.), it returns NULL.

FILE *fopen(const char *filename, const char *mode);

The mode string determines what you can do with the file:

// Text modes
"r"   — Read only. File must exist.
"w"   — Write only. Creates new or TRUNCATES existing file.
"a"   — Append only. Creates new or appends to existing.
"r+"  — Read + write. File must exist.
"w+"  — Read + write. Creates new or TRUNCATES existing.
"a+"  — Read + append. Creates new or appends to existing.

// Binary modes (add 'b')
"rb", "wb", "ab", "rb+", "wb+", "ab+"

Warning: The "w" mode is dangerous — it destroys existing file contents immediately upon opening. Many beginners have lost data by accidentally opening a file with "w" instead of "a" or "r+".

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

int main(void) {
    FILE *fp = fopen("notes.txt", "r");
    
    if (fp == NULL) {
        perror("Error opening file");  // prints system error message
        return EXIT_FAILURE;
    }
    
    // ... work with the file ...
    
    fclose(fp);
    return EXIT_SUCCESS;
}

The perror() function is your best friend for file errors. It prints your message followed by the system’s error description (like “No such file or directory” or “Permission denied”). Always check the return value of fopen() — never assume a file opened successfully.

Closing Files with fclose()

Every file you open must be closed with fclose(). This flushes any buffered data to disk and releases system resources.

int fclose(FILE *stream);
// Returns 0 on success, EOF on error

Why closing matters:

// Data loss scenario
FILE *fp = fopen("log.txt", "w");
fprintf(fp, "Critical data here");
// Program crashes before fclose()
// Data may be lost — it's still in the buffer!

The operating system limits how many files a process can have open simultaneously (often 256-1024). If you open files in a loop without closing them, you’ll eventually hit this limit and fopen() will start returning NULL. This is a resource leak, similar to the memory leaks we discussed in C Memory Bugs.

// Resource leak — BAD
for (int i = 0; i < 10000; i++) {
    char name[64];
    sprintf(name, "file_%d.txt", i);
    FILE *fp = fopen(name, "r");  // eventually fails!
    // forgot fclose(fp)
}

// Correct — close when done
for (int i = 0; i < 10000; i++) {
    char name[64];
    sprintf(name, "file_%d.txt", i);
    FILE *fp = fopen(name, "r");
    if (fp) {
        // process file
        fclose(fp);  // always close
    }
}

Writing to Files: fprintf, fputs, fputc

C provides three main functions for writing to files:

fprintf() — Formatted Output

Works exactly like printf(), but writes to a file instead of the screen:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("students.txt", "w");
    if (!fp) { perror("fopen"); return 1; }
    
    // Write formatted data
    fprintf(fp, "Name: %s\n", "Alice");
    fprintf(fp, "Score: %d\n", 95);
    fprintf(fp, "GPA: %.2f\n", 3.87);
    
    fclose(fp);
    return 0;
}

// students.txt will contain:
// Name: Alice
// Score: 95
// GPA: 3.87

fputs() — Write a String

Writes a string to a file without any formatting. Does NOT add a newline automatically:

fputs("Hello, World!\n", fp);  // you must add \n yourself
fputs("Second line\n", fp);

fputc() — Write a Single Character

fputc('A', fp);       // writes one character
fputc('\n', fp);      // writes a newline

Practical Example: CSV Writer

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

typedef struct {
    char name[50];
    int age;
    float salary;
} Employee;

int main(void) {
    Employee team[] = {
        {"Alice", 28, 75000.00},
        {"Bob", 35, 92000.50},
        {"Carol", 42, 108000.75}
    };
    int count = sizeof(team) / sizeof(team[0]);
    
    FILE *fp = fopen("employees.csv", "w");
    if (!fp) { perror("fopen"); return EXIT_FAILURE; }
    
    // Write header
    fprintf(fp, "Name,Age,Salary\n");
    
    // Write data rows
    for (int i = 0; i < count; i++) {
        fprintf(fp, "%s,%d,%.2f\n", 
                team[i].name, team[i].age, team[i].salary);
    }
    
    fclose(fp);
    printf("Wrote %d records to employees.csv\n", count);
    return EXIT_SUCCESS;
}

Reading from Files: fscanf, fgets, fgetc

fgets() — Read a Line (Recommended)

fgets() is the safest and most commonly used function for reading text files. It reads up to n-1 characters or until a newline, whichever comes first:

char *fgets(char *str, int n, FILE *stream);
// Returns str on success, NULL on EOF or error
#include <stdio.h>

int main(void) {
    FILE *fp = fopen("notes.txt", "r");
    if (!fp) { perror("fopen"); return 1; }
    
    char line[256];
    int line_num = 0;
    
    while (fgets(line, sizeof(line), fp) != NULL) {
        line_num++;
        printf("Line %d: %s", line_num, line);
        // Note: fgets keeps the \n, so no need for extra newline
    }
    
    fclose(fp);
    return 0;
}

Important: fgets() keeps the trailing newline character (\n) in the buffer. To remove it:

#include <string.h>

fgets(line, sizeof(line), fp);
line[strcspn(line, "\n")] = '\0';  // strip newline

fscanf() — Formatted Input

Like scanf() but reads from a file. Useful for structured data, but fragile with malformed input:

// Reading structured data
FILE *fp = fopen("data.txt", "r");
char name[50];
int age;
float score;

while (fscanf(fp, "%49s %d %f", name, &age, &score) == 3) {
    printf("Read: %s, age %d, score %.1f\n", name, age, score);
}

The return value of fscanf() tells you how many items were successfully read. Always check it — this is how you detect the end of file or malformed data.

fgetc() — Read One Character

// Count characters in a file
int ch;
long count = 0;
while ((ch = fgetc(fp)) != EOF) {
    count++;
}
printf("File contains %ld characters\n", count);

Notice ch is declared as int, not char. That’s because fgetc() returns EOF (usually -1) to signal end-of-file, and EOF doesn’t fit in a char on some systems. This is a classic C gotcha.

Standard Streams: stdin, stdout, stderr

Since stdin, stdout, and stderr are just FILE * pointers, you can use any file function with them:

// These are equivalent:
printf("Hello\n");
fprintf(stdout, "Hello\n");

// These are equivalent:
scanf("%d", &x);
fscanf(stdin, "%d", &x);

// Error messages should go to stderr, not stdout
fprintf(stderr, "Error: invalid input\n");
// Why? Because stderr is unbuffered (immediate output)
// and can be redirected separately from stdout

This design principle — treating everything as a file — is one of the most powerful ideas in C and Unix programming. When you learn to redirect streams, your programs become composable building blocks, as we discussed in C Input & Output.

Complete Example: Simple Note-Taking App

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

#define FILENAME "notes.txt"
#define MAX_LINE 512

void add_note(const char *note) {
    FILE *fp = fopen(FILENAME, "a");  // append mode
    if (!fp) {
        perror("Cannot open notes file");
        return;
    }
    fprintf(fp, "%s\n", note);
    fclose(fp);
    printf("Note saved.\n");
}

void list_notes(void) {
    FILE *fp = fopen(FILENAME, "r");
    if (!fp) {
        printf("No notes yet.\n");
        return;
    }
    
    char line[MAX_LINE];
    int count = 0;
    
    printf("\n=== Your Notes ===\n");
    while (fgets(line, sizeof(line), fp)) {
        line[strcspn(line, "\n")] = '\0';
        count++;
        printf("  %d. %s\n", count, line);
    }
    
    if (count == 0) {
        printf("  (no notes)\n");
    }
    printf("==================\n\n");
    
    fclose(fp);
}

void clear_notes(void) {
    FILE *fp = fopen(FILENAME, "w");  // truncate
    if (!fp) {
        perror("Cannot clear notes");
        return;
    }
    fclose(fp);
    printf("All notes cleared.\n");
}

int main(void) {
    char input[MAX_LINE];
    
    printf("Simple Notes App (type 'help' for commands)\n");
    
    while (1) {
        printf("> ");
        if (!fgets(input, sizeof(input), stdin)) break;
        input[strcspn(input, "\n")] = '\0';
        
        if (strcmp(input, "list") == 0) {
            list_notes();
        } else if (strcmp(input, "clear") == 0) {
            clear_notes();
        } else if (strcmp(input, "quit") == 0) {
            break;
        } else if (strcmp(input, "help") == 0) {
            printf("Commands: list, clear, quit, or type a note to save\n");
        } else if (strlen(input) > 0) {
            add_note(input);
        }
    }
    
    printf("Goodbye!\n");
    return EXIT_SUCCESS;
}

Common File I/O Mistakes

1. Not Checking fopen() Return Value

// WRONG — will crash if file doesn't exist
FILE *fp = fopen("config.txt", "r");
fgets(line, sizeof(line), fp);  // NULL pointer dereference!

// CORRECT
FILE *fp = fopen("config.txt", "r");
if (fp == NULL) {
    perror("config.txt");
    return 1;
}

2. Using “w” When You Meant “a”

// This DESTROYS existing content!
FILE *fp = fopen("log.txt", "w");

// This PRESERVES existing content and adds to the end
FILE *fp = fopen("log.txt", "a");

3. Forgetting fgets() Keeps the Newline

char name[50];
fgets(name, sizeof(name), stdin);
// name is "Alice\n", not "Alice"

// Fix:
name[strcspn(name, "\n")] = '\0';

4. Using feof() Wrong

// WRONG — feof() only returns true AFTER a failed read
while (!feof(fp)) {
    fgets(line, sizeof(line), fp);
    printf("%s", line);  // prints last line twice!
}

// CORRECT — check the return value of fgets()
while (fgets(line, sizeof(line), fp) != NULL) {
    printf("%s", line);
}

Best Practices

Always check return values. fopen() can fail for dozens of reasons — missing file, permission denied, disk full, too many open files. Use perror() to get meaningful error messages.

Close files as soon as you’re done. Don’t keep files open longer than necessary. This prevents resource leaks and ensures data is flushed to disk.

Use fgets() over fscanf() for text. fgets() reads a whole line safely with bounds checking. fscanf() with %s can overflow buffers if you forget width specifiers, as we covered in C String Functions.

Write errors to stderr. Use fprintf(stderr, ...) for error messages so they appear even when stdout is redirected to a file.

Use EXIT_SUCCESS and EXIT_FAILURE. These constants from <stdlib.h> make your exit codes portable and self-documenting.

Summary

C file I/O revolves around FILE * pointers and a small set of functions: fopen() to open, fclose() to close, fprintf()/fputs() to write, and fgets()/fscanf() to read. The key principles are: always check return values, always close your files, and use fgets() for safe line-by-line reading. In the next lesson, we’ll explore the difference between text and binary file modes — and why it matters more than you think.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *