C Binary Files
A binary file stores data exactly as it exists in memory: raw bytes for integers, floats, and structs, with no translation into human-readable text. This makes binary files compact and fast, since an entire array of numbers or a whole struct can be written or read with a single function call. Binary files power things like save-game files, image and audio formats, and custom databases — anywhere a program needs to persist structured data efficiently and read it back byte-for-byte.
Overview: How Binary Files Work
A text file stores human-readable characters. The integer 255 written to a text file becomes the three characters '2', '5', '5' (3 bytes), and reading it back means parsing those characters with something like fscanf. A binary file skips that translation entirely: the integer 255 is written as its raw in-memory representation — typically 4 bytes on most systems — and reading it back is just copying those 4 bytes straight into a variable. No parsing, no formatting, no rounding of floating-point values.
Because there is no text conversion, binary I/O in C uses two dedicated functions, fwrite and fread, instead of fprintf and fscanf. Both work directly with raw memory: fwrite copies size * count bytes starting at a pointer into the file, and fread copies size * count bytes from the file back into memory starting at a pointer. This is what makes it possible to write or read an entire array, or even an entire struct, in a single call rather than looping field by field.
To open a file for binary access, add the letter b to the mode string passed to fopen: "rb" to read, "wb" to write (creating or truncating the file), "ab" to append, and the + variants such as "rb+" or "wb+" for simultaneous reading and writing. On POSIX systems such as Linux and macOS, text mode and binary mode behave identically, so leaving out the b often goes unnoticed. On Windows, however, text mode silently translates every newline byte (0x0A) into a carriage-return/newline pair (0x0D 0x0A) on write, reverses that translation on read, and treats the byte 0x1A as an end-of-file marker. Any of those byte values can legitimately appear inside binary data, so opening a binary file without the b flag can silently corrupt it on Windows. Always include b for binary files so the code behaves the same on every platform.
Binary files also make random access practical. Because a struct or a fixed-size record always occupies the same number of bytes, you can compute exactly where record number N begins (N * record_size) and jump straight there with fseek, instead of reading through every record before it. ftell reports the current byte offset in the file, which is useful both for confirming a seek and for computing a file’s total size.
Syntax
| Mode | Meaning |
|---|---|
"rb" |
Open an existing file for reading only |
"wb" |
Create the file (or truncate it if it exists) for writing only |
"ab" |
Open or create the file; every write is appended at the end |
"rb+" |
Open an existing file for both reading and writing |
"wb+" |
Create/truncate a file for both reading and writing |
"ab+" |
Open or create the file; reading is free, writing is always appended |
The core functions for binary I/O:
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
ptr— address of the memory to write from, or read into.size— the size in bytes of one element (usuallysizeof(type)).count— how many elements to transfer.stream— the openFILE *.- Both functions return the number of complete elements actually transferred, which can be less than
counton a short file or a full disk. whenceforfseekis one ofSEEK_SET(from the start),SEEK_CUR(from the current position), orSEEK_END(from the end).
Examples
Example 1: Writing and Reading an Array of Integers
The simplest case: dump a whole array of int to a file, then read it straight back into another array.
#include <stdio.h>
int main(void) {
int numbers[5] = {10, 20, 30, 40, 50};
FILE *out = fopen("numbers.bin", "wb");
if (out == NULL) {
perror("fopen");
return 1;
}
size_t written = fwrite(numbers, sizeof(int), 5, out);
fclose(out);
printf("Wrote %zu integers to numbers.bin\n", written);
int loaded[5] = {0};
FILE *in = fopen("numbers.bin", "rb");
if (in == NULL) {
perror("fopen");
return 1;
}
size_t items_read = fread(loaded, sizeof(int), 5, in);
fclose(in);
printf("Read %zu integers:", items_read);
for (size_t i = 0; i < items_read; i++) {
printf(" %d", loaded[i]);
}
printf("\n");
return 0;
}
Output:
Wrote 5 integers to numbers.bin
Read 5 integers: 10 20 30 40 50
Notice that fwrite and fread both take sizeof(int) as the element size and 5 as the count, so the whole array moves in one call. No format specifiers, no loop needed for the transfer itself — the loop here is only for printing.
Example 2: Writing and Reading an Array of Structs
Binary files really shine with structs, since a whole record can be saved or restored in one call.
#include <stdio.h>
typedef struct {
int id;
char name[20];
float gpa;
} Student;
int main(void) {
Student students[3] = {
{101, "Alice", 3.9f},
{102, "Bob", 3.4f},
{103, "Carol", 3.7f}
};
FILE *out = fopen("students.bin", "wb");
if (out == NULL) {
perror("fopen");
return 1;
}
fwrite(students, sizeof(Student), 3, out);
fclose(out);
Student loaded[3];
FILE *in = fopen("students.bin", "rb");
if (in == NULL) {
perror("fopen");
return 1;
}
fread(loaded, sizeof(Student), 3, in);
fclose(in);
for (int i = 0; i < 3; i++) {
printf("ID: %d, Name: %s, GPA: %.2f\n",
loaded[i].id, loaded[i].name, loaded[i].gpa);
}
return 0;
}
Output:
ID: 101, Name: Alice, GPA: 3.90
ID: 102, Name: Bob, GPA: 3.40
ID: 103, Name: Carol, GPA: 3.70
A single fwrite(students, sizeof(Student), 3, out) copies all three records — ids, names, and GPAs together — in one shot, and a single fread restores them exactly. This is far less code than writing each field separately with fprintf.
Example 3: Random Access with fseek and ftell
Because every Student record is the same size, you can jump directly to record N instead of reading everything before it.
#include <stdio.h>
typedef struct {
int id;
char name[20];
float gpa;
} Student;
int main(void) {
Student students[3] = {
{101, "Alice", 3.9f},
{102, "Bob", 3.4f},
{103, "Carol", 3.7f}
};
FILE *fp = fopen("students2.bin", "wb");
if (fp == NULL) {
perror("fopen");
return 1;
}
fwrite(students, sizeof(Student), 3, fp);
fclose(fp);
fp = fopen("students2.bin", "rb");
if (fp == NULL) {
perror("fopen");
return 1;
}
int target_index = 1;
fseek(fp, (long)(target_index * sizeof(Student)), SEEK_SET);
Student one;
fread(&one, sizeof(Student), 1, fp);
printf("Record %d -> ID: %d, Name: %s, GPA: %.2f\n",
target_index, one.id, one.name, one.gpa);
long pos = ftell(fp);
printf("Current file position: %ld bytes\n", pos);
fclose(fp);
return 0;
}
Output:
Record 1 -> ID: 102, Name: Bob, GPA: 3.40
Current file position: 56 bytes
fseek jumps straight to byte 28 (1 * sizeof(Student), since this struct is 28 bytes with no padding on a typical 64-bit system), skipping Alice’s record entirely. After fread pulls in Bob’s 28 bytes, the position reported by ftell advances to 56. This is the key advantage of binary files with fixed-size records: reading record 1,000 costs the same as reading record 1.
How It Works Step by Step (Under the Hood)
fopen asks the operating system to open or create the file and returns a FILE * representing a buffered stream. fwrite does not format anything — it copies size * count bytes directly from the address you give it into the stream’s output buffer, which the C library eventually flushes to disk, either automatically or when you call fclose or fflush. fread does the reverse, copying bytes straight from the file into the memory you point it at, filling in every byte of every element, including any padding bytes the compiler inserted between struct members for alignment. fseek moves the stream’s internal file-position indicator to an arbitrary byte offset without reading anything, and the next fread or fwrite happens starting at that new position. ftell simply reports the current value of that indicator.
One subtlety worth knowing: because C compilers are free to insert padding bytes inside a struct for alignment, and different compilers, platforms, and CPU architectures can choose different padding rules and byte orders (endianness), a raw struct dump written by one program is not guaranteed to be readable by a different compiler or a different machine. It works reliably as long as the writer and the reader use the exact same struct definition compiled for the same kind of system — which is the normal case for a program reading back its own save files.
Common Mistakes
Mistake 1: Forgetting the b in the Mode String
This compiles and even runs correctly on Linux/macOS, but it is not portable and is technically wrong:
#include <stdio.h>
int main(void) {
int data[3] = {1, 2, 3};
FILE *fp = fopen("data.bin", "w"); /* missing "b" - not portable */
if (fp == NULL) {
perror("fopen");
return 1;
}
fwrite(data, sizeof(int), 3, fp);
fclose(fp);
printf("Wrote 3 integers using text mode (non-portable).\n");
return 0;
}
Output:
Wrote 3 integers using text mode (non-portable).
On Windows, this code risks corrupting the file: any raw byte in data that happens to equal 0x0A gets an extra 0x0D inserted on write, and a byte equal to 0x1A read back in text mode would be treated as an early end-of-file. The fix is simple — always add b:
#include <stdio.h>
int main(void) {
int data[3] = {1, 2, 3};
FILE *fp = fopen("data.bin", "wb"); /* binary mode: portable */
if (fp == NULL) {
perror("fopen");
return 1;
}
fwrite(data, sizeof(int), 3, fp);
fclose(fp);
printf("Wrote 3 integers using binary mode (portable).\n");
return 0;
}
Output:
Wrote 3 integers using binary mode (portable).
Mistake 2: Ignoring the Return Value of fread/fwrite
fread and fwrite return how many complete elements were actually transferred. Ignoring that value lets a program silently act on a partial read as if it were complete:
#include <stdio.h>
int main(void) {
int source[3] = {7, 8, 9};
FILE *out = fopen("short.bin", "wb");
if (out == NULL) {
perror("fopen");
return 1;
}
fwrite(source, sizeof(int), 3, out); /* only 3 ints written */
fclose(out);
int data[5] = {-1, -1, -1, -1, -1};
FILE *in = fopen("short.bin", "rb");
if (in == NULL) {
perror("fopen");
return 1;
}
fread(data, sizeof(int), 5, in); /* asked for 5, only 3 exist - return ignored */
fclose(in);
for (int i = 0; i < 5; i++) {
printf("%d ", data[i]);
}
printf("\n");
return 0;
}
Output:
7 8 9 -1 -1
The file only ever contained 3 integers, yet the code prints 5 values as if all of them came from the file. The last two are leftover sentinel values from initialization, not file data — in a real program without that safety initializer, they would be indeterminate garbage. The fix is to check the count fread actually returns:
#include <stdio.h>
int main(void) {
int source[3] = {7, 8, 9};
FILE *out = fopen("short.bin", "wb");
if (out == NULL) {
perror("fopen");
return 1;
}
fwrite(source, sizeof(int), 3, out);
fclose(out);
int data[5] = {-1, -1, -1, -1, -1};
FILE *in = fopen("short.bin", "rb");
if (in == NULL) {
perror("fopen");
return 1;
}
size_t count = fread(data, sizeof(int), 5, in);
fclose(in);
if (count < 5) {
printf("Warning: expected 5 integers but only found %zu\n", count);
}
for (size_t i = 0; i < count; i++) {
printf("%d ", data[i]);
}
printf("\n");
return 0;
}
Output:
Warning: expected 5 integers but only found 3
7 8 9
A related pitfall: never write a struct that contains a pointer (like char *name) directly to a binary file. The bytes written would be the pointer’s numeric address, which is meaningless once the program exits and the memory it pointed to is gone. Use a fixed-size array member, such as char name[20], so the actual characters — not an address — end up in the file.
Best Practices
- Always include the
bflag ("rb","wb","ab", etc.) when opening a file meant to hold binary data, even on systems where it currently makes no difference — it keeps the code portable. - Always check the value returned by
fwriteandfreadagainst the count you requested; a short count means the disk is full, the file is shorter than expected, or you hit end-of-file mid-record. - Use
sizeof(type)orsizeof(variable)for the size argument instead of a hard-coded number, so the code stays correct if the type changes. - Never write a struct containing pointers directly to a binary file; store the actual data (fixed-size arrays) instead of addresses.
- Remember raw binary files are not portable across machines with different endianness, word sizes, or struct padding; define an explicit byte layout, or use a library, if true portability is required.
- Always close every
FILE *withfclose(or ensure it happens on every exit path) so buffered writes are actually flushed to disk. - Use
fseekwithSEEK_SET,SEEK_CUR, orSEEK_ENDtogether with fixed-size records to jump directly to any record instead of reading a file sequentially.
Practice Exercises
- Write a program that stores an array of 10
doublevalues (for example, temperature readings) in a binary file namedtemps.binusingfwrite, then reads them back withfreadand prints their average. (Hint: usesizeof(double)as the element size.) - Extend the
Studentexample from this lesson by writing a functionStudent read_student_at(FILE *fp, int index)that usesfseekandfreadto load and return just one record by index, without reading any other records. Test it by fetching the student at index2(Carol). - Write a program that appends a new
Studentrecord to the end ofstudents.binby opening it with mode"ab"(no need to rewrite the whole file). Then reopen the file with"rb", usefseekto the end plusftellto find the total file size, divide bysizeof(Student)to get the record count, and print every record in a loop.
Summary
- Binary files store the raw in-memory bytes of data with no text conversion, making them compact and fast.
- Open binary files with a mode string that includes
b:"rb","wb","ab","rb+","wb+", etc. fwrite(ptr, size, count, stream)writescountelements ofsizebytes fromptr;freaddoes the reverse.- Both functions return the number of complete elements actually transferred — always check it.
fseekandftelllet you jump to and report a byte offset, enabling fast random access to fixed-size records.- Binary files are not portable across platforms with different endianness or struct padding, and raw pointers should never be stored in them.
