advanced-c Lesson 3 22 min read

How Do I Read and Write Files in C?

fopen, fclose, fprintf, fscanf — getting data in and out of files

Reading: C Text: Ch. 6 §3 (pp. 365–375), Ch. 11 §1–2 (pp. 651–675, File Processing)

After this lesson, you will be able to:

  • Open and close files using fopen/fclose with correct mode and NULL check
  • Write to files with fprintf and read with fscanf, checking return values
  • Read lines safely with fgets and strip the trailing newline
  • Explain why while (!feof(fp)) is a bug

Beyond the Keyboard

Every program so far has read from the keyboard (stdin) and written to the screen (stdout). Real programs read configuration files, process data files, and write reports. C’s file I/O functions mirror printf/scanf — once you know those, files are straightforward.


File Operations

Opening and Closing Files

FILE *fp = fopen("students.txt", "r");    // Open for reading
if (fp == NULL)
{
    fprintf(stderr, "Error: cannot open students.txt\n");
    return 1;
}

// ... read from fp ...

fclose(fp);    // Always close when done

File modes:

Mode Meaning
"r" Read (file must exist)
"w" Write (creates or truncates)
"a" Append (creates or adds to end)

Common Pitfall: Always check if fopen returns NULL. The file might not exist, you might not have permission, or the path might be wrong. Skipping this check leads to crashes when you try to read from a NULL pointer.

Check Your Understanding
What does fopen("data.txt", "w") do if data.txt already exists and contains 100 lines of data?
A Opens the file and positions the cursor at the end, preserving existing content
B Returns NULL because the file already exists
C Truncates the file to zero length — all 100 lines are erased
D Opens the file read-only since it already has content
Answer: C. The "w" mode truncates existing files — all content is destroyed before you write anything. This is one of the most common data-loss bugs. If you want to add to an existing file, use "a" (append). Option A describes append mode, not write mode.

Writing to Files

fprintf works exactly like printf, but writes to a file:

FILE *fp = fopen("output.txt", "w");
if (fp == NULL) { /* handle error */ }

fprintf(fp, "Name: %s\n", name);
fprintf(fp, "GPA: %.2f\n", gpa);

fclose(fp);

Reading from Files

fscanf — formatted reading (like scanf):

FILE *fp = fopen("data.txt", "r");

int id;
double gpa;
while (fscanf(fp, "%d %lf", &id, &gpa) == 2)
{
    printf("ID: %d, GPA: %.2f\n", id, gpa);
}

fclose(fp);

fgets — read a line (safer):

char line[256];
while (fgets(line, sizeof(line), fp) != NULL)
{
    line[strcspn(line, "\n")] = '\0';    // Strip newline
    printf("Line: %s\n", line);
}

The Trick: fgets is generally safer than fscanf for reading text. It prevents buffer overflows and handles lines with spaces. Read the whole line with fgets, then parse it with sscanf or strtok.

Why does this matter?

Almost every lab from here on reads data from files. The fgets + sscanf pattern is your workhorse — it reads a line safely, then parses it. Getting comfortable with this pattern now saves you debugging time on every future assignment.

Check Your Understanding
Which loop correctly reads integers from a file until there are no more?
A while (!feof(fp)) { fscanf(fp, "%d", &val); process(val); }
B while (fscanf(fp, "%d", &val) == 1) { process(val); }
C while (fgets(fp, line, 256)) { process(val); }
D while (fp != NULL) { fscanf(fp, "%d", &val); process(val); }
Answer: B. Check the return value of the read function itself. fscanf returns the number of items successfully read — when it returns something other than 1, you're done. Option A is the classic feof trap: feof only returns true after a read has already failed, so the last iteration processes garbage. Option C has the fgets arguments in the wrong order. Option D checks if the file pointer is NULL, which it isn't once opened — this loops forever.

The feof Trap

Common Pitfall: Never use while (!feof(fp)) as your loop condition. feof returns true only after a read has already failed — so the last iteration processes garbage data. Instead, check the return value of your read function:

