skip to content

A bash script must write progress lines to /var/log/deploy.log while its ordinary stdout still goes wherever the caller pointed it. How do you open a dedicated file descriptor once with exec, write to it, and close it when done?

level: middleimportance: should knowfreq 40%

answer

  1. exec with no command word
  2. a third channel of its own
  3. open once, keep the offset
  4. the closing form uses a dash
  5. bash 4.1 can pick the number

basics

~20 s

Run exec 3>/var/log/deploy.log once near the top of the script. That opens the file on descriptor 3 for the whole shell, so later commands write to it with >&3 while stdout and stderr are untouched. Close it with exec 3>&- when finished.

solid answer

~50 s

`exec` with redirections but **no command** does not replace the shell — it applies those redirections to the shell process itself, so they persist for the rest of the script. `exec 3>/var/log/deploy.log` opens the file once (truncating it) and parks it at descriptor 3; from then on `echo "step 1 done" >&3` or `printf ... >&3` writes there, while descriptors 1 and 2 stay exactly as the caller set them. Use `exec 3>>` if you want to append instead. Opening once matters: the file is opened a single time with one shared write offset, so nothing overwrites anything, and each write skips a fresh `open()`. When the channel is no longer needed, `exec 3>&-` closes it — worth doing before you launch a long-lived child, since children inherit descriptor 3 otherwise. From bash 4.1 you can let the shell pick the number with `exec {log_fd}>/var/log/deploy.log` and then write to `>&"$log_fd"`.

code

bash · 11 lines
bash
#!/usr/bin/env bash
set -euo pipefail

exec 3>>/tmp/deploy.log            # private append-mode channel on fd 3
log() { printf '%s %s\n' "$(date +%FT%T)" "$*" >&3; }

log "deploy starting"
echo "artifact-42.tar.gz"           # stdout: still the caller's
log "deploy finished"

exec 3>&-                          # close before anything forks

go deeper

for a junior

Recognise that descriptors above 2 exist and that a script can open one. Know that exec 3>file followed by echo hi >&3 writes to that file without affecting normal output.

for a middle

Explain that exec with no command word rewires the shell itself, so the descriptor outlives the line that opened it, and contrast 3> truncating with 3>> appending. Know that 3>&- closes.

for a senior

Show the operational reasoning: one open file with one offset, a private channel that ignores whatever the caller does to stdout, and closing before forking so a background child does not hold the file open forever.

for a principal

Decide when a script should own a log channel at all versus emitting to stdout/stderr and letting the supervisor route it. Weigh hardcoded descriptor numbers against {var}> allocation given the bash versions your fleet actually runs.

