File Handling · intermediate · ~8 min
**What you will learn** - Use `fwrite` to copy raw bytes or fixed-size records from memory into a file opened in binary mode. - Read and interpret the return value of `fwrite` to detect partial or failed writes, and confirm errors with `ferror`. - Understand how the C standard library buffers output, and control durability with `fflush` and `fsync(fileno(f))`. - Write and read back fixed-size `struct` records so the same data round-trips correctly. - Recognize portability hazards (endianness, padding, type sizes) when binary files cross machines or compilers. - Apply professional habits: open in the right mode, check every write, and always release file resources with `fclose`.
When a program needs to save data and load it again later, it must turn values that live in memory into bytes on disk. There are two broad ways to do this. Text output (fprintf, fputs) converts values into human-readable characters like the digits 4 2. Binary output copies the exact bytes of a value straight from memory into the file, with no conversion at all. fwrite is the standard tool for binary output.
This lesson is the write-side companion to fread for binary I/O, your prerequisite. Where fread pulls a block of bytes from a file into a buffer, fwrite pushes a block of bytes from a buffer into a file. The two functions have mirror-image signatures and are almost always used together: you design a record layout, write records with fwrite, and later read them back with fread.
Binary output matters because it is compact and fast. Saving the integer 1000000 as text takes seven characters (1000000); saving it as a binary int takes exactly four bytes, and no parsing is needed on the way back in. Games use binary files for save states, databases use them for on-disk pages, and image and audio formats are binary at their core.
The key idea to carry through the lesson: fwrite does not understand your data. It sees only a starting address, an item size, and an item count. It is your job to make sure the bytes you hand it mean the same thing when you read them back.
Almost every serious program eventually persists structured data: a configuration cache, a save file, a log of fixed-size events, a custom file format. Binary I/O is how that is done when you control both the writer and the reader.
Getting fwrite right has direct, practical consequences:
These are the same concerns that show up in file formats, network protocols, and embedded firmware, so the habits you build here transfer widely.
fwrite contractsize_t fwrite(const void *buf, size_t sz, size_t n, FILE *f);
fwrite copies n items, each sz bytes long, from the memory starting at buf into the stream f. The total number of bytes it attempts to write is sz * n. It returns the number of complete items written, not the number of bytes.
n, every item was written.n, a write error occurred (commonly a full disk or a stream opened read-only). Confirm with ferror(f).The const void *buf means fwrite can write from any type without complaint; the compiler will not warn you if you pass the wrong size. That flexibility is also a trap, covered in the mistakes section.
fwrite(&rec, sizeof(rec), 1, f)
| | |
| | +-- write 1 item
| +---------- each item is sizeof(rec) bytes
+------------------- copy bytes starting at &rec
Knowledge check: If sizeof(int) is 4 and you call fwrite(arr, sizeof(int), 5, f), how many bytes does fwrite try to write, and what return value means full success?
You must open the file in binary mode for fwrite to behave predictably: fopen(path, "wb") to truncate and write, or "ab" to append. The b matters on Windows, where text mode translates \n into \r\n. For binary data that translation corrupts your bytes. On Linux and macOS text and binary modes behave the same, but always writing b for binary data keeps your code portable.
| Mode string | Meaning |
|---|---|
"wb" |
create/truncate, write binary |
"ab" |
append binary (writes go to end) |
"rb+" |
read and write binary, file must exist |
"wb+" |
create/truncate, read and write binary |
When NOT to use binary mode: if the goal is a human-readable file (a CSV, a log, a config), use text functions instead. Binary mode buys nothing there and makes the file harder to inspect.
Writing through fwrite does not immediately reach the disk. The C library keeps a user-space buffer and accumulates writes there for efficiency. The bytes move on to the operating system when:
fflush(f),fclose(f) (which flushes first). your buffer (rec)
| fwrite
v
+-----------------+ fflush +----------------+ fsync +---------+
| C-library buffer| ----------> | OS page cache | --------> | disk |
+-----------------+ +----------------+ +---------+
Note the two layers. fflush pushes from the C library to the OS, but the OS may still hold the data in its own cache. For real durability (a database, a save file you cannot afford to lose) you call fflush(f) and then fsync(fileno(f)), where fileno(f) returns the low-level file descriptor behind the FILE *. Only after fsync returns are the bytes guaranteed on the physical device.
When NOT to fsync: it is slow. Calling it after every small write throttles throughput badly. Use it at meaningful checkpoints, not on every record.
Common pitfall: assuming that because fwrite returned n, the data is safe on disk. It is only in a buffer. A crash before flush loses it.
The most common use of fwrite is saving fixed-size records, usually a struct:
struct Player { int id; int score; };
struct Player p = { 7, 1500 };
fwrite(&p, sizeof p, 1, f);
This copies the raw bytes of p, including any padding the compiler inserted between fields for alignment. As long as the same program (same compiler, same platform) reads it back with fread into the same struct, the layout matches and the values survive.
struct Player in memory (typical 4-byte int, no padding needed):
offset: 0 4
[ id ][ score ]
\------ 8 bytes ------/ written verbatim to the file
When NOT to do this: never share such a file between different compilers, architectures, or as a public format. Padding and byte order are not guaranteed across them. For portable formats, serialize field by field in a fixed byte order.
Knowledge check: Predict the output. If you fwrite(&p, sizeof p, 1, f) a struct on a 64-bit machine, then later fread it into the same struct type on the same program, will the values match? Will they if a different program on a big-endian machine reads it as raw little-endian ints?
Knowledge check (explain in your own words): Why does fwrite need both a size and a count instead of just a single byte total?
#include <stdio.h>
FILE *f = fopen("data.bin", "wb"); // "wb": truncate + write, binary mode
if (f == NULL) { /* handle error: fopen failed */ }
int values[3] = { 10, 20, 30 };
// write 3 items, each sizeof(int) bytes, from values into f
size_t written = fwrite(values, sizeof(int), 3, f);
if (written != 3) { // fewer than requested means an error
if (ferror(f)) { /* a write error occurred */ }
}
fflush(f); // push C-library buffer to the OS
// fsync(fileno(f)); // (POSIX) force OS to disk for durability
fclose(f); // flushes and releases the FILE *
Key points: always check the return of fopen and fwrite; compare the return to the item count (not the byte total); and pair every successful fopen with fclose.
fwrite doessize_t fwrite(const void *buf, size_t sz, size_t n, FILE *f);
fwrite is the write-side counterpart of fread. It copies n items, each sz bytes, from the buffer buf into the file f.
It returns the number of items successfully written.
n, all items were written.n, an error occurred. Check the error with ferror(f).Writing does not always reach the disk right away. The data may sit in a user-space buffer (memory managed by the C library) until one of these happens:
fclose(f),fflush(f),This is fine for most programs. But it means a crash can lose buffered data.
Long-running programs that need real durability take an extra step. They call fflush(f) to push the buffer to the operating system, then fsync(fileno(f)) to force the OS to write it to the physical disk. fileno(f) returns the low-level file descriptor behind the FILE *.
#include <stdio.h> #include <stdlib.h>
struct Player { int id; int score; };
int main(void) { struct Player team[3] = { { 1, 1500 }, { 2, 2300 }, { 3, 900 } }; const size_t count = sizeof team / sizeof team[0];
/* ---- write the records ---- */
FILE *out = fopen("players.bin", "wb"); /* truncate + binary write */
if (out == NULL) {
perror("fopen for writing"); /* prints the OS error reason */
return 1;
}
size_t written = fwrite(team, sizeof team[0], count, out);
if (written != count) { /* short write == error */
fprintf(stderr, "wrote only %zu of %zu records\n", written, count);
fclose(out);
return 1;
}
if (fflush(out) != 0) { /* flush buffer to the OS */
perror("fflush");
fclose(out);
return 1;
}
if (fclose(out) != 0) { /* fclose can also fail */
perror("fclose");
return 1;
}
/* ---- read them back to prove the round-trip ---- */
FILE *in = fopen("players.bin", "rb");
if (in == NULL) {
perror("fopen for reading");
return 1;
}
struct Player loaded[3];
size_t got = fread(loaded, sizeof loaded[0], count, in);
if (got != count) {
fprintf(stderr, "read only %zu of %zu records\n", got, count);
fclose(in);
return 1;
}
fclose(in);
for (size_t i = 0; i < count; i++) {
printf("player %d -> score %d\n", loaded[i].id, loaded[i].score);
}
return 0;
}
struct Player defines a fixed-size record of two ints. On a typical machine that is 8 bytes, and sizeof team[0] computes it for us so we never hard-code the number.team is initialized with three players. count is derived from sizeof team / sizeof team[0], the standard idiom for the length of a stack array.fopen("players.bin", "wb") creates (or truncates) the file in binary mode. The check out == NULL catches a failure such as a read-only directory; perror prints why.fwrite(team, sizeof team[0], count, out) copies count items of sizeof team[0] bytes each from team straight into the file. The whole array is contiguous in memory, so one call writes all three records.written to count. If fewer items were written, a real error occurred (for example, the disk filled mid-write); we report it and bail out, still closing the file.fflush(out) pushes the C-library buffer to the OS. fclose(out) would flush anyway, but flushing explicitly lets us check the result separately. fclose itself is checked because the final flush it performs can fail too."rb" and fread the same number of records into loaded. Because the writer and reader are the same program on the same platform, the byte layout (including any padding) matches exactly.Trace of what is in the file after the write:
| Bytes (offset) | Meaning |
|---|---|
| 0-3 | id = 1 |
| 4-7 | score = 1500 |
| 8-11 | id = 2 |
| 12-15 | score = 2300 |
| 16-19 | id = 3 |
| 20-23 | score = 900 |
Expected output:
player 1 -> score 1500
player 2 -> score 2300
player 3 -> score 900
Edge cases: if count were 0, fwrite writes nothing and returns 0 (not an error). If the file cannot be opened, the program reports it and exits with status 1 rather than dereferencing a NULL FILE *.
1. Comparing the return value to the byte total instead of the count.
/* WRONG */
if (fwrite(arr, sizeof(int), 5, f) != 5 * sizeof(int)) { /* always true! */ }
fwrite returns the number of items (5 on success), not bytes. The check above treats success as failure. Compare to the count:
/* CORRECT */
if (fwrite(arr, sizeof(int), 5, f) != 5) { /* real error */ }
2. Swapping the size and count arguments.
/* WRONG */ fwrite(arr, 5, sizeof(int), f); /* size=5, count=4 */
/* CORRECT */ fwrite(arr, sizeof(int), 5, f);
Both write 5 * sizeof(int) bytes, so it looks fine, but the return value now means "number of sizeof(int)-count blocks," which breaks your error check and any partial-write logic. Keep the convention (buf, sizeof(element), number_of_elements, f).
3. Writing the pointer instead of the data.
char *msg = "hello";
/* WRONG */ fwrite(&msg, sizeof msg, 1, f); /* writes the 8-byte pointer */
/* CORRECT */ fwrite(msg, 1, strlen(msg), f); /* writes the 5 characters */
The wrong version saves a memory address that is meaningless after the program ends. Recognize it when your file is 8 bytes of garbage instead of the text you expected.
4. Forgetting to flush before exit or relying on buffering. Buffered data is lost on a crash. Call fflush (or fclose) before you assume anything is saved; add fsync(fileno(f)) when durability truly matters.
5. Opening in text mode on Windows. fopen(path, "w") for binary data corrupts every \n (0x0A) into \r\n. Always use "wb"/"ab" for binary output.
Compiler warnings: enable -Wall -Wextra. A warning like passing argument 1 of 'fwrite' makes pointer from integer usually means you passed a value instead of its address (e.g. fwrite(x, ...) instead of fwrite(&x, ...)).
Runtime: nothing in the file, or it is empty. Check, in order:
fopen succeed? Print the result and use perror on NULL.fwrite return the expected count? If not, call ferror(f) to confirm a stream error.fclose (or fflush) before inspecting the file? Unflushed data is still in memory.Runtime: file has wrong size. Inspect the raw bytes with xxd players.bin or od -An -tx1 players.bin. Compare the byte count to count * sizeof(record). A size of 8 where you expected text is the classic "wrote the pointer" bug.
Logic: values come back wrong after fread. Confirm the writer and reader use the identical struct definition and the same compiler/platform. If you moved the file between machines, padding or endianness likely differs.
Questions to ask when it does not work: Is the file open in binary mode? Am I comparing the return to the count, not the byte total? Am I writing &data or data correctly for the type? Did I flush before reading back?
fwrite reads sz * n bytes starting at buf. The single most important safety rule is that the buffer must actually contain that many valid, initialized bytes, or you trigger undefined behavior.
fwrite to write more items than the buffer holds, it reads past the end of your array and writes garbage (or crashes). Always derive the count from the real array length: sizeof arr / sizeof arr[0] for stack arrays, or track the count explicitly for heap buffers.memset the struct to 0 first if the file is shared or sensitive.sz * n. size_t multiplication can wrap if both values are huge. When sizes come from untrusted input, validate that n <= SIZE_MAX / sz before calling.fwrite from a buffer that has already been freed or from a local array after the function returned. Write while the data is still alive.fopen needs a matching fclose. Leaking FILE * handles exhausts a finite per-process limit and can leave buffered data unflushed.Always check the fwrite return value: a short write is not a crash, but ignoring it produces a silently corrupt file, which is its own kind of data-safety failure.
Concrete uses. Game save states are typically arrays of fixed-size structs written with fwrite. Databases write fixed-size pages to disk this way. Caches serialize computed results to a binary blob so a restart can reload them instantly. Embedded firmware logs fixed-size sensor records to flash. Custom file formats (a level editor's .lvl, a tool's project file) are built on exactly this pattern.
Professional best practices.
Beginner rules:
b in the mode string.fopen for NULL and check fwrite against the item count.fopen with fclose; do not leak handles.sizeof(element) for the size argument so it stays correct if the type changes.Advanced habits:
fflush + fsync(fileno(f)) at durability checkpoints; for atomic updates, write to a temp file and rename it over the target.size_t overflow.Beginner 1 — Write an int array.
Objective: persist five integers and verify the file size.
Requirements: open nums.bin with "wb", fwrite an int[5], check the return equals 5, then fclose.
Expected: ls -l nums.bin (or equivalent) shows 5 * sizeof(int) bytes (20 on most machines).
Hint: compare the fwrite return to the count, not the byte total. Concepts: fwrite contract, binary mode, return checking.
Beginner 2 — Append a record.
Objective: add one record to an existing file without erasing it.
Requirements: open with "ab", write a single struct { int id; int score; }, confirm fwrite returned 1, close.
Expected: the file grows by sizeof(struct) bytes each run.
Hint: "ab" always writes at the end regardless of position. Concepts: append mode, record writing.
Intermediate 1 — Round-trip a struct array.
Objective: write an array of records, read it back, and assert equality.
Requirements: write N structs, then in the same program reopen with "rb", fread them into a new array, and compare every field. Report mismatches.
Input/output: print OK if all match, else the first differing index.
Hint: use the same struct type and the same count for write and read. Concepts: write/read symmetry, return checking.
Intermediate 2 — Durable write with verification.
Objective: write records and guarantee they hit disk.
Requirements: after fwrite, call fflush, then fsync(fileno(f)), checking each return. Handle and report any failure.
Constraints: include <unistd.h> for fsync. Hint: fileno(f) bridges FILE * to the descriptor. Concepts: buffering, durability, error handling.
Challenge — Portable, versioned format. Objective: design a binary format that survives moving between machines. Requirements: write a header (4-byte magic number, 1-byte version, 4-byte record count), then write each record's integer fields byte by byte in big-endian order rather than dumping the struct. Write a matching reader that validates the magic and version before loading. Reject a file with the wrong magic. Constraints: do not rely on struct layout or native byte order anywhere. Hint: shift and mask each int into four bytes; reassemble on read. Concepts: serialization, endianness, format versioning, validation.
fwrite(buf, sz, n, f) copies n items of sz bytes each from buf into f and returns the number of items written; a value below n means an error, confirmed with ferror."wb"/"ab" so byte data is never altered (critical on Windows).fflush, fclose, or a full buffer. For true durability call fflush then fsync(fileno(f)) at checkpoints.&pointer instead of the data. Compare to the count, use (buf, sizeof(element), count, f), and write the data itself.sz * n valid bytes, watch for size_t overflow, and pair every fopen with fclose.