05 · Binary File I/O¶
Level 1, Module 8 wrote files with fprintf — the
number 1000000 became the seven characters 1000000. That's text I/O:
human-readable, easy to debug, and lossy for floats. Binary I/O writes the
bytes of the value itself: those same 1,000,000 become four bytes, exactly as
they sit in memory, with no conversion and no rounding.
Binary files are smaller, faster to read and write, and support random access
— you can jump directly to record 500 without reading the first 499. The trade
is that you can no longer inspect the file with cat, and the format is tied to
your machine's representation unless you're careful.
Text vs binary at a glance¶
Text (fprintf/fscanf) |
Binary (fwrite/fread) |
|
|---|---|---|
int 1000000 on disk |
7 bytes: 1000000 |
4 bytes: 40 42 0F 00 |
double 0.1 |
rounded to your %f precision |
exact bit pattern |
| Human readable | yes | no |
| Parse cost | must convert every field | none — it's already a value |
| Random access | hard (variable-width lines) | easy (fixed-size records) |
| Portable across machines | yes | not automatically |
Opening in binary mode¶
Add b to the mode string. On Linux and macOS it changes nothing; on Windows
it stops the C library translating \n into \r\n, which would corrupt your
data. Always include it — it costs nothing and makes the code portable.
| Mode | Meaning |
|---|---|
"rb" |
read; fails if the file doesn't exist |
"wb" |
write; truncates an existing file to zero length |
"ab" |
append; writes always go to the end |
"rb+" |
read and write an existing file (for updating records in place) |
"wb+" |
read and write, truncating first |
fwrite and fread¶
Both have the same shape:
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);
They move size * count bytes and return the number of items (not bytes)
successfully transferred. A short return means end-of-file or an error.
#include <stdio.h>
int main(void) {
int numbers[] = {10, 20, 30, 40, 50};
size_t n = sizeof numbers / sizeof numbers[0];
FILE *f = fopen("numbers.bin", "wb");
if (f == NULL) { perror("fopen"); return 1; }
size_t written = fwrite(numbers, sizeof numbers[0], n, f);
printf("wrote %zu of %zu items\n", written, n);
fclose(f);
int loaded[5] = {0};
f = fopen("numbers.bin", "rb");
if (f == NULL) { perror("fopen"); return 1; }
size_t got = fread(loaded, sizeof loaded[0], 5, f);
printf("read %zu items:", got);
for (size_t i = 0; i < got; i++) printf(" %d", loaded[i]);
printf("\n");
fclose(f);
return 0;
}
// Output:
// wrote 5 of 5 items
// read 5 items: 10 20 30 40 50
The resulting numbers.bin is exactly 20 bytes. The same data as text
("10 20 30 40 50\n") would be 15 bytes here, but binary wins decisively as
values get larger, and it never needs parsing.
Writing and reading structs¶
This is where binary I/O earns its keep — a whole record in one call:
#include <stdio.h>
#include <string.h>
typedef struct {
int id;
char name[32];
double price;
} Product;
int main(void) {
Product items[3] = {
{1, "keyboard", 49.95},
{2, "monitor", 219.00},
{3, "cable", 7.25}
};
FILE *f = fopen("products.dat", "wb");
if (!f) { perror("open for write"); return 1; }
if (fwrite(items, sizeof(Product), 3, f) != 3) {
fprintf(stderr, "short write\n");
fclose(f);
return 1;
}
fclose(f);
// Read them back one at a time
f = fopen("products.dat", "rb");
if (!f) { perror("open for read"); return 1; }
Product p;
while (fread(&p, sizeof p, 1, f) == 1) {
printf("#%d %-10s $%.2f\n", p.id, p.name, p.price);
}
fclose(f);
return 0;
}
// Output:
// #1 keyboard $49.95
// #2 monitor $219.00
// #3 cable $7.25
while (fread(&p, sizeof p, 1, f) == 1) is the idiomatic record-reading loop:
it stops cleanly at EOF, and a partial record (a truncated file) also fails the
test instead of silently producing half-garbage.
Note why this works only for this struct: Product contains a fixed-size
char name[32], not a char *. If it held a pointer, fwrite would faithfully
save the address — meaningless in any other process. Structs containing
pointers cannot be dumped and reloaded directly; you have to serialize what they
point to.
Random access: fseek, ftell, rewind¶
Fixed-size records give you O(1) access to any record:
#include <stdio.h>
typedef struct { int id; char name[32]; double price; } Product;
// Read record number `index` (0-based) without reading the ones before it.
int read_record(const char *path, long index, Product *out) {
FILE *f = fopen(path, "rb");
if (!f) return 0;
if (fseek(f, index * (long)sizeof(Product), SEEK_SET) != 0) {
fclose(f);
return 0;
}
int ok = (fread(out, sizeof *out, 1, f) == 1);
fclose(f);
return ok;
}
// Overwrite record `index` in place -- note the "rb+" mode.
int update_price(const char *path, long index, double new_price) {
FILE *f = fopen(path, "rb+");
if (!f) return 0;
Product p;
if (fseek(f, index * (long)sizeof p, SEEK_SET) != 0 ||
fread(&p, sizeof p, 1, f) != 1) {
fclose(f);
return 0;
}
p.price = new_price;
fseek(f, index * (long)sizeof p, SEEK_SET); // rewind to the record start
int ok = (fwrite(&p, sizeof p, 1, f) == 1);
fclose(f);
return ok;
}
int main(void) {
Product p;
if (read_record("products.dat", 1, &p))
printf("record 1: %s $%.2f\n", p.name, p.price);
update_price("products.dat", 1, 199.99);
if (read_record("products.dat", 1, &p))
printf("record 1: %s $%.2f\n", p.name, p.price);
return 0;
}
// Output:
// record 1: monitor $219.00
// record 1: monitor $199.99
fseek(f, offset, whence) takes one of three origins:
whence |
Offset measured from |
|---|---|
SEEK_SET |
the beginning of the file |
SEEK_CUR |
the current position |
SEEK_END |
the end of the file (use a negative offset to go back) |
ftell(f) reports the current byte offset, and rewind(f) is shorthand for
seeking to 0 and clearing the error flags.
Counting records with fseek + ftell¶
#include <stdio.h>
long record_count(const char *path, size_t record_size) {
FILE *f = fopen(path, "rb");
if (!f) return -1;
fseek(f, 0, SEEK_END); // jump to the end
long bytes = ftell(f); // ...and ask where that is
fclose(f);
if (bytes < 0) return -1;
return bytes / (long)record_size;
}
One subtlety with "rb+": the C standard requires a fseek, fflush, or
rewind between a read and a write on the same stream (and vice versa).
Skipping it is undefined behavior, even though it often appears to work.
Portability: endianness and struct layout¶
The bytes you write are your machine's bytes. Two things can differ elsewhere:
- Endianness.
int 1is01 00 00 00on x86/ARM (little-endian) and00 00 00 01on a big-endian machine. Same file, different number. - Padding. As shown in Module 4, a struct's
internal padding is compiler- and platform-dependent, so
sizeof(Product)may not match across builds.
#include <stdio.h>
int main(void) {
unsigned int x = 1;
unsigned char *b = (unsigned char *)&x;
printf("%s-endian (first byte = %u)\n",
b[0] == 1 ? "little" : "big", b[0]);
return 0;
}
// Output on x86-64 / Apple Silicon:
// little-endian (first byte = 1)
For files that only your program on your machine reads — caches, save files, scratch data — raw struct dumps are perfectly fine and delightfully simple. For anything that crosses machines, write field by field in a defined byte order (or use a format like JSON, CBOR, or Protocol Buffers). A common middle ground is to start every binary file with a small header containing a magic number and a version:
typedef struct {
char magic[4]; // "INVT"
uint32_t version; // bump when the record layout changes
uint32_t record_count;
} FileHeader;
Reading a file whose magic doesn't match then fails loudly instead of misinterpreting the bytes — and version numbers let old files stay readable.
How It Actually Works¶
fwrite(numbers, sizeof numbers[0], n, f) performs almost no interpretation
of the data at all — internally it's close to a raw memcpy from your
array's memory straight into the FILE's buffer (and eventually, via
write(), into the kernel's page cache and then disk), byte for byte,
exactly as those bytes sit in RAM right now. This is the mechanical reason
binary I/O has no conversion cost: fprintf("%d", n) must run a whole
integer-to-decimal-digits algorithm (repeated division and modulo by 10,
building character digits back to front), while fwrite just streams the
4 bytes of the int's two's-complement representation as-is — the "value"
was already sitting in memory in exactly the form the file now holds.
This is also precisely why struct dumps can't include pointers safely: a
char * field's 8 bytes are a virtual memory address meaningful only
within this one process's address space, assigned by the OS's memory
manager when that particular allocation happened. fwrite-ing that struct
faithfully copies those 8 address bytes to disk; reading them back later
(even in the same process, after the pointed-to memory has been freed and
possibly reused, or in an entirely different process where that address
means something else or nothing at all) produces a pointer that no longer
refers to valid data — dereferencing it is undefined behavior, exactly like
use-after-free.
fseek(f, offset, SEEK_SET) maps closely to the kernel's own
lseek() system call, which simply updates the file's cursor position — a
number the kernel tracks per open file description — without touching any
data. Because every Product record is a fixed sizeof(Product) bytes,
computing index * sizeof(Product) and seeking there directly is O(1): the
disk (or OS page cache) can jump straight to that byte offset, unlike a
text file's variable-length lines, which force a sequential scan from the
beginning just to find where record index starts (because nothing marks
byte offsets when every line's length differs).
Endianness is a hardware fact about how a multi-byte CPU register gets
laid out in memory, not a software choice: a little-endian CPU (x86, most
ARM in default mode) stores an integer's least significant byte at the
lowest memory address, so fwrite-ing 1 produces the bytes 01 00 00 00
— because that's genuinely the order those bytes sit in the CPU's register
before the write instruction even runs. A big-endian machine's hardware
stores the same integer value with the byte order reversed. fwrite
doesn't add or remove any conversion step, so the exact in-memory
representation — including this hardware-determined byte order — is what
ends up on disk, which is why a binary file written on one architecture can
misread as a completely different number on another.
Exercise¶
Extend the Product example into a small binary database. Write
int append_product(const char *path, const Product *p) using "ab",
long count_products(const char *path) using fseek/ftell, and
int delete_product(const char *path, long index) that copies every record
except index into a temporary file and then replaces the original. Add a
FileHeader with magic "PROD" and version 1 at the start of the file, and
make every read verify it before trusting the rest. Confirm your file size
matches sizeof(FileHeader) + count * sizeof(Product) with ls -l.