← articles
CLinuxUnixSystems Programming

Understanding Stdio Buffering: Why fork() + exit() Duplicates Output

Learn why printf output duplicates when redirecting to a file in Linux -- and how stdio buffering, fork(), and exit() interact to cause unintended behavior.

March 1, 2026/6 min read

Silent Duplication: printf(), fork(), and Stdio Buffering

Learn why printf() output duplicates when redirecting to a file in Linux -- and how stdio buffering, fork() and exit() interact to cause silent bugs.

The Surprising Behavior

You write a simple C program, run it, and everything works as expected. But one day, you decide to redirect the output to a file, and suddenly the output is duplicated, maybe even in the wrong order. No code changed. No logic changed. Just ./prog > out.txt instead of ./prog.

Understanding how stdio buffering works can save a lot of headaches in the future.

How printf() Works

Most programmers think of printf() as "write this text to the terminal". This mental model is close enough for casual use, but it's wrong in a way that can bite you if you ever deploy your code to users.

printf doesn't write directly to a file descriptor. It writes into a userspace buffer -- a chunk of memory managed by the C standard library. The actual write() syscall only happens when that buffer is flushed. When flushing occurs depends on the buffering mode of the stream.

There are three buffering modes:

  • Unbuffered -- every write goes to the file descriptor immediately. stderr uses this by default.
  • Line-Buffered -- the buffer is flushed whenever a newline (\n) is written, or when the buffer fills up. stdout uses this when connected to a **terminal (tty) **
  • Fully-Buffered -- the buffer is only flushed when it fills up or is explicitly flushed with fflush(). stdout switches to this mode when connected to anything that isn't a tty -- including a file.

The last point is key to understanding the problem we described earlier. When you redirect stdout to a filew, the C library detects that the file descriptor is not a tty (via isatty()) and silently switches to full buffering. Your printf() calls now accumulate in memory and are written out in batches.

exit() vs. _exit(): Not the Same Thing

Before we look at the bug, you need to understand the difference between exit() and _exit(), the latter being the raw system call.

exit() is a libc function. Before it hands control to the kernel, it performs cleanup:

  • Calls any functions registered with atexit()
  • Flushes and closes all open stdio streams (via fflush())
  • Then calls _exit()

_exit() (and the raw sys_exit syscall) skips all of that. It terminates the process immediately, leaving any unflushed stdio buffers unwritten to disk.

This distinction is normally invisible -- most programs just call exit() or return from main(), and everything flushes cleanly. But once you add fork() into the mix, things can get weird.

The Bug: fork() Copies the Buffer

Here is a simple program that demonstrates the bug:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
 
int main(void) {
    printf("hello\n");
    fork();
    exit(0);
}
 

Run the program normally:

$ ./prog
hello

Redirect to a file:

$ ./prog > out.txt
$ cat out.txt
hello
hello

Same program, different output. What gives?

When your program runs on a terminal, stdout is line-buffered. The \n at the end of "hello\n" triggers an immediate flush -- the buffer is empty by the time fork() is called. Both the parent and child inherit an empty buffer, and exit(0) has nothing to flush. We get the expected output: "hello".

When you redirect to a file, stdout is fully-buffered. The \n does nothing special -- the string "hello\n" sits in the userspace buffer, unflushed. Then fork() gets called.

fork() creates a complete copy of the calling process, including its entire userspace memory -- including the stdio buffer. Both the parent and child now hold an independent copy of the buffer containing "hello\n". Both call exit(0). Both flush their buffers to the file descriptor. The file gets written to twice.

Before fork():
    [stdio buffer: "hello\n"]
 
After fork():
    Parent: [stdio buffer: "hello\n"] -> exit(0) -> fflush() -> writes "hello\n"
    Child:  [stdio buffer: "hello\n"] -> exit(0) -> fflush() -> writes "hello\n"
 
File contents: "hello\nhello\n"
 

Note: If you remove the newline, it makes things worse.

printf("hello"); // no \n
fork();
exit(0);

Now it duplicates the output in the terminal too, because even line-buffered stdout hasn't flushed yet by the time fork() is called. The buffer copy problem is the same -- but without a newline to trigger a flush, now you will see it in the terminal and in the file.

The Solution

There are two standard solutions.

Fix 1: Flush Before Forking

Explicitly drain all stdio buffers before calling fork():

printf("hello\n");
fflush(stdout);
fork();
exit(0);

Since the buffer is empty when fork() runs, neither process has anything to flush on exit. One write, one copy in the file.

Fix 2: Use _exit() in the Child

In most real programs -- daemons, shells, anything that forks to do work -- the correct pattern is to have the child use _exit() instead of exit(), and have only the parent call exit():

printf("hello\n");
 
if (fork() == 0) {
    /* child does its work */
    _exit(0); // no fflush, no atexit handlers
}
 
/* parent continues */
exit(0); // only the parent flushes

_exit() bypasses stdio cleanup entirely. The child exits without flushing the inherited buffer copy, so only the parent's flush reaches the file. This is actually the recommended approach in any fork()-based program because it also prevents atexit() handlers from running multiple times, which can cause double-frees, duplicate log entries, or corrupted state in cleanup code.

Controlling Buffering Mode Manually

If you need explicit control over buffering regardless of whether stdout is tty, use setvbuf():

// Force line buffering on stdout
setvbuf(STDOUT_FILENO, NULL, _IOLBF, 0);
 
// Force unbuffered
setvbuf(STDOUT_FILENO, NULL, _IONBF, 0);
 
// Force fully-buffered with custom buffer size
setvbuf(STDOUT_FILENO, NULL, _IOFBF, 4096);

Call setvbuf() before any I/O on the stream. This is useful when writing programs that pipe output to other programs and you want predictable buffering behavior without having fflush() calls everywhere.

Summary

  • printf() writes to a userspace buffer -- the actual write() syscall is deferred.
  • stdout is line-buffered on a tty, fully-buffered when redirected to a file or pipe.
  • fork() copies the entire process image, including unflushed stdio buffers.
  • exit() flushes stdio' _exit() does not. Both parent and child calling exit() means both flush the inherited buffer copy.
  • Always call fflush(stdout) before fork(), or use _exit() in the child.
  • This bug only surfaces under file redirection, making it easy to miss.

The deeper lesson here is that C standard library's I/O layer is an abstraction sitting between your code and the kernel, and fork() brings a lot of the underlying complexity to the surface. Having a deeper understanding of the C standard library and how it interacts with the kernel can save you a lot of headaches and unexpected bugs down the road.