Polyglot
Languages C io
C § io

File I/O

The file half of <stdio.h> is the conventional file-input-and-output interface in standard C. Its model is a stream: a FILE * that wraps an underlying file descriptor (or its platform equivalent), buffers reads and writes, tracks the current position, and reports end-of-file and error conditions. The interface is older than POSIX, predates Unix’s open/read/write file descriptors, and remains the portable choice for ISO-C programs. POSIX provides a lower-level alternative — open, read, write, close — that is faster for some workloads but is not part of the standard library; portable C programs use <stdio.h> and accept the buffering as a feature rather than a cost.

The FILE * abstraction

A FILE * is an opaque pointer to an FILE structure that the implementation manages. The structure contains the underlying handle (a Unix file descriptor or a Windows HANDLE), the current buffer, the buffer’s read/write position, an end-of-file flag, an error flag, and other implementation-specific state. The standard does not expose the structure’s layout; programs interact with FILE * only through the <stdio.h> functions.

Three standard streams are available without explicit opening:

  • stdin — standard input. Conventionally connected to the terminal or to a pipe from another process.
  • stdout — standard output. Conventionally line-buffered when connected to a terminal, fully buffered otherwise.
  • stderr — standard error. Conventionally unbuffered.

The streams are visible to every translation unit that includes <stdio.h>. A program may freely read from stdin, write to stdout, and report diagnostics to stderr without any setup.

Opening and closing files

fopen opens a file and returns a FILE * for it; fclose closes the stream and releases its resources:

FILE *fp = fopen("data.txt", "r");
if (!fp) {
    fprintf(stderr, "open: %s\n", strerror(errno));
    return -1;
}

/* … use fp … */

fclose(fp);

fopen returns NULL on failure and sets errno; the conventional check is at the call site.

The mode string is a small mini-language describing what operations are permitted and how the stream behaves:

ModeSemantics
"r"Open for reading. The file must exist.
"w"Open for writing. Truncates an existing file or creates a new one.
"a"Open for appending. Creates the file if necessary. Writes always go to the end.
"r+"Open for reading and writing. The file must exist.
"w+"Open for reading and writing. Truncates or creates.
"a+"Open for reading and appending.

Each form may have b appended ("rb", "wb+") to indicate binary mode. On Unix-like systems the distinction is purely cosmetic: text and binary modes are identical. On Windows, text mode performs CRLF translation on input and output; binary mode does not. Portable code that handles arbitrary byte data uses binary mode unconditionally.

C11 added a notational x flag ("wx", "w+x") that causes fopen to fail rather than truncate if the file already exists — useful when a program must avoid overwriting:

FILE *fp = fopen(path, "wx");
if (!fp && errno == EEXIST) {
    /* file already exists; do not overwrite */
}

fclose returns 0 on success and EOF on error. The conventional discipline is to check the return: the close may flush buffered output that fails to write, and a successful series of fwrite calls followed by a failing fclose indicates that the data did not reach the filesystem.

Character I/O

The single-character primitives:

  • fgetc(fp) / getc(fp) — read one character; return the character (cast to unsigned char then to int) or EOF.
  • fputc(c, fp) / putc(c, fp) — write one character; return the character or EOF.
  • getchar() — equivalent to getc(stdin).
  • putchar(c) — equivalent to putc(c, stdout).
  • ungetc(c, fp) — push a character back onto the input stream; the next read returns it.

The return type of fgetc is int, not char, because EOF (typically -1) must be distinguishable from any valid character value. The conventional read loop:

int c;
while ((c = fgetc(fp)) != EOF) {
    process_byte((unsigned char)c);
}
if (ferror(fp)) {
    /* read error */
}

The cast (unsigned char)c is necessary if the byte will be stored in a char variable: on platforms where char is signed, an unsigned-to-signed conversion of a high-bit-set byte is implementation-defined.

Line I/O

fgets(buf, n, fp) reads up to n - 1 bytes (or up to the next newline, inclusive) into buf and writes a terminating null byte. The function returns buf on success and NULL on end-of-file or error.

char buf[LINE_MAX];
while (fgets(buf, sizeof buf, fp) != NULL) {
    /* buf contains a line, possibly including the trailing newline */
}

The trailing newline (if any) is included in the buffer; programs that want to remove it conventionally do so manually:

size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') buf[--len] = '\0';

Lines longer than n - 1 bytes are split across multiple fgets calls; the program can detect the split by checking whether the last byte before the null is a newline.

fputs(s, fp) writes the null-terminated string s to fp. It does not append a newline (in contrast to puts(s) which writes to stdout and adds one).

fputs("ready\n", stdout);

