Shell Functions and I/O Redirection
- Description: Function definition, arguments, return status,
localscope, file descriptors, redirects, heredoc / here-string, pipes, process substitution, and theecho/printf/readbuiltins - 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
- 2. Arguments and
local - 3. Return Status and Output
- 4. File Descriptors
- 5. Output Redirection (
>,>>) - 6. Input Redirection, Heredoc, Here-String
- 7. Combining and Reordering Streams
- 8. Pipes and Process Substitution
- 9.
echo,printf,read - 10. References
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 greetremoves it.declare -f greetprints the source of the function.- Stuck with sh? Drop the
functionkeyword 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.$0is still the script name (not the function name) — surprising. Use$FUNCNAMEto get the function's own name.localscopes a variable to the function. Without it, assignments leak to the caller — almost always a bug. Makelocalthe first statement of every non-trivial function:bad() { x=1; } # leaks $x to caller good() { local x=1; } # containedlocalaccepts the same flags asdeclare:local -i n=0,local -a arr=(),local -A map=(),local -r CONST=42.- Pitfall:
local x=$(cmd)discards the exit status ofcmd—localitself 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 nsets the function's exit status. Range is0..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:
returnfrom outside a function exits the whole script with that status (in some shells) or errors. Useexitto 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 withexec. - A redirect operator without an FD number defaults to
1for output (>,>>) or0for input (<). &Nmeans "duplicate FD N" (point this FD at the same place FD N points to). Used in2>&1etc.
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(orset -C); override per-command with>|.>always means "redirect FD". To get a literal>in output, pass it as an argument:echo '>'.- Pitfall:
cmd > fileand reading the same file in the pipeline fails — bash truncatesfilebeforecmdreads: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 toDELIM(no leading/trailing whitespace).- Quote
DELIM(<<'EOF'or<<"EOF") to prevent$var,`cmd`,$(cmd)expansion inside. <<-DELIMstrips 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 toprintf '%s\n' "$str" | cmdbut 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>&11(stdout) →file2(stderr) → wherever1currently points →file- Result: both go to
file. ✓
-
cmd 2>&1 > file2(stderr) → wherever1currently points → the terminal1(stdout) →file- 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, runscmd, 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 tocmd'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
printfoverecho.echo's flags (-e,-n,-E) are non-portable; someechos interpret backslashes by default, others don't.printf '%s\n' "..."always does the right thing. printfis 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 linesreadflags 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. WithoutIFS=, leading/trailing whitespace gets stripped.