Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Bash Dynamic File Descriptors: Allocate, Share, and Close Deliberately

Manage Bash file descriptors with allocated {var} redirections, explicit lifetime, safe child inheritance, and tests that keep streams separate.

Bash can allocate a file descriptor and store its number in a variable with redirection syntax such as {log_fd}>>"$logfile". This avoids guessing that descriptor 3 or 9 is unused and can make a script’s input, output, diagnostics, and temporary channels easier to reason about. The feature also adds a lifetime contract: the descriptor may outlive the command that opened it, inherited descriptors can leak into child processes, and shell options affect automatic closure behavior.

This is a Bash extension, not portable POSIX shell syntax. Use a Bash shebang, document the supported versions, and avoid copying the syntax into a script that might run under dash, BusyBox sh, or /bin/sh. The examples use explicit closure so resource ownership is visible.

Allocate and use a descriptor

The shell allocates a descriptor at least 10 and assigns its number to the named variable. The exact number is an implementation choice; scripts should use the variable instead of assuming a fixed value.

#!/usr/bin/env bash
set -u

log_file=${1:?log file required}
if ! exec {log_fd}>>"$log_file"; then
    printf 'cannot open log file\n' >&2
    exit 1
fi

printf 'started at %(%Y-%m-%dT%H:%M:%S%z)T\n' -1 >&"$log_fd"
some_command 2>&"$log_fd"
exec {log_fd}>&-

The timestamp format operand shown here requires a sufficiently recent Bash; for an older supported version, use a separately validated date command or omit the formatted timestamp. The key descriptor syntax itself is a Bash manual feature. Check the opening operation’s status before relying on the descriptor, and keep the human-readable error on stderr so failure reporting does not depend on the log file that failed to open.

exec with only redirections changes the current shell’s descriptor table. That makes the descriptor available to later commands and functions in the script. A simple command with a redirection can have a narrower command environment depending on shell behavior and varredir_close; if code depends on a descriptor beyond one command, use exec or explicitly test the scope and option policy.

Redirection order still matters

Descriptor allocation does not change the left-to-right order of redirections. Opening a log descriptor and then duplicating stderr to it is not the same as duplicating stderr before the descriptor exists. Likewise, 2>&1 copies the current destination of stdout at that point; it does not create a permanent link between descriptor 2 and descriptor 1.

Use an explicit order and keep logs separate from machine-readable stdout. If a command emits JSON on stdout and progress on stderr, redirect only stderr to the log. If both streams belong in the log, redirect after the allocated descriptor exists. Test with a command that writes to each stream so an example that “looks right” does not silently mix them.

Lifetime and automatic closure

When Bash assigns a descriptor with {varname} redirection, the descriptor can persist beyond the scope of the command. The varredir_close shell option changes that behavior: when enabled, Bash automatically closes descriptors assigned this way when a command completes, except when the redirection is an argument to the exec builtin. The Bash manual says shopt options are disabled by default unless an option is specifically documented otherwise; varredir_close is not an exception, so it is off by default. It was added in Bash 5.2, so older Bash versions do not provide the option. Check availability and state on the interpreter you support (shopt -q varredir_close is a state query on versions that implement it). If a script relies on a descriptor surviving, make the lifetime explicit with exec and close it explicitly. If it relies on automatic closure, verify both the target Bash version and the option state.

Close a write descriptor after the last writer finishes. Leaving a pipe’s write end open can prevent a reader from ever receiving EOF. A parent that launches a background process must consider which descriptors that child inherits; the child may keep a log or pipe open after the parent believes the operation ended. Redirect or close descriptors in child commands where inheritance is not intended.

Descriptor variables are numbers, not ownership types. Copying their value to another variable does not create a duplicate descriptor or reference count. Closing the underlying descriptor invalidates every variable that names that number. Keep ownership in one function or controller and avoid helpers that close descriptors they did not allocate.

Descriptors above 9 should still be treated carefully. Bash uses descriptors internally, and the manual warns against assuming that a hard-coded descriptor in that range is available. Variable allocation reduces accidental collisions, but scripts should not overwrite the descriptor variable or reserve the numeric value for another unrelated purpose. When passing a descriptor to a helper, either pass the actual number as an argument or give the helper a clear contract to read a named variable in the current shell; do not make both assumptions at once.

When a command runs with a redirection such as some_command {fd}>>file, the allocated descriptor can have command-specific lifetime behavior. When a script needs a long-lived channel, the exec builtin applies the redirection to the current shell and makes the ownership obvious. When a channel should exist only for one command, use a scoped command redirection and verify the varredir_close policy of the Bash releases you support. Tests should assert closure as well as successful writes, since a lingering descriptor can keep a pipe reader blocked at end-of-file.

Functions and subshell boundaries

An allocated descriptor opened in the current shell can be used by called functions because functions execute in the shell process. A subshell created by ( ... ), command substitution, or pipeline components has a separate descriptor table with inherited copies, subject to shell behavior. Closing a descriptor in the subshell does not close the parent’s descriptor; closing it in the parent does not automatically revoke a copy already inherited by a running child.

If a function opens and returns a descriptor number to its caller, define who closes it and whether the function runs in the current shell. A function invoked in command substitution runs in a subshell, so its changes to shell variables and open descriptor state do not become the caller’s state. Do not attempt to return both a descriptor and its live ownership through stdout without an explicit process design.

Error handling and filename expansion

The target filename undergoes Bash expansions. Quote the variable so spaces and wildcard characters remain part of one filename. Validate that a required value is present before opening. If a path is supplied by an external caller, use the application’s normal path validation and logging policy; the redirection operator itself does not establish that a path is appropriate.

A failed open causes the redirection to fail before the command can use the descriptor. Handle that failure at the opening point, not after a later printf or external command produces a confusing “bad file descriptor.” When multiple descriptors must open successfully, open them in a controlled sequence and close any earlier successful ones if a later open fails. Shell redirections are not an all-or-nothing transaction.

Concurrency and log integrity

Multiple processes can write to the same file descriptor target. The shell’s printf builtin writes data, but separate writes from multiple processes can interleave depending on output size, buffering, filesystem, and the receiving object. If line integrity matters, serialize writers with an application-level lock or a single logging process; do not assume that a file descriptor makes a multi-command record atomic.

Avoid sharing a descriptor across unrelated child processes just to reduce file opens. Give each process a clear output policy and include an identifier or timestamp where useful. A descriptor can point to a pipe, socket, terminal, or regular file; the behavior of backpressure and closure differs for each. A blocked pipe write can stall the process even though the shell syntax succeeded.

Verification checklist

Test successful creation and append, a read-only directory, a nonexistent parent, a path with spaces and wildcard characters, a failure after one of several descriptors opened, child inheritance, background process completion, and pipe EOF. Capture stdout and stderr separately. Inspect open descriptors on the target platform if an unexpected process retains a file or pipe.

Add a low file-descriptor limit test when the program opens several channels; allocation can fail even though the syntax is correct. Confirm the script reports which channel failed and closes any earlier descriptor before returning. Also run a test in which the callee closes its descriptor and the caller continues, to make sure ownership is not duplicated accidentally. Bash behavior should be tested with the minimum and current interpreter versions because shell options and implementation details can affect lifetime.

Dynamic descriptors eliminate brittle numeric guesses, but they do not eliminate redirection ordering, ownership, concurrency, or compatibility questions. Allocate through Bash’s variable syntax, use the assigned number everywhere, document the lifetime, and close each descriptor at the point its owner is finished.

Related:

Sources:

Comments