The historical gets(buf) function read a line from stdin into buf with no length limit — an unbounded buffer-overflow waiting to happen. It was deprecated in C99 and removed in C11. Code that must read a line of arbitrary length builds it up with fgets into successive buffer pieces.

Formatted I/O

The printf family in <stdio.h> produces formatted output:

FunctionOutput target
printf(fmt, …)stdout
fprintf(fp, fmt, …)fp
sprintf(buf, fmt, …)buf (no length limit — unsafe)
snprintf(buf, n, fmt, …)buf, up to n - 1 bytes plus null
vprintf(fmt, ap)stdout, with va_list
vfprintf(fp, fmt, ap)fp, with va_list
vsnprintf(buf, n, fmt, ap)buf, with va_list

snprintf is the safe variant; sprintf should be avoided in code that handles untrusted or variable-length input. The format-string mini-language — conversion specifiers, width, precision, length modifiers — is treated in Strings.

The scanf family parses formatted input. It is widely considered awkward: error reporting is limited, the field-width interaction with %s is non-obvious, and the rules around whitespace are unintuitive. For non-trivial input parsing, fgets followed by manual or strtol-based scanning is generally easier to make correct.

int  n;
char line[LINE_MAX];
if (fgets(line, sizeof line, fp) && sscanf(line, "%d", &n) == 1) {
    /* parsed n */
}

The composition above — read a line, then sscanf it — separates the I/O failure mode (line cannot be read) from the parse failure mode (line is malformed) and reports errors at the right level.

Binary I/O

fread and fwrite move arbitrary bytes between memory and a stream:

size_t fread (void       *ptr, size_t size, size_t count, FILE *fp);
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *fp);

Each transfers size * count bytes. The return value is the number of items (not bytes) successfully transferred; a short return indicates end-of-file or error.

struct record {
    int      id;
    double   value;
    char     name[32];
};

struct record records[100];
size_t n_read = fread(records, sizeof *records, 100, fp);
if (n_read < 100 && ferror(fp)) {
    /* read error */
}

fread/fwrite operate on raw bytes; they are not portable across platforms with different byte orders, structure paddings, or floating-point representations. For exchange formats that need cross-platform reproducibility, the conventional approach is a serialisation layer that handles each field individually with a defined byte order.

Positioning

The position of a stream — the offset at which the next read or write will occur — can be queried and changed:

  • ftell(fp) returns the current position as a long. Returns -1L on error.
  • fseek(fp, offset, whence) repositions the stream. whence is one of SEEK_SET (offset from start), SEEK_CUR (offset from current), SEEK_END (offset from end).
  • rewind(fp) is shorthand for fseek(fp, 0L, SEEK_SET) plus clearing the error indicator.
  • fgetpos(fp, &pos) and fsetpos(fp, &pos) use an opaque fpos_t type; preferred for very large files since long may be 32 bits and inadequate for files larger than 2 GiB.

A typical pattern: determine a file’s size by seeking to the end and reading the position:

fseek(fp, 0, SEEK_END);
long size = ftell(fp);
rewind(fp);

char *contents = malloc(size + 1);
fread(contents, 1, size, fp);
contents[size] = '\0';

The pattern works for files of moderate size; on systems where long is 32 bits it fails for files larger than 2 GiB, and fgetpos/fsetpos or POSIX lseek is the alternative.

Buffering

<stdio.h> streams are buffered: writes accumulate in an in-memory buffer and are flushed to the underlying file in batches. Three buffering modes are defined:

  • Fully buffered — the buffer is flushed when full or when explicitly flushed. Conventional for files.
  • Line buffered — the buffer is flushed when a newline is written or when full. Conventional for stdout connected to a terminal.
  • Unbuffered — every write is immediately propagated. Conventional for stderr.

fflush(fp) flushes the buffer for fp immediately. fflush(NULL) flushes all output streams. The conventional uses:

  • Before a long computation that does not touch the stream: flush so that intermediate progress is visible.
  • Before a fork: flush so that buffered output is not duplicated in both processes.
  • Before reading from a stream that was just written to: flush so that the read sees the writes.

setvbuf(fp, buf, mode, size) lets a program select a buffering mode and provide its own buffer. The options for mode are _IOFBF (fully buffered), _IOLBF (line buffered), and _IONBF (unbuffered). The function is rarely needed but useful when default buffering is wrong for the use case.

End-of-file and errors

A <stdio.h> operation that returns a sentinel (EOF, NULL, a short count) does not say why it failed. Two functions distinguish:

  • feof(fp) returns true if the stream has read past the end of the file.
  • ferror(fp) returns true if a read or write error has occurred.
  • clearerr(fp) resets both indicators.

The discipline is that operations report their failure via the sentinel, but the cause is determined by feof or ferror afterwards:

int c;
while ((c = fgetc(fp)) != EOF) {
    process(c);
}
if (ferror(fp)) {
    fprintf(stderr, "read error: %s\n", strerror(errno));
}
/* otherwise we reached EOF normally */

feof returns true only after a read attempt that failed because of EOF; reading the last byte of the file does not set the EOF indicator, only the next attempt does.

Conventional patterns

Several patterns recur in idiomatic file I/O.

Read an entire file into memory

FILE *fp = fopen(path, "rb");
if (!fp) return -1;

fseek(fp, 0, SEEK_END);
long size = ftell(fp);
rewind(fp);

char *contents = malloc(size + 1);
if (!contents) { fclose(fp); return -1; }

size_t n = fread(contents, 1, size, fp);
contents[n] = '\0';

fclose(fp);

The pattern works for files of moderate size; for very large files, streaming or memory-mapping is preferable.

Read line by line

FILE *fp = fopen(path, "r");
if (!fp) return -1;

char line[LINE_MAX];
while (fgets(line, sizeof line, fp) != NULL) {
    handle(line);
}

if (ferror(fp)) {
    fprintf(stderr, "read error\n");
}
fclose(fp);

fgets handles long lines by truncation; the loop must handle the case where a line did not end with a newline (it spilled into the next iteration).

Write fixed-size records

FILE *fp = fopen(path, "wb");
if (!fp) return -1;

for (size_t i = 0; i < n; ++i) {
    if (fwrite(&records[i], sizeof records[i], 1, fp) != 1) {
        fclose(fp); return -1;
    }
}

if (fclose(fp) != 0) return -1;   /* fclose can fail flushing */
return 0;

The check on fclose matters: a write may have been buffered, and the failure may surface only when the buffer is flushed at close.

Binary versus text mode

On Unix-like systems, the distinction is invisible: text and binary modes are identical. On Windows, text mode ("r", "w", etc.) performs CRLF↔LF translation on input and output. The conventional advice for portable code:

  • Open files containing arbitrary binary data with "b" mode.
  • Open text files (logs, configuration, source code) without "b" if the program intends to handle the platform’s native line endings; with "b" if the program intends to handle line endings explicitly.
  • Be aware that ftell and fseek with SEEK_SET may not be reliable on text streams (the standard permits the implementation to map text-mode positions in unintuitive ways); use binary mode if precise positioning matters.

The cleanup-label idiom for fallible multi-step file operations

A function that opens a file, allocates a buffer, performs the operation, and releases everything benefits from the cleanup-label idiom:

int copy_file(const char *src, const char *dst) {
    int   ret  = -1;
    FILE *in   = NULL;
    FILE *out  = NULL;
    char *buf  = NULL;

    in = fopen(src, "rb");
    if (!in) goto cleanup;

    out = fopen(dst, "wb");
    if (!out) goto cleanup;

    buf = malloc(BUFFER_SIZE);
    if (!buf) goto cleanup;

    size_t n;
    while ((n = fread(buf, 1, BUFFER_SIZE, in)) > 0) {
        if (fwrite(buf, 1, n, out) != n) goto cleanup;
    }

    if (ferror(in)) goto cleanup;

    if (fclose(out) != 0) { out = NULL; goto cleanup; }
    out = NULL;

    ret = 0;

cleanup:
    free(buf);
    if (out) fclose(out);
    if (in)  fclose(in);
    return ret;
}

The pattern keeps the acquisitions and the cleanups in one-to-one correspondence and runs them in reverse order on every exit path. The treatment of out near the success line — setting it to NULL after the explicit fclose succeeds — prevents the cleanup from closing it twice if a later step has a failure path that is added later.

A note on the alternatives

POSIX provides a lower-level interface — open, read, write, close, lseek — that operates on integer file descriptors with no buffering. The trade-offs:

  • POSIX I/O is faster for large transfers (no copy through the buffer) and required for some operations (sockets, pipes, terminal control).
  • <stdio.h> is portable to non-POSIX platforms and provides convenient line- and formatted-I/O.

Most C programs that target Unix-like systems use both: <stdio.h> for general-purpose file work, POSIX I/O for sockets and for performance-critical paths. The conversion between the two is straightforward: fileno(fp) returns the underlying file descriptor; fdopen(fd, mode) wraps a descriptor in a FILE *. Using both interfaces on the same stream simultaneously is risky — the buffering can confuse one or the other — and the conventional discipline is to settle on one interface per stream.