| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user |
| Scope | Custom Bash file descriptors (FD 3 and higher): exec open/close, >&n, read -u, read/write FDs, save/restore stdout and stderr, dynamic {var} allocation, inheritance, and varredir_close. Does not cover FD 0/1/2 basics, pipelines, or file locking with flock. |
| Related guides | Shell scripting course hub |
Bash file descriptors let you keep extra input and output streams open beyond stdin (0), stdout (1), and stderr (2). With exec, you can open FD 3 or another descriptor once, reuse it across commands, read from it with read -u, temporarily save stdout or stderr, and close it when finished.
For larger scripts, Bash's {varname} syntax can allocate an unused descriptor automatically instead of hard-coding FD 3. FD 0, 1, and 2 are covered in Bash redirection; this page focuses on custom descriptors and how to manage them safely.
File Descriptors Beyond 0, 1 and 2
Every open file, pipe, or socket in a process is tracked by a small integer called a file descriptor (FD). Interactive Bash starts with three standard streams:
For the kernel-level model behind these shell operations, including
open(), dup(), process limits, and /proc/PID/fd, see
Linux file descriptors.
| FD | Stream | Role |
|---|---|---|
| 0 | stdin | Input |
| 1 | stdout | Normal output |
| 2 | stderr | Diagnostics and errors |
Scripts commonly use FDs 3–9 for extra streams when those numbers are free. Common uses include:
- A dedicated log or trace file while stdout stays clean for data
- Two (or more) input files read in alternation without reopening
- Saving stdout or stderr, redirecting briefly, then restoring
GNU Bash warns against manually choosing descriptors greater than 9 because they may conflict with descriptors Bash uses internally. Even FDs 3–9 work only when nothing else in the current shell already occupies that number. For reusable code, {varname} allocation is safer because Bash chooses an unused descriptor of 10 or greater.
Open a Custom FD with exec
Use exec with a numeric FD and a redirection operator to open a descriptor for the rest of the current shell (or subshell). The file is opened once; later commands inherit the FD until you close it or exit.
Create a lab directory and open FD 3 for append-only logging:
mkdir -p ~/fd-lab && cd ~/fd-lab
exec 3>>diag.logexec 3>>diag.log attaches FD 3 to diag.log in append mode. Nothing prints yet because no command wrote through FD 3.
Open FD 3 for reading instead with 3<file:
printf 'alpha line\n' > file-a.txt
exec 3<file-a.txtFD 3 now points at file-a.txt for input. The shell keeps the file open until you close FD 3, replace FD 3 with another redirection, or the shell exits.
For read/write on one open file descriptor, use 3<>file:
printf 'seed line\n' > rw.txt
exec 3<>rw.txt3<>rw.txt opens the file once for both reading and writing. This is not append mode: reads and writes share one file offset, and any read or write moves that position.
Write to an FD
Send bytes to an open FD with >&n (same idea as >&2 for stderr). FD 1 still goes to the terminal unless you redirected it separately.
Truncate the log, open FD 3 for append, and split normal output from diagnostics:
: > diag.log
exec 3>>diag.log
printf 'DATA: result=42\n'
printf 'DIAG: checked input files\n' >&3DATA: result=42The DATA: line used FD 1 (stdout). The DIAG: line went only to FD 3, so it does not appear in the terminal transcript above.
Confirm what landed in the log file, then close FD 3:
cat diag.logDIAG: checked input filesOnly the diagnostic line is in diag.log. stdout stayed on the terminal.
exec 3>&-exec 3>&- closes the log FD so a later section can reuse FD 3 for a different file.
Read from an FD with read -u
The Bash read builtin accepts -u fd to read from a file descriptor instead of stdin. That lets you read from a custom FD while stdin stays available for something else. The -u form is Bash-specific; scripts run under sh may not support it.
Open two inputs and read one line from each with both -u and the <&n form:
printf 'alpha line\n' > file-a.txt
printf 'beta line\n' > file-b.txt
exec 3<file-a.txt
exec 4<file-b.txt
read -r -u 3 line3
read -r line4 <&4
printf 'from FD 3: <%s>\n' "$line3"
printf 'from FD 4: <%s>\n' "$line4"from FD 3: <alpha line>
from FD 4: <beta line>read -r -u 3 line3 is Bash's explicit -u form: it reads from FD 3 instead of stdin. read -r line4 <&4 redirects that one invocation's stdin from FD 4. Both patterns work when the FD is already open. For looping through files line by line, see read a file line by line.
Close the read FDs when the loop finishes:
exec 3<&-
exec 4<&-Each exec n<&- closes input FD n. The commands exit silently on success.
Open an FD for Reading and Writing
Use 3<>file when the same descriptor must support both reading and writing. This is not append mode: reads and writes share one file offset, and writes occur at the current position.
Prepare the file and open FD 3 for read/write:
printf 'seed line\n' > rw.txt
exec 3<>rw.txt
read -u 3 first
printf 'read back: <%s>\n' "$first"read back: <seed line>Write through the same FD and inspect the file on disk:
printf 'second line\n' >&3
exec 3>&-
cat rw.txtseed line
second lineHere the first read consumed the file's only line and left the offset at EOF, so the subsequent write was appended. Writing earlier in the file could overwrite existing bytes. For heavy read/write on the same file, separate FDs or explicit reopening is simpler than fighting the shared offset.
Close File Descriptors Correctly
An open FD stays open until the shell closes it or exits. Forgetting to close one in a long-running script can waste descriptor slots, keep files or pipes open longer than intended, and let child processes inherit streams you no longer need. An inherited write end can also prevent readers from seeing EOF, and background jobs may keep resources alive until they exit.
When a script can stop early, a Bash trap on EXIT can
close descriptors alongside its other cleanup instead of relying only on
the normal path.
Close FD 3 with either closing redirection form:
exec 3>&-or:
exec 3<&-You will often see >&- after output examples and <&- after input examples, but both close the specified file descriptor.
Open FD 3, write through it, then close with >&-:
exec 3>>diag.log
printf 'DIAG: temporary\n' >&3
exec 3>&-The same close forms work on a read-only FD — exec 3<&- would close it just as well.
After exec 3>&- or exec 3<&-, FD 3 is free for reuse. Close custom descriptors as soon as they are no longer needed, especially before returning from a function or starting long-lived child or background processes that should not inherit them.
On Linux you can inspect open descriptors for the current shell: Use the lsof command when you need to inspect every file, pipe, and socket held by a process rather than one known FD.
exec 3>>diag.log
ls -l /proc/$$/fd/3
exec 3>&-l-wx------. 1 golinuxcloud golinuxcloud 64 Sep 10 13:27 /proc/141219/fd/3 -> /home/golinuxcloud/fd-lab/diag.logThe PID, timestamp, username, path, and permission details vary by system; the important part is that /proc/$$/fd/3 resolves to the file currently attached to FD 3. After exec 3>&-, that entry disappears.
Save and Restore stdout and stderr
To capture stdout temporarily without losing the terminal connection, duplicate FD 1 onto a spare FD, redirect stdout to a file, then copy the spare back onto FD 1.
Save stdout on FD 3, redirect stdout to a file, then restore:
exec 3>&1
exec 1>capture.txt
printf 'this goes to the file only\n'
exec 1>&3
exec 3>&-
printf 'this is back on the terminal\n'this is back on the terminalOnly the restored line appears above. The redirected line went to capture.txt:
cat capture.txtthis goes to the file onlyThe sequence matters: exec 3>&1 saves stdout, exec 1>file moves stdout, exec 1>&3 restores stdout from the copy, and exec 3>&- closes the spare FD.
Use the same pattern for stderr: exec 3>&2, exec 2>errors.log, work, then exec 2>&3 and exec 3>&-.
Let Bash Allocate a Dynamic FD with {var}
Instead of hard-coding 3 or 4, Bash can allocate a free descriptor and store its number in a shell variable:
exec {logfd}>>diag.log
printf 'Bash chose FD %s\n' "$logfd"
printf 'DIAG: allocated fd number is %s\n' "$logfd" >&"$logfd"
exec {logfd}>&-Bash chose FD 10The exact number can differ; Bash chooses an unused descriptor of 10 or greater. The diagnostic line went to the log file through >&"$logfd". Write with >&"$logfd" and close with exec {logfd}>&- — the braces tell Bash which variable holds the number.
Wrap that in a function so each caller gets a fresh FD without colliding with FD 3 in another part of the script:
log_diag() {
local logfd
exec {logfd}>>diag.log
printf 'DIAG: %s\n' "$1" >&"$logfd"
exec {logfd}>&-
}
log_diag 'function scoped message'
tail -1 diag.logDIAG: function scoped messageDynamic allocation avoids guessing which numeric FD is still free in large scripts. Manual use of FD 10+ without {var} can still clash with Bash internals; let the shell assign the number when you can.
Descriptor Lifetime and Child Processes
File descriptors opened with exec in the current shell are inherited by child processes — subshells, commands in a pipeline, and scripts launched from the same shell.
Truncate a fresh log file and show that a child can write through an inherited FD:
: > inherited.log
exec 3>>inherited.log
printf 'parent write\n' >&3
bash -c 'printf "child write\n" >&3'
exec 3>&-
cat inherited.logparent write
child writeThe child shell could write to FD 3 without opening the file itself because it inherited the descriptor from its parent. Inherited FDs are useful when a helper script should share a log stream; they are a leak risk when you spawn background jobs and forget to close FDs first.
Closing exec 3>&- in the parent does not affect children that already inherited a copy. Design functions that open FDs to close them before starting long-lived background work unless those children should keep the stream.
varredir_close — Advanced Option
By default, a {varname} redirection on a simple command leaves the FD open after that command finishes so you can keep using $varname. The varredir_close shell option reverses that: Bash closes the FD when the command completes.
Check the current setting:
shopt varredir_closevarredir_close offWith the option off, the FD stays open after the command. The examples below show FD 10, but Bash may choose another unused descriptor of 10 or greater:
shopt -u varredir_close
: {log}>>diag.log
printf 'Bash allocated FD %s\n' "$log"
ls -l "/proc/$$/fd/$log"
exec {log}>&-Bash allocated FD 10
l-wx------. 1 golinuxcloud golinuxcloud 64 Sep 10 13:36 /proc/143742/fd/10 -> /home/golinuxcloud/fd-lab/diag.logThe no-op : command allocates the descriptor; printf only reports the number Bash stored in $log. FD $log still exists after : returns.
Enable auto-close and run the same pattern:
shopt -s varredir_close
: {log}>>diag.log
if [[ -e /proc/$$/fd/$log ]]; then
printf 'FD %s is open\n' "$log"
else
printf 'FD %s is closed\n' "$log"
fi
shopt -u varredir_closeFD 10 is closedBash closed the descriptor when : finished. The variable $log may still hold 10, but the descriptor is no longer valid — do not write to it after auto-close. The final shopt -u varredir_close restores the default for later commands in the same shell.
exec {var}>file in the shell itself is separate: you still close those with exec {var}>&- manually. Use varredir_close when short one-liners should not leave stray FDs open.
exec Redirection vs exec program
exec does two different jobs in Bash:
| Form | Effect |
|---|---|
exec 3>file, exec 1>&2, … |
Redirects FDs in the current shell; no new process is created |
exec program args… |
Replaces the current shell process image with program (same PID); the shell does not return |
Demonstrate process replacement in a subshell so your interactive session survives:
bash -c 'echo before; exec echo replaced; echo after'before
replacedecho after never ran because exec echo replaced the subshell. There is no return to the rest of the script after exec program.
Redirecting FDs does not create a new process:
bash -c 'exec 3>diag.log; printf "still running\n"; exec 3>&-'still runningThe subshell stayed alive after exec 3>diag.log. Only the exec program form replaces the process image in place.
Practical Example: Data stdout + Diagnostic Log FD
This pattern keeps machine-readable data on stdout while human-oriented
traces go to a log FD. The broader Bash script logging
guide compares that design with stderr, tee, syslog, and journald for
cron jobs and services. Keeping the streams separate is also useful for
scripts consumed by other tools or pipes.
Run the whole example in a subshell so your interactive FDs stay untouched:
bash -c '
exec 3>run.log
printf "DATA: id=7,name=widget\n"
printf "DIAG: validated schema\n" >&3
printf "DATA: id=8,name=gadget\n"
printf "DIAG: done\n" >&3
exec 3>&-
'DATA: id=7,name=widget
DATA: id=8,name=gadgetDownstream tools see only DATA: lines. Diagnostics accumulated in the log:
cat run.logDIAG: validated schema
DIAG: doneFor exclusive access to a lock file while writing logs, the usual tool is flock from util-linux — that workflow is separate from FD wiring.
Common Bash File Descriptor Errors
| Symptom | Likely cause | Fix |
|---|---|---|
Bad file descriptor while writing to >&3 |
FD 3 is closed, unopened, or not open for writing | Open with exec 3>file or exec 3>>file before writing; verify the target with ls -l /proc/$$/fd/3 |
read -u 3 waits indefinitely |
FD 3 refers to a terminal, pipe, socket, or another stream that is still open but has no data available | Verify what FD 3 points to; use -t when waiting on a supported non-regular stream. At EOF on a regular file, read returns non-zero instead of waiting |
| Open FD count keeps growing in a loop | Repeated {fd} allocation without closing the previous descriptor |
Close with exec {fd}>&- each iteration, or open one descriptor before the loop and reuse it |
| Child inherits open log FD | Parent opened FD before background job | Close unneeded FDs before & or subshell; use {var} + explicit close |
| Hard-coded FD 10 fails on some systems | Collision with Bash internals | Use exec {name}>file and >&"$name" |
| Restore stdout still wrong | Saved/restored in wrong order | Save with exec 3>&1, restore with exec 1>&3, then exec 3>&- |
{var} FD after one-liner still open |
Default varredir_close off |
shopt -s varredir_close or manual exec {var}>&- |
Summary
Custom file descriptors let a Bash script keep extra streams open beyond stdin, stdout, and stderr. You open them with exec 3>file, exec 3<file, or exec 3<>file, write with >&3, read with read -u 3, and close with exec 3>&- or exec 3<&- so descriptors do not remain open longer than necessary.
Saving and restoring stdout follows a fixed pattern: duplicate FD 1 onto a spare FD, redirect stdout, do the work, copy the spare back, then close the spare. For maintainable scripts, exec {logfd}>>file asks Bash for a free descriptor number instead of hard-coding 3 everywhere — and you should still close {logfd} when finished unless varredir_close handles a one-shot command.
Children inherit open FDs from the parent shell, which helps shared logging and hurts when background jobs keep files open longer than you intended. Remember that exec 3>file only rewires the current shell; exec program replaces the shell's process image and never returns to the lines below it.
References
- GNU Bash Manual — Redirections
- GNU Bash Manual — Bourne Shell Builtins (
exec) - GNU Bash Manual — The Shopt Builtin (
varredir_close) - read — Bash manual
- Linux man-pages — proc_pid_fd(5) —
/proc/<pid>/fd/entries for open file descriptors
Frequently Asked Questions
Why use FD 3?
FD 3 is a convention, not a special descriptor. Scripts commonly use FDs 3–9 for extra streams when those numbers are free. Bash warns that manually choosing descriptors greater than 9 may conflict with descriptors the shell uses internally, so larger scripts should prefer {varname} allocation, which asks Bash to choose an unused descriptor of 10 or greater.
How do I close an FD?
Use exec n>&- or exec n<&- to close descriptor n. For a dynamically allocated descriptor stored in logfd, use exec {logfd}>&-. After closing, do not read or write through that number until you open it again.
What does exec {fd}>file do?
Bash allocates an unused file descriptor of 10 or greater, opens file according to the operator (>, >>, <, or <>), and stores the number in fd. You can then redirect command output with >&"$fd" or close with exec {fd}>&-. This avoids hard-coding FD 3 when another part of the script may already use it.
How do I restore stdout?
Run exec 3>&1 to copy stdout onto spare FD 3, exec 1>file to redirect stdout to a file, then exec 1>&3 to copy the saved stdout back onto FD 1, and exec 3>&- to close the spare. The same duplicate-and-restore pattern works for stderr with 2 instead of 1.
Is exec starting a new process here?
No. exec does not create a new process. With redirections only, such as exec 3>log, Bash changes file descriptors in the current shell and continues running. With exec program args, Bash replaces the current shell process image with the program, so there is no shell to return to afterward.