// WRONG:
while (!feof(fp))
{
    fscanf(fp, "%d", &value);    // Processes garbage on last iteration
}

// RIGHT:
while (fscanf(fp, "%d", &value) == 1)
{
    // Process value
}
Deep dive: File I/O connects to shell redirection

Remember from Lesson 1.5: printf writes to stdout and shell redirection (>) sends stdout to a file. File I/O functions are the C-level version of the same mechanism:

Shell concept C equivalent Effect
printf(...) fprintf(stdout, ...) Write to screen
./prog > file.txt fprintf(fp, ...) Write to file
scanf(...) fscanf(stdin, ...) Read from keyboard
./prog < input.txt fscanf(fp, ...) Read from file
fprintf(stderr, ...) ./prog 2> err.txt Error output

In fact, printf("Hello") is literally defined as fprintf(stdout, "Hello") and scanf("%d", &x) is fscanf(stdin, "%d", &x). The stdin, stdout, and stderr streams from Lesson 1.5 are just FILE * pointers that the C runtime opens for you automatically.

This also explains why perror() writes to stderr — error messages should go to a separate stream so they aren’t captured when you redirect stdout to a file:

FILE *fp = fopen("data.txt", "r");
if (fp == NULL)
{
    perror("data.txt");    // Writes to stderr, not stdout
    return 1;
}

When you run ./program > output.txt, the perror message still appears on screen (stderr) while normal output goes to the file (stdout).

Reading Structs from Files

typedef struct
{
    char name[50];
    int id;
    double gpa;
} Student;

int read_students(const char *filename, Student roster[], int max)
{
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) return -1;

    int count = 0;
    char line[256];

    while (count < max && fgets(line, sizeof(line), fp) != NULL)
    {
        line[strcspn(line, "\n")] = '\0';
        sscanf(line, "%49s %d %lf",
               roster[count].name,
               &roster[count].id,
               &roster[count].gpa);
        count++;
    }

    fclose(fp);
    return count;
}

From Java: In Java, you’d use Scanner scanner = new Scanner(new File("data.txt")) with scanner.nextLine() and scanner.nextInt(). C’s fopen/fgets/fscanf serve the same purpose — but you manage the file handle yourself (no automatic closing, no try-with-resources). Always fclose when done.

Check Your Understanding
In the read_students function above, what does line[strcspn(line, "\n")] = '\0'; do?
A Adds a newline character at the end of the string
B Copies the newline character into the line buffer
C Counts the number of newlines in the string
D Strips the trailing newline by replacing it with a null terminator
Answer: D. fgets includes the newline character in the buffer. strcspn(line, "\n") returns the index of the first newline (or the string length if none). Setting that position to '\0' effectively chops off the newline. This is a standard idiom you'll use every time you read lines with fgets.
Quick Check: Why should you always check fopen's return value?

fopen returns NULL if it can’t open the file (doesn’t exist, no permission, bad path). Passing NULL to fprintf or fscanf causes a crash. Checking lets you print a meaningful error message instead.

Quick Check: What's wrong with while (!feof(fp))?

feof returns true only after a read has already failed, not before. The last iteration of the loop reads garbage because the previous read hit EOF but feof hasn’t been checked yet. Check the read function’s return value instead.

Quick Check: What's the difference between "w" and "a" modes?

"w" truncates the file (erases existing content) and writes from the beginning. "a" appends to the end without erasing. Both create the file if it doesn’t exist.


Big Picture: File I/O transforms your programs from interactive toys into tools that process real data. Every database, compiler, and web server reads and writes files using the same primitives you just learned. Combined with structs, you can build programs that load, process, and save structured records — which is what most real software does.


Files Connect Programs to Data

File I/O is how your programs interact with the outside world — reading data sets, writing reports, processing logs. Combined with structs, you can read structured records from files, manipulate them in memory, and write results back.

Next: sorting structs. You’ll sort your struct arrays by different fields — name, ID, GPA — using the selection sort you learned in Lesson 2.14, adapted for structured data.