## exec without a command `exec` has two jobs that look like one. With a command word — `exec myprog` — it replaces the shell process. With **only redirections** and no command word, it does something quite different: it applies those redirections to the running shell, permanently, and execution continues on the next line. That second form is the tool here. ```bash exec 3>/var/log/deploy.log # shell now holds the file open on fd 3 ``` Descriptor 3 is now part of the shell's descriptor table. Every command the script runs afterwards inherits it, which is exactly why `>&3` works from anywhere in the script, including inside functions and loops. ## Writing to the channel ```bash exec 3>/var/log/deploy.log log() { printf '%s %s\n' "$(date +%FT%T)" "$*" >&3; } log "deploy starting" build_artifacts # its own stdout still goes to the caller log "artifacts built" ``` `>&3` duplicates descriptor 3 onto descriptor 1 for that one command — the same duplication operator as `>&2`, just with a descriptor you opened yourself. Descriptors 1 and 2 are never touched, so the script's real output and its errors keep flowing wherever the caller sent them. That is the whole point of a third channel: three independent destinations, chosen once. ## Why open once instead of `>> file` on every line You could write `printf ... >> /var/log/deploy.log` on every log line. Opening once with `exec` is better for three reasons: 1. **One open file, one offset.** With `exec 3>file` the file is opened a single time; the shared write offset advances with each write, so successive writes append naturally without needing `>>` semantics per call. 2. **The path is resolved once.** If the script later `cd`s, or the log directory is rotated and replaced underneath it, the descriptor still refers to the file the script opened — which is a feature when you want all lines in one place, and a thing to know about when log rotation expects reopening. 3. **Less syscall churn.** Each `>> file` is an `open()` plus a `close()` around the write. Use `exec 3>>` when the file must be *appended* to across separate runs rather than truncated at startup — `exec 3>` truncates, exactly like `>` on a command. ## Closing, and why it matters ```bash exec 3>&- # close descriptor 3 ``` `n>&-` closes descriptor `n`. Two reasons to bother in a real script: - **Inheritance.** Any child process the script starts inherits every open descriptor. A background daemon that inherits fd 3 keeps the log file open long after the script exits, so the disk space is not reclaimed if the file is deleted, and a lock file held on that descriptor is never released. - **Scoped exposure.** You can close a descriptor for one command only: `some_child 3>&-` runs that child without fd 3 while the script keeps it. Bash closes everything at exit anyway, so closing is about children and long-lived state, not tidiness for its own sake. ## Read-write and other openings The same numbered-descriptor mechanism covers more than writing: ```bash exec 4</etc/hosts # open for reading on fd 4 read -r first_line <&4 exec 4>&- exec 5<>/tmp/state.txt # read-write, does NOT truncate ``` `n<> file` opens for both reading and writing without truncating — useful when a file must be updated in place, and the standard way to open a lock file that you neither want to empty nor need to write to. ## Letting bash allocate the number Hardcoding 3 through 9 is fine in a script you own end to end, but in a sourced library it can collide with a descriptor the caller already uses. Since bash 4.1: ```bash exec {log_fd}>/var/log/deploy.log printf 'started\n' >&"$log_fd" exec {log_fd}>&- ``` Bash picks a free descriptor (10 or above) and stores the number in `log_fd`. macOS ships bash 3.2, where this syntax is a parse error — one of the standard reasons a script that works on Linux fails on a Mac. ## The interview answer in one breath "`exec` with redirections and no command rewires the shell itself, so `exec 3>file` gives the script a private, long-lived output channel; commands write to it with `>&3`, it never disturbs stdout or stderr, and `exec 3>&-` closes it before I fork anything that should not inherit it."

  • What is the difference between `exec 3>file` and `exec 3>>file`?
    Both open the file on descriptor 3 for the life of the shell; the difference is the same as for `>` and `>>` on a command. `exec 3>file` truncates the file at open time, so each run of the script starts from empty. `exec 3>>file` opens in append mode, preserving previous runs — the right choice for a log that accumulates.
  • Why close descriptor 3 with `exec 3>&-` when bash closes it at exit anyway?
    Because of inheritance. Every child the script starts gets a copy of fd 3, so a background daemon or a `nohup`-ed process keeps the file open long after the script ends — space is not freed if the file is deleted, and anything held on that descriptor is not released. Closing before forking, or per command with `child 3>&-`, keeps that contained.
  • Why would you use `exec 5<>/tmp/state` instead of `exec 5>/tmp/state`?
    `5<>` opens for reading *and* writing and does not truncate. `5>` empties the file the moment it is opened, which destroys the state you meant to read. The read-write form is also the usual way to open a file you only need a descriptor on, such as a lock file that must keep its contents.
  • How do you avoid picking a descriptor number that collides with the caller's?
    Use bash 4.1's automatic allocation: `exec {fd}>file` makes bash choose a free descriptor (10 or higher) and store it in `$fd`, then write with `>&"$fd"` and close with `exec {fd}>&-`. It matters most in a sourced library. It is unavailable on macOS's bash 3.2, so a portable script must hardcode a number instead.

saying these in an interview costs you the question

  • Thinking exec 3>file replaces the running shell
  • Believing >&3 also changes stdout
  • Assuming the descriptor vanishes when a function returns
  • Reopening the file on every log line for no reason
  • Expecting exec 5>file to preserve the file's contents

context