File Handling · intermediate · ~8 min

fwrite for binary output

**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`.

Overview

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.

Why it matters

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:

  • Data integrity. If you ignore the return value, a disk-full or permission error can silently truncate a save file, and the user only finds out when loading fails.
  • Durability. Buffered data that is never flushed is lost on a crash or power cut. Knowing when bytes are actually on disk is the difference between a reliable database and a corrupting one.
  • Portability. A binary file written on one machine may be unreadable on another if you ignore byte order, struct padding, and type sizes. Real formats solve this deliberately; naive code corrupts data across platforms.

These are the same concerns that show up in file formats, network protocols, and embedded firmware, so the habits you build here transfer widely.

Core concepts

1. The fwrite contract

size_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.

  • If the return value equals n, every item was written.
  • If it is less than 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?

2. Binary mode vs text mode

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.

3. Output buffering and durability

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:

  • the buffer fills up,
  • you call fflush(f),
  • or you call 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.

4. Writing records and structs

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?

Syntax notes

#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.

Lesson

What fwrite does

size_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.

  • If the return value equals n, all items were written.
  • If it is less than n, an error occurred. Check the error with ferror(f).

Buffering and durability

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:

  • you call fclose(f),
  • you call fflush(f),
  • the buffer fills up.

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 *.

Code examples

#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;

}

Line by line

  1. The 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.
  2. 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.
  3. 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.
  4. 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.
  5. We compare 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.
  6. 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.
  7. We reopen the file in "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.
  8. The loop prints each loaded player.

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 *.

Common mistakes

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.

Debugging tips

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:

  1. Did fopen succeed? Print the result and use perror on NULL.
  2. Did fwrite return the expected count? If not, call ferror(f) to confirm a stream error.
  3. Did you 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?

Memory safety

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.

  • Buffer over-read. If you tell 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.
  • Uninitialized bytes leak. Writing a partially-initialized struct copies whatever was in memory, including padding bytes, into the file. That is harmless for round-tripping but can leak stale data; memset the struct to 0 first if the file is shared or sensitive.
  • Integer overflow in 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.
  • Lifetime. Do not 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.
  • Resource cleanup. Every successful 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.

Real-world uses

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:

  • Always open binary files with b in the mode string.
  • Check fopen for NULL and check fwrite against the item count.
  • Pair every fopen with fclose; do not leak handles.
  • Use sizeof(element) for the size argument so it stays correct if the type changes.

Advanced habits:

  • For any file that crosses machines or is a published format, do not dump raw structs. Serialize each field in a fixed endianness (e.g. write integers big-endian byte by byte) and avoid relying on struct padding.
  • Add a small header: a magic number and a version field, so the reader can validate the file and evolve the format safely.
  • Use fflush + fsync(fileno(f)) at durability checkpoints; for atomic updates, write to a temp file and rename it over the target.
  • Validate sizes from untrusted sources before multiplying, to avoid size_t overflow.

Practice tasks

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.

Summary

  • 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.
  • Open binary files with "wb"/"ab" so byte data is never altered (critical on Windows).
  • Output is buffered: data may not reach disk until fflush, fclose, or a full buffer. For true durability call fflush then fsync(fileno(f)) at checkpoints.
  • The most common mistakes are comparing the return to the byte total, swapping size and count, and writing &pointer instead of the data. Compare to the count, use (buf, sizeof(element), count, f), and write the data itself.
  • Raw struct dumps round-trip fine within one program/platform but are not portable: padding and endianness differ. For shared or published formats, serialize field by field in a fixed byte order with a versioned header.
  • Remember the safety basics: the buffer must hold sz * n valid bytes, watch for size_t overflow, and pair every fopen with fclose.

Practice with these exercises