Shell Functions and I/O Redirection


  • Description: Function definition, arguments, return status, local scope, file descriptors, redirects, heredoc / here-string, pipes, process substitution, and the echo / printf / read builtins
  • My Notion Note ID: K2A-E-6
  • Created: 2020-06-03
  • Updated: 2026-05-18
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Defining a Function

Two syntaxes, equivalent in bash:

# POSIX form (portable to sh)
greet() {
  echo "hi, $1"
}

# Bash form — `function` keyword, parens optional
function greet {
  echo "hi, $1"
}

# Call exactly like a command — no parens at call site
greet alice
  • A function name behaves like a command. It can be defined anywhere before the call.
  • unset -f greet removes it.
  • declare -f greet prints the source of the function.
  • Stuck with sh? Drop the function keyword and stay with the POSIX form.

2. Arguments and local

Inside a function, positional parameters shadow the script's:

divmod() {
  local a=$1 b=$2
  local q=$(( a / b ))
  local r=$(( a % b ))
  echo "$q $r"
}

divmod 17 5     # 3 2
echo "$1"       # outside the function — script's $1, unchanged
  • $1, $2, $@, $# inside the function refer to function arguments.
  • $0 is still the script name (not the function name) — surprising. Use $FUNCNAME to get the function's own name.
  • local scopes a variable to the function. Without it, assignments leak to the caller — almost always a bug. Make local the first statement of every non-trivial function:
    bad()  {       x=1; }        # leaks $x to caller
    good() { local x=1; }        # contained
    
  • local accepts the same flags as declare: local -i n=0, local -a arr=(), local -A map=(), local -r CONST=42.
  • Pitfall: local x=$(cmd) discards the exit status of cmdlocal itself succeeds, so $? becomes 0. Capture and check on a separate line if you need the status:
    local x
    x=$(cmd) || return 1
    

3. Return Status and Output

Two distinct channels — easy to confuse.

# Exit status: integer 0..255, returned via `return`
is_root() {
  [[ $EUID -eq 0 ]]
  return $?              # same as plain `return` (uses last command's status)
}

if is_root; then echo "root"; fi

# Output: write to stdout, capture with $(...)
get_user() {
  echo "alice"
}

name=$(get_user)
echo "name=$name"
  • return n sets the function's exit status. Range is 0..255; numbers outside wrap modulo 256 (uses unsigned 8-bit truncation on most systems).
  • Without return, the function returns the status of its last command — exactly like a script.
  • To return a "value", echo to stdout and capture with $(fn). Status and output are independent: a function can echo a string AND return non-zero.
  • Pitfall: return from outside a function exits the whole script with that status (in some shells) or errors. Use exit to terminate scripts.
# Both at once:
fetch_url() {
  local url=$1
  curl -fsS "$url" || { echo "fetch failed: $url" >&2; return 1; }
}

body=$(fetch_url https://example.com) || die "could not fetch"

4. File Descriptors

Every process starts with three:

FD Stream Purpose
0 stdin input
1 stdout normal output
2 stderr error / diagnostic output
  • Higher FDs (3, 4, ...) can be opened explicitly with exec.
  • A redirect operator without an FD number defaults to 1 for output (>, >>) or 0 for input (<).
  • &N means "duplicate FD N" (point this FD at the same place FD N points to). Used in 2>&1 etc.

5. Output Redirection (>, >>)

cmd > file        # stdout overwrites file (truncates first)
cmd >> file       # stdout appends to file
cmd 2> file       # stderr overwrites
cmd 2>> file      # stderr appends
cmd > /dev/null   # discard stdout

# Open / move a file descriptor explicitly
exec 3> debug.log         # open FD 3 for writing
echo "trace" >&3          # write to FD 3
exec 3>&-                 # close FD 3
  • > truncates immediately, even if the command fails. To prevent accidental clobber: set -o noclobber (or set -C); override per-command with >|.
  • > always means "redirect FD". To get a literal > in output, pass it as an argument: echo '>'.
  • Pitfall: cmd > file and reading the same file in the pipeline fails — bash truncates file before cmd reads:
    cat data > data       # WRONG — `data` is truncated, nothing is read
    

6. Input Redirection, Heredoc, Here-String

# Read stdin from a file
sort < unsorted.txt

# Heredoc — multi-line literal as stdin
cat <<EOF
line 1
home is $HOME
EOF

# Heredoc with quoted delimiter — DISABLE expansion
cat <<'EOF'
$HOME stays literal here
no `command` substitution either
EOF

# Heredoc that strips leading TABS (only tabs, not spaces) — `<<-`
cat <<-EOF
	one tab gets stripped
	useful inside indented blocks
EOF

# Here-string — single line as stdin (bash)
grep -i error <<< "$log_line"
  • <<DELIM ... DELIM — heredoc. Body ends on the first line equal to DELIM (no leading/trailing whitespace).
  • Quote DELIM (<<'EOF' or <<"EOF") to prevent $var, `cmd`, $(cmd) expansion inside.
  • <<-DELIM strips leading tab characters (not spaces) from every line and from the closing delimiter — meant for indented heredocs in shell scripts. Use a real tab; many editors silently convert tabs to spaces, which breaks this.
  • <<< (here-string) is bash-only; feeds the string (plus a trailing newline) as stdin. Equivalent to printf '%s\n' "$str" | cmd but no subshell, no pipe.

7. Combining and Reordering Streams

cmd > file 2>&1     # send BOTH stdout and stderr to `file`
cmd &> file         # bash shortcut — same as above
cmd &>> file        # append both

cmd 2>&1 > file     # NOT the same — see below

# Swap stdout and stderr through FD 3
cmd 3>&1 1>&2 2>&3 3>&-

The order trap. Redirects are processed left to right:

  • cmd > file 2>&1

    1. 1 (stdout) → file
    2. 2 (stderr) → wherever 1 currently points → file
    3. Result: both go to file. ✓
  • cmd 2>&1 > file

    1. 2 (stderr) → wherever 1 currently points → the terminal
    2. 1 (stdout) → file
    3. Result: stderr stays on the terminal; only stdout goes to file. ✗

Use &> when you want both — clearer and the right order is automatic.

Other useful patterns:

cmd >/dev/null              # silence stdout, keep stderr
cmd 2>/dev/null             # silence stderr only
cmd >/dev/null 2>&1         # silence both (POSIX form of `&>`)
cmd 2>&1 | grep ERROR       # pipe BOTH streams — must merge BEFORE the pipe
cmd |& grep ERROR           # bash 4+ shortcut — pipe stdout+stderr

8. Pipes and Process Substitution

# Pipe — stdout of LHS becomes stdin of RHS
ps aux | grep nginx | awk '{print $2}'

# tee — fork stdout to a file AND keep going
make 2>&1 | tee build.log

# Process substitution — treat a command's output (or input) as a file path
diff <(sort a.txt) <(sort b.txt)        # compare sorted versions, no temp files
tee >(grep ERROR > errors.log) > full.log
  • Pipelines run each stage as a separate subshell in parallel; the shell waits on the last (or all, with pipefail). Variable assignments in any stage don't leak out (see §4 of the control-flow note for the loop-counter trap).
  • <(cmd) — bash creates a FIFO or /dev/fd/N, runs cmd, and substitutes the path. The receiving command opens it as if it were a file.
  • >(cmd) — same but for output. Anything written to the substituted path is fed to cmd's stdin.
  • Process substitution is bash-only. Not POSIX.

9. echo, printf, read

echo "hello"
echo -n "no newline"
echo -e "tab\there"      # interpret escapes — UNPORTABLE: ignored on some echos

printf '%s\n' "always portable"
printf 'name=%s, age=%d\n' "alice" 30
printf '%.2f\n' 3.14159           # 3.14
printf '%-10s | %5d\n' alice 30   # left-pad / right-pad

# Read user input
read -p "name? " name              # prompt, read into $name
read -r line                       # don't interpret backslashes
read -t 5 answer || answer=default # 5-second timeout
read -s -p "password: " pw         # silent (no echo) — for passwords
read -n 1 key                      # read exactly 1 char
read -a parts <<< "a b c"          # split on $IFS into array $parts
echo "${parts[1]}"                 # b
  • Prefer printf over echo. echo's flags (-e, -n, -E) are non-portable; some echos interpret backslashes by default, others don't. printf '%s\n' "..." always does the right thing.
  • printf is a bash builtin (no fork) and supports the C-style format string. Repeats the format if more args than placeholders:
    printf '%s\n' a b c     # prints a, b, c on separate lines
    
  • read flags worth remembering:
    • -r — raw; always use it, except for niche cases. Without it, backslashes get eaten.
    • -p PROMPT — prompt.
    • -s — silent (passwords).
    • -t N — timeout in seconds.
    • -n N — read exactly N characters (no Enter needed).
    • -a ARR — split input into array.
    • -d DELIM — change line terminator (e.g., -d '' for paragraph mode).
  • IFS= read -r line — the canonical line reader. Without IFS=, leading/trailing whitespace gets stripped.

10. References