Shell redirection looks compact:
command >output.log 2>&1But that syntax hides a useful systems concept. The shell is not asking the command to “log somewhere else.” It is changing the command’s file descriptors before the program starts.
Understanding that model explains several behaviors that otherwise feel arbitrary:
- why
>output.log 2>&1and2>&1 >output.logare different; - why duplicated descriptors can share one file position;
- why closing one descriptor does not necessarily close the underlying open file;
- why
dup2()is safer than a manualclose()followed bydup(); - how shells connect standard input, standard output, standard error, pipes, terminals, and files with the same small set of primitives.
The central mental model is:
A file descriptor is a process-local integer that refers to an open file description. Duplicating a descriptor creates another reference to the same open file description.
The distinction between the descriptor and the open file description is the key to everything that follows.
A file descriptor is a process-local handle
On Linux, a successful open() returns a small non-negative integer such as 3 or 4. That integer is the file descriptor, often abbreviated as fd.
A process uses the descriptor in later system calls:
open("data.txt") -> 3
read(3, ...)
close(3)The descriptor itself is not the file. It is an entry in the process’s file-descriptor table.
A simplified picture is:
process file-descriptor table
fd 0 -> terminal input
fd 1 -> terminal output
fd 2 -> terminal output
fd 3 -> open file description -> data.txtBy convention, processes normally begin with three familiar descriptors:
| Descriptor | Conventional role |
|---|---|
0 |
standard input |
1 |
standard output |
2 |
standard error |
Those roles are conventions, not special file types. Descriptor 1 might point to a terminal, a regular file, a pipe, a socket, or something else depending on how the process was started.
That is why a program can write to standard output without knowing whether a human, a file, or another process will receive the bytes.
The open file description holds shared I/O state
Linux documentation uses open file description for the kernel object created by open() that stores state associated with an open file.
Among other things, it contains:
- the current file offset;
- file status flags such as append mode;
- a reference to the underlying file.
A file descriptor refers to that object.
This distinction matters because two descriptors can refer to the same open file description:
fd 3 ----\
+--> open file description --> data.txt
fd 4 ----/When that happens, the descriptors are different integers, but some I/O state is shared.
The current file offset is the most visible example.
dup() creates another descriptor for the same open file description
The dup() system call duplicates an existing descriptor.
Suppose descriptor 3 refers to data.txt:
fd 3 -> open file description -> data.txtAfter:
int copy = dup(3);the new descriptor also refers to the same open file description:
fd 3 ----\
+--> open file description -> data.txt
fd 4 ----/The exact returned number depends on which descriptor numbers are free. dup() chooses the lowest-numbered available descriptor.
The important guarantee is not the number. It is the sharing relationship.
Duplicates share the file offset
Consider a file containing:
abcdefIf descriptor 3 and descriptor 4 are duplicates, reading two bytes through descriptor 3 advances the shared offset from 0 to 2.
A read through descriptor 4 therefore begins at byte 2, not byte 0:
read(fd3, 2) -> "ab"
shared offset is now 2
read(fd4, 2) -> "cd"
shared offset is now 4This is different from opening the pathname twice.
Two separate open() calls normally create two different open file descriptions:
fd 3 -> open file description A -> data.txt
fd 4 -> open file description B -> data.txtEach open file description has its own file offset, so reads through one descriptor do not advance the other’s position.
That distinction is useful when debugging code that unexpectedly shares a seek position.
Duplicated descriptors do not share every descriptor property
The shared state lives in the open file description, but a process also stores some flags on each descriptor-table entry.
One important example is the close-on-exec descriptor flag, FD_CLOEXEC.
A descriptor duplicated with dup() refers to the same open file description, but it has its own descriptor flags. In particular, dup() returns a descriptor whose close-on-exec flag is cleared.
This gives a practical rule:
Do not infer that every property of one descriptor automatically applies to its duplicate.
When descriptor inheritance across exec matters, prefer APIs that let you establish close-on-exec behavior without creating a race window. On Linux, dup3() can duplicate onto a chosen descriptor while setting O_CLOEXEC.
dup2() duplicates onto a descriptor number you choose
dup() chooses the destination descriptor automatically. Redirection often needs a specific destination.
For example, standard output must be descriptor 1.
dup2(oldfd, newfd) makes newfd refer to the same open file description as oldfd.
Conceptually:
before
fd 1 -> terminal
fd 3 -> output.log
dup2(3, 1)
after
fd 1 ----\
+--> open file description -> output.log
fd 3 ----/If descriptor 1 was already open, dup2() closes that old reference as part of replacing it.
This is the primitive behind a large amount of shell redirection.
Why dup2() is not just close() followed by dup()
A tempting implementation is:
close(newfd);
dup(oldfd);That is not equivalent in concurrent or signal-heavy code.
Between close() and dup(), the descriptor number is free. Another thread or a signal handler could open a file or allocate a descriptor and take that number.
Then the following dup() could receive a different descriptor, and the program would redirect the wrong entry.
dup2() performs the close-and-rebind operation atomically with respect to descriptor allocation.
That guarantee is one reason the system call exists.
There is still error handling to consider: dup2() can fail, for example when oldfd is invalid. If oldfd == newfd and oldfd is valid, dup2() simply returns newfd without changing it.
Linux also provides dup3(), which differs in two notable ways:
- it can set close-on-exec with
O_CLOEXEC; - it fails with
EINVALwhen the old and new descriptor numbers are equal.
Use the behavior that matches the lifecycle your program needs.
Shell output redirection is descriptor rebinding
Now consider:
command >output.logFor a normal external command, the shell conceptually performs work like this before executing the program:
1. open output.log for writing
2. make descriptor 1 refer to that open file
3. execute commandThe implementation details vary, but the descriptor model is stable.
The command continues to write to standard output, descriptor 1. It does not need to know that the shell changed what descriptor 1 refers to.
Before redirection:
fd 1 -> terminalAfter redirection:
fd 1 -> output.logThat is why the same program works in all of these forms:
command
command >output.log
command | another-commandThe program can keep using standard output while the shell decides where that output goes.
2>&1 duplicates a descriptor; it does not name a destination
The expression:
2>&1means: make descriptor 2 refer to the same open file description that descriptor 1 refers to at this point in redirection processing.
That final phrase matters.
Suppose both standard output and standard error initially point at the terminal:
fd 1 -> terminal
fd 2 -> terminalNow run:
command >output.log 2>&1Bash processes redirections from left to right.
First:
>output.logchanges descriptor 1:
fd 1 -> output.log
fd 2 -> terminalThen:
2>&1duplicates the current descriptor 1 onto descriptor 2:
fd 1 -> output.log
fd 2 -> output.logBoth streams therefore go to the file.
Redirection order matters because duplication copies the current reference
Reverse the order:
command 2>&1 >output.logStart again with:
fd 1 -> terminal
fd 2 -> terminalThe first redirection is:
2>&1So descriptor 2 becomes a duplicate of descriptor 1 as it exists now:
fd 1 -> terminal
fd 2 -> terminalThey may now be separate descriptors pointing at the same underlying terminal-related open file description.
Next:
>output.logchanges only descriptor 1:
fd 1 -> output.log
fd 2 -> terminalThe earlier duplication is not a permanent link saying “wherever fd 1 goes later, fd 2 follows.”
It copied the reference that descriptor 1 had at that moment.
This gives a compact rule for reading shell redirections:
Evaluate each redirection from left to right and redraw the descriptor table after each step.
That method scales better than memorizing special cases.
A pipe changes descriptors too
A pipeline such as:
producer | consumeralso makes sense as descriptor wiring.
A pipe has a read end and a write end. Conceptually, the shell arranges:
producer fd 1 -> pipe write end
consumer fd 0 -> pipe read endThen producer writes normal standard output and consumer reads normal standard input.
The programs do not need special “pipeline mode.”
This is one of the strengths of the Unix process model: many tools can compose because they agree to read and write standard descriptors while the parent process builds the connections.
Closing one duplicate does not necessarily close the underlying open file
Suppose descriptors 3 and 4 refer to the same open file description:
fd 3 ----\
+--> open file description -> data.txt
fd 4 ----/Closing descriptor 3 removes one reference:
fd 4 -> open file description -> data.txtDescriptor 4 remains usable.
The open file description stays alive while references to it remain. Those references may exist through duplicated descriptors and, depending on process creation, in other processes.
This is why descriptor lifetime and underlying-object lifetime are related but not identical.
A common resource-management mistake is to assume that closing one descriptor invalidates every duplicate. It does not.
The opposite mistake is also possible: forgetting that an extra duplicate keeps the underlying object referenced longer than expected.
fork() can extend the same sharing relationship across processes
After fork(), the child inherits copies of the parent’s file descriptors.
Those inherited descriptors refer to the same open file descriptions as the corresponding descriptors in the parent.
That means a shared file offset can cross the process boundary:
parent fd 3 --\
+--> same open file description
child fd 3 --/If one process changes the file offset through that shared open file description, the other process observes the updated position.
This behavior is often exactly what a shell needs when constructing pipelines and redirections before executing commands.
It can also surprise application code that forks while holding descriptors and then performs file-position-sensitive I/O in both processes.
Use pread() and pwrite() when shared offsets are the wrong abstraction
Some programs need independent I/O positions even when they share an underlying open file description.
Changing the shared offset with lseek() and then calling read() can be difficult to reason about when multiple execution contexts use the descriptor.
Linux and POSIX provide positioned I/O operations such as pread() and pwrite(). They perform an I/O operation at a specified offset without changing the open file description’s current file offset.
Conceptually:
read(fd, ...) -> uses and advances shared current offset
pread(fd, ..., 100) -> reads at offset 100 without changing current offsetThat can make concurrent random-access code easier to reason about.
It does not remove every concurrency concern. Applications still need to reason about overlapping writes, file growth, external modification, and the guarantees of the filesystem and storage stack.
Descriptor numbers are reusable, so stale integers are dangerous
A descriptor is just a small process-local integer.
After:
close(3);descriptor number 3 becomes available for reuse.
A later open(), socket(), pipe(), or similar operation may return 3 again, but now it refers to a different resource.
That creates a dangerous class of bugs:
old meaning: fd 3 -> config file
close(3)
...
new meaning: fd 3 -> network socketCode that accidentally keeps and reuses the stale integer 3 may operate on the socket instead of failing cleanly.
The lesson is to treat descriptor lifetime explicitly:
- stop using a descriptor immediately after closing it;
- structure ownership so it is clear who closes each descriptor;
- avoid keeping raw descriptor numbers in long-lived state without a defined lifecycle;
- be careful when descriptor operations happen across threads, signal handlers, and process boundaries.
Common mistakes come from collapsing two layers into one
Most confusion becomes simpler when you separate descriptor table entries from open file descriptions.
Mistake: assuming two descriptor numbers mean two independent file positions
That is false when one descriptor was duplicated from the other.
Check whether the descriptors share an open file description.
Mistake: treating 2>&1 as “send stderr to stdout’s eventual destination”
It means “duplicate stdout’s current descriptor reference onto stderr now.”
That is why order matters.
Mistake: replacing dup2() with close() plus dup()
The manual sequence introduces a descriptor-allocation race window.
Use the atomic operation when you need a specific descriptor number.
Mistake: assuming a descriptor stays associated with one resource forever
Descriptor numbers are reused after close.
The number has meaning only while that descriptor-table entry is valid.
When this model is useful
You do not need to think about open file descriptions for every file operation.
High-level file objects are usually the right interface for ordinary application code.
The lower-level model becomes useful when you work with:
- shell redirection;
- pipelines;
- subprocess setup;
- daemons and service managers;
- inherited descriptors;
- descriptor leaks;
fork()andexec();- sockets and pipes;
- shared file offsets;
- close-on-exec behavior;
- low-level debugging under
/proc/<pid>/fd.
It is also useful when a higher-level API behaves unexpectedly. Knowing the underlying descriptor relationships helps you ask the right question instead of treating the behavior as magic.
Conclusion
Linux file descriptors are process-local integers, while open file descriptions hold shared state such as the current file offset.
dup() and dup2() create new descriptor references to the same open file description. That sharing explains why duplicated descriptors can advance one file position together and why closing one duplicate does not necessarily close the underlying open file.
Shell redirection builds on the same model. 2>&1 duplicates the reference held by descriptor 1 at the moment the redirection is processed, so left-to-right ordering changes the result.
When redirection becomes confusing, draw the descriptor table after each operation. That small mental model turns compact shell syntax into ordinary reference manipulation and makes low-level process I/O much easier to reason about.