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:
| Mode | Semantics |
|---|---|
"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 tounsigned charthen toint) orEOF.fputc(c, fp)/putc(c, fp)— write one character; return the character orEOF.getchar()— equivalent togetc(stdin).putchar(c)— equivalent toputc(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:
| Function | Output 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 along. Returns-1Lon error.fseek(fp, offset, whence)repositions the stream.whenceis one ofSEEK_SET(offset from start),SEEK_CUR(offset from current),SEEK_END(offset from end).rewind(fp)is shorthand forfseek(fp, 0L, SEEK_SET)plus clearing the error indicator.fgetpos(fp, &pos)andfsetpos(fp, &pos)use an opaquefpos_ttype; preferred for very large files sincelongmay 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
stdoutconnected 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
ftellandfseekwithSEEK_SETmay 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.