Bash Functions, Arrays, getopts and trap: Practical Guide

There’s a point where a Bash script stops being a list of commands and starts being a small program. It takes options. It keeps track of things. It tidies up after itself when someone hits Ctrl+C. Functions, arrays, getopts and trap are what get you there.

This post walks through all of it with short scripts you can copy and run, then combines the pieces into one small log report tool at the end. Along the way we’ll hit the traps most tutorials skip, like local hiding a failed command and a signal handler that waits five seconds to run.

Everything was tested on Ubuntu 26.04.1 LTS (Bash 5.3.9) and RHEL 10.2 (Bash 5.2.26), and the output is real. New to scripting? Start with Bash scripting basics, which covers conditionals and loops too.

How to create and use functions in Bash

A function is a named block of commands. Define it first, then call it like any other command, with no parentheses at the call:

#!/usr/bin/env bash
log_msg() {
  printf '[%s] %s\n' "$1" "$2"
}

log_msg INFO "Backup started"
log_msg WARN "Disk is at 81%"
[INFO] Backup started
[WARN] Disk is at 81%

If you’ve copied the same few lines three times in a script, that’s your cue to write a function.

Function arguments

Functions get arguments the same way scripts do: $1, $2, $# and "$@". Inside a function those refer to the function’s own arguments, not the script’s. $0 is the exception, it stays the script name.

show_args() {
  echo "function: ${FUNCNAME[0]}, script: $0, count: $#"
  for a in "$@"; do echo "  [$a]"; done
}
show_args web01 "db server"
function: show_args, script: ./fnargs.sh, count: 2
  [web01]
  [db server]

Return values

This one trips up almost everybody coming from another language. return doesn’t hand back data. It sets an exit status from 0 to 255, where 0 means success. So use it for yes or no answers, and print anything else, then capture it with $(...).

is_port() {
  [[ $1 =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 ))
}
get_ext() {
  echo "${1##*.}"
}
too_big() { return 300; }

for p in 8080 70000 abc; do
  if is_port "$p"; then echo "$p: valid"; else echo "$p: not valid"; fi
done
ext=$(get_ext backup.tar.gz)
echo "extension: $ext"
too_big
echo "return 300 gives: $?"
8080: valid
70000: not valid
abc: not valid
extension: gz
return 300 gives: 44

See that last line? Exit codes wrap around at 256, so 300 turns into 44. Never use return to pass back a number you care about.

Need a whole array back? Printing gets messy once values contain spaces. A nameref (local -n) lets the function fill a variable the caller names:

load_hosts() {
  local -n result=$1
  result=(web01 "db server" cache01)
}
load_hosts my_hosts
echo "got ${#my_hosts[@]} hosts: ${my_hosts[1]}"
got 3 hosts: db server

Local vs global variables inside Bash functions

Variables in Bash are global by default, even when you set them inside a function. Only local keeps them in.

name=global
count=10

change() {
  local name=inside
  count=99
  temp=leaked
  echo "in function: name=$name count=$count"
}

change
echo "after call:  name=$name count=$count temp=$temp"
in function: name=inside count=99
after call:  name=global count=99 temp=leaked

name survived because it was local. count got overwritten, and temp leaked out into the rest of the script. Make local a habit for every variable in a function.

One catch, though. Putting local and a command substitution on the same line hides failures:

one_line() {
  local out=$(false)
  echo "same line:  exit status $?"
}
two_lines() {
  local out
  out=$(false)
  echo "two lines:  exit status $?"
}
same line:  exit status 0
two lines:  exit status 1

On the first version, $? is the status of local itself, which pretty much always succeeds. Your error check never fires. If you need the status, declare first and assign on the next line.

How to use arrays in Bash scripting

A normal array holds a list, indexed from 0. Always quote "${servers[@]}" when looping, or db server becomes two items.

servers=(web01 web02 "db server")
servers+=(cache01)

echo "count:   ${#servers[@]}"
echo "first:   ${servers[0]}"
echo "last:    ${servers[-1]}"
echo "slice:   ${servers[@]:1:2}"

unset 'servers[1]'
echo "indexes: ${!servers[@]}"
servers=("${servers[@]}")
echo "reindex: ${!servers[@]}"

for s in "${servers[@]}"; do
  echo "server: $s"
done
count:   4
first:   web01
last:    cache01
slice:   web02 db server
indexes: 0 2 3
reindex: 0 1 2
server: web01
server: db server
server: cache01

Notice that unset leaves a hole. Indexes went 0, 2, 3, so a loop counting from 0 would hit an empty slot. Copying the array onto itself closes the gap.

Bash associative arrays with practical examples

Associative arrays use text keys rather than numbers. You must declare them first with declare -A:

declare -A ports=([ssh]=22 [http]=80 [https]=443)
ports[mysql]=3306

echo "http is on ${ports[http]}"
echo "total keys: ${#ports[@]}"

if [[ -v ports[redis] ]]; then echo "redis known"; else echo "redis not in the map"; fi

for svc in "${!ports[@]}"; do
  printf '%-6s %s\n' "$svc" "${ports[$svc]}"
done | sort
http is on 80
total keys: 4
redis not in the map
http   80
https  443
mysql  3306
ssh    22

Keys come back in no useful order, which is why that loop pipes to sort. And skip the declare -A at your own risk. Bash won’t complain, it just treats the keys as index 0, so every value lands on top of the last one:

ports[ssh]=22
ports[http]=80
echo "ssh is ${ports[ssh]}, count ${#ports[@]}"
ssh is 80, count 1

Where they really shine is counting. Here’s how many requests each client made, from a small access log:

declare -A hits
while read -r ip _; do
  hits[$ip]=$(( ${hits[$ip]:-0} + 1 ))
done < access.log

for ip in "${!hits[@]}"; do
  echo "${hits[$ip]} $ip"
done | sort -rn
3 10.0.0.9
3 10.0.0.5
1 garbage
1 10.0.0.7

That garbage entry is a broken line in the log. We’ll deal with it using a regex shortly.

How to sort and search Bash arrays

Bash has no built-in array sort, so hand the job to sort and read the result back with mapfile. Printing one element per line keeps values with spaces intact.

hosts=("web 2" db01 web10 app03 db01)
nums=(10 9 100 2)

mapfile -t sorted < <(printf '%s\n' "${hosts[@]}" | sort)
printf 'sorted: [%s]\n' "${sorted[@]}"

mapfile -t uniq < <(printf '%s\n' "${hosts[@]}" | sort -u)
echo "unique count: ${#uniq[@]}"

mapfile -t by_text < <(printf '%s\n' "${nums[@]}" | sort)
mapfile -t by_num < <(printf '%s\n' "${nums[@]}" | sort -n)
echo "text sort: ${by_text[*]}"
echo "-n sort:   ${by_num[*]}"

Here’s the Ubuntu output:

sorted: [app03]
sorted: [db01]
sorted: [db01]
sorted: [web 2]
sorted: [web10]
unique count: 4
text sort: 10 100 2 9
-n sort:   2 9 10 100

Two things to notice. Numbers need sort -n, or 100 lands before 2. And on RHEL, web10 came before web 2, because Ubuntu’s default locale here was C.UTF-8 and RHEL’s was en_US.UTF-8. If a script has to sort the same way everywhere, use LC_ALL=C sort. That gave the same order on both systems.

Searching is a short function for exact matches, or grep for partial ones:

hosts=("web 2" db01 web10 app03)

in_array() {
  local needle=$1 item
  shift
  for item in "$@"; do
    [[ $item == "$needle" ]] && return 0
  done
  return 1
}

in_array db01 "${hosts[@]}" && echo "db01 found"
in_array web "${hosts[@]}" || echo "web not found (exact match only)"

echo "partial match:"
printf '%s\n' "${hosts[@]}" | grep '^web'
db01 found
web not found (exact match only)
partial match:
web 2
web10

How to use command substitution in Bash

$(command) runs a command and gives you its output as text. You’ll see the older backtick form in old scripts, but $(...) nests cleanly, so stick with it.

users=$(wc -l < /etc/passwd)
echo "accounts: $users"
echo "parent of parent: $(dirname "$(dirname /var/log/nginx/access.log)")"

lines=$(printf 'one\ntwo\n\n\n')
echo "quoted:"
echo "$lines"
echo "unquoted:"
echo $lines

if out=$(grep -c '^nosuchuser:' /etc/passwd); then
  echo "found: $out"
else
  echo "grep said no, status $?, output $out"
fi
accounts: 30
parent of parent: /var/log
quoted:
one
two
unquoted:
one two
grep said no, status 1, output 0

Three useful details here. Trailing newlines get stripped. Without quotes, the remaining newlines collapse into spaces. And an assignment keeps the command’s exit status, so if out=$(...) works.

Bash process substitution explained

Some commands insist on filenames. diff is the classic one. Process substitution, <(command), makes a command’s output look like a file:

echo "what Bash passes: " <(true)
diff <(sort before.txt) <(sort after.txt)
echo "--- count"
count=0
while read -r _; do
  (( ++count ))
done < <(grep -v '^garbage' access.log)
echo "valid lines: $count"
what Bash passes:  /dev/fd/63
3c3
< web02
---
> web03
--- count
valid lines: 7

So diff just sees two file paths, and you’ve compared two unsorted lists without any temp files. The second half matters too. Feeding a loop this way keeps it in the main shell, so count still has its value afterwards. Pipe into the loop instead and you get the subshell counting bug. Syntax-wise, watch the space in < <(. It’s a redirect, then the substitution. For the basics of redirects, see pipes and redirection in Linux.

Bash here documents and here strings

A here document feeds several lines to a command’s input. It’s perfect for config files and help text. Quote the delimiter and nothing inside gets expanded:

host=web01
cat <<EOT
Host:  $host
Today: $(date +%Y)
EOT
echo "--- quoted"
cat <<'EOT'
Host:  $host
Today: $(date +%Y)
EOT
echo "--- tabs"
if true; then
	cat <<-EOT
	this was indented with tabs
	EOT
fi
echo "--- to a file"
cat > app.conf <<EOT
server=$host
port=8080
EOT
cat app.conf
Host:  web01
Today: 2026
--- quoted
Host:  $host
Today: $(date +%Y)
--- tabs
this was indented with tabs
--- to a file
server=web01
port=8080

<<- strips leading tabs, which lets you indent inside an if. Tabs only. Spaces won’t work, and that catches people whose editor converts tabs automatically.

A here string, <<<, is the one-line version. It’s great for splitting a value with read:

line="web01 10.0.0.5 up"
read -r name ip state <<< "$line"
echo "name=$name ip=$ip state=$state"

grep -q up <<< "$line" && echo "host is up"
wc -w <<< "$line"
name=web01 ip=10.0.0.5 state=up
host is up
3

Understanding Bash parameter expansion

Parameter expansion changes a variable’s value without calling any outside program. That makes it quick inside loops, and you’ll stop needing basename, dirname and half your sed calls.

path=/var/log/nginx/access.log.gz

echo "file:     ${path##*/}"
echo "dir:      ${path%/*}"
echo "no .gz:   ${path%.gz}"
echo "no ext:   ${path%%.*}"
echo "length:   ${#path}"

unset port
echo "port:     ${port:-8080}"
echo "still:    [${port}]"
echo "assign:   ${port:=8080}"
echo "now:      [${port}]"

debug=1
echo "flag:     ${debug:+--verbose}"
unset debug
echo "no flag:  [${debug:+--verbose}]"

( echo "${db_host:?db_host must be set}" )
echo "subshell exit: $?"
file:     access.log.gz
dir:      /var/log/nginx
no .gz:   /var/log/nginx/access.log
no ext:   /var/log/nginx/access
length:   28
port:     8080
still:    []
assign:   8080
now:      [8080]
flag:     --verbose
no flag:  []
./expand.sh: line 21: db_host: db_host must be set
subshell exit: 1

An easy way to remember the first group: # cuts from the front and % cuts from the back. One symbol removes the shortest match, two removes the longest. Then for defaults, :- uses a fallback without saving it, := saves it too, :+ gives a value only if the variable is set, and :? stops with an error if it isn’t.

That last one is a neat way to catch missing settings early. I wrapped it in a subshell here only so the demo could keep going. There’s more on quoting and validation in Bash variables explained.

Bash string manipulation techniques

The same syntax handles most day-to-day string work:

s="Hello World"
echo "first 5:  ${s:0:5}"
echo "last 5:   ${s: -5}"
echo "replace:  ${s/World/Bash}"
echo "all o:    ${s//o/0}"
echo "upper:    ${s^^}"
echo "lower:    ${s,,}"

[[ $s == *World* ]] && echo "contains World"

raw="   padded text   "
trimmed=${raw#"${raw%%[![:space:]]*}"}
trimmed=${trimmed%"${trimmed##*[![:space:]]}"}
echo "trimmed:  [$trimmed]"

IFS=, read -ra parts <<< "red,green,blue"
echo "split:    ${#parts[@]} parts, second is ${parts[1]}"
joined=$(IFS=:; echo "${parts[*]}")
echo "joined:   $joined"

for f in report.txt notes.txt; do
  echo "rename $f -> ${f%.txt}.md"
done
first 5:  Hello
last 5:   World
replace:  Hello Bash
all o:    Hell0 W0rld
upper:    HELLO WORLD
lower:    hello world
contains World
trimmed:  [padded text]
split:    3 parts, second is green
joined:   red:green:blue
rename report.txt -> report.md
rename notes.txt -> notes.md

Mind the space in ${s: -5}. Without it, Bash reads :- as the default value syntax from the last section. The trim lines look scary, and honestly they are, so wrap them in a function once and forget about them.

Bash regular expressions with practical examples

Inside [[ ]], the =~ operator tests a regex, and any parenthesised groups land in the BASH_REMATCH array. Keep the pattern in a variable. It’s easier to read and avoids quoting headaches.

ver_re='^([0-9]+)\.([0-9]+)\.([0-9]+)$'
for v in 5.3.9 5.3 v1.2.3; do
  if [[ $v =~ $ver_re ]]; then
    echo "$v: major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}"
  else
    echo "$v: not a version"
  fi
done

line='10.0.0.9 - - [04/Nov/2026:09:01:15] "GET /admin HTTP/1.1" 403 210'
log_re='^([0-9.]+) .*"([A-Z]+) ([^ ]+) [^"]*" ([0-9]{3}) '
if [[ $line =~ $log_re ]]; then
  echo "ip=${BASH_REMATCH[1]} method=${BASH_REMATCH[2]} url=${BASH_REMATCH[3]} status=${BASH_REMATCH[4]}"
fi

[[ abc =~ a.c ]] && echo "unquoted a.c: match" || echo "unquoted a.c: no match"
[[ abc =~ "a.c" ]] && echo "quoted a.c: match" || echo "quoted a.c: no match"
[[ 5 =~ ^\d$ ]] && echo '\d: match' || echo '\d: no match'
[[ 5 =~ ^[0-9]$ ]] && echo '[0-9]: match' || echo '[0-9]: no match'
5.3.9: major=5 minor=3 patch=9
5.3: not a version
v1.2.3: not a version
ip=10.0.0.9 method=GET url=/admin status=403
unquoted a.c: match
quoted a.c: no match
\d: no match
[0-9]: match

Those last four lines cover the two classic mistakes. Quoting the pattern makes it plain text, so the dot stops being a wildcard. And Bash uses extended regex, so shortcuts like \d don’t match digits here. Use [0-9]. For searching through files rather than single strings, grep and friends are the better tool.

How to use getopts for script options

getopts is built into Bash and parses short options like -n web01 -v. In the option string, a colon after a letter means it takes a value. The colon at the very start turns off the default error messages so you can write your own.

#!/usr/bin/env bash
name=localhost port=22 verbose=0

usage() { echo "Usage: ${0##*/} [-n name] [-p port] [-v] [file...]"; }

while getopts ":n:p:vh" opt; do
  case $opt in
    n) name=$OPTARG ;;
    p) port=$OPTARG ;;
    v) verbose=1 ;;
    h) usage; exit 0 ;;
    :) echo "-$OPTARG needs a value" >&2; usage >&2; exit 2 ;;
    \?) echo "Unknown option: -$OPTARG" >&2; usage >&2; exit 2 ;;
  esac
done
shift $(( OPTIND - 1 ))

echo "name=$name port=$port verbose=$verbose files=$#: $*"
./opts.sh -n web01 -p 8443 -v a.log b.log
name=web01 port=8443 verbose=1 files=2: a.log b.log
./opts.sh -vn web01 a.log
name=web01 port=22 verbose=1 files=1: a.log
./opts.sh a.log -v
name=localhost port=22 verbose=0 files=2: a.log -v
./opts.sh -x
Unknown option: -x
Usage: opts.sh [-n name] [-p port] [-v] [file...]
./opts.sh -p
-p needs a value
Usage: opts.sh [-n name] [-p port] [-v] [file...]

Look at the third run. getopts stops at the first thing that isn’t an option, so -v after a filename gets treated as a filename. Options go first. The shift line then drops the parsed options, leaving $@ holding just the files.

The main limit: no long options like --name. If you need those, use the separate getopt command from util-linux or parse them by hand with case.

How to build interactive Bash scripts

Most interactive scripts come down to a few read flags and select for menus:

#!/usr/bin/env bash
read -rp "Server name [web01]: " server
server=${server:-web01}

read -rsp "API token: " token
echo
echo "token length: ${#token}"

if read -rt 2 -p "Port (2s timeout) [22]: " port; then
  echo "using port ${port:-22}"
else
  status=$?
  echo
  echo "timed out, status $status, using 22"
fi

confirm() {
  local answer
  read -rn1 -p "$1 [y/N] " answer
  echo
  [[ $answer == [yY] ]]
}
if confirm "Deploy to $server?"; then echo "deploying"; else echo "cancelled"; fi

PS3="Pick an environment: "
select env in dev staging prod quit; do
  case $env in
    dev|staging|prod) echo "selected $env"; break ;;
    quit) echo "bye"; break ;;
    *) echo "invalid choice: $REPLY" ;;
  esac
done

Here’s a session where I pressed Enter at the first prompt, typed a token, let the port prompt time out, pressed y, then tried a bad menu choice before picking 3:

Server name [web01]:
API token:
token length: 9
Port (2s timeout) [22]:
timed out, status 142, using 22
Deploy to web01? [y/N] y
deploying
1) dev
2) staging
3) prod
4) quit
Pick an environment: 9
invalid choice: 9
Pick an environment: 3
selected prod

The flags: -p shows a prompt, -s hides typing, -t sets a timeout, -n1 returns after one key, and -r keeps backslashes literal. Use -r always. A timeout returns a status above 128, 142 in this case, which is how you can tell it apart from normal input.

One thing to plan for: scripts don’t always have a person attached. Under cron there’s nobody to answer, so check with [[ -t 0 ]], which is true only when input comes from a terminal, and fall back to sensible defaults or options.

Bash signal handling using trap

trap runs code when your script receives a signal or exits. The most useful form by far is cleanup on EXIT, which fires however the script ends, apart from the case we’ll get to below.

#!/usr/bin/env bash
tmp=$(mktemp)
trap 'rm -f "$tmp"; echo "cleaned up $tmp"' EXIT
trap 'echo "caught Ctrl+C"; exit 130' INT
trap 'echo "caught TERM"; exit 143' TERM

echo "working with $tmp"
sleep "${1:-0}" &
wait $!
echo "finished normally"

Run it normally, then again with kill -TERM from another terminal:

./cleanup.sh
working with /tmp/tmp.lb9fCApXXh
finished normally
cleaned up /tmp/tmp.lb9fCApXXh

./cleanup.sh 30
working with /tmp/tmp.FANDmLjYk8
caught TERM
cleaned up /tmp/tmp.FANDmLjYk8

Exit codes were 0 and 143. By convention that’s 128 plus the signal number, and TERM is 15. Sending INT gave the same cleanup.

The exception is SIGKILL. kill -9 can’t be trapped, so the script just dies with status 137 and the temp file stays behind. That’s why kill -9 should be your last resort, not your first.

Why your trap seems slow

Why sleep & and wait above, instead of plain sleep? Because Bash won’t run a trap until the current foreground command finishes. I sent TERM one second into each of these two scripts:

# fgsleep.sh
trap 'echo "TERM handled after ${SECONDS}s"; exit 143' TERM
sleep 5
echo "not reached"

# bgsleep.sh
trap 'echo "TERM handled after ${SECONDS}s"; exit 143' TERM
sleep 5 &
wait $!
echo "not reached"
fgsleep.sh: TERM handled after 5s
bgsleep.sh: TERM handled after 1s

The foreground version sat there for the full five seconds. With a long-running command, that can look like your script is ignoring the signal. wait gets interrupted straight away, so the trap runs on time.

The background sleep does keep running after the script exits, though. If the child needs to stop too, kill it in the trap:

trap 'kill "$child" 2>/dev/null; echo "TERM handled, child stopped"; exit 143' TERM
sleep 47 &
child=$!
wait "$child"

Finding the line that failed with an ERR trap

An ERR trap fires when a command fails, which helps a lot when debugging a long script:

trap 'echo "failed: line $LINENO, command: $BASH_COMMAND, status $?" >&2' ERR
ls /nonexistent/dir 2>/dev/null
echo "script kept going"
failed: line 3, command: ls /nonexistent/dir 2> /dev/null, status 2
script kept going

Putting it together: a small log report script

Here’s all of that working in one script. It takes options with getopts, uses functions and a nameref, counts with associative arrays, filters out junk with a regex, and writes its report through a temp file that trap removes.

#!/usr/bin/env bash
# logsum.sh: summarise an access log by status code and client IP
top=3

usage() {
  cat <<EOT
Usage: ${0##*/} [-n top] logfile
  -n  how many IPs to show (default $top)
  -h  show this help
EOT
}
die() { echo "error: $1" >&2; exit 1; }
bump() { local -n map=$1; map[$2]=$(( ${map[$2]:-0} + 1 )); }

while getopts ":n:h" opt; do
  case $opt in
    n) top=$OPTARG ;;
    h) usage; exit 0 ;;
    :) die "-$OPTARG needs a value" ;;
    \?) die "unknown option -$OPTARG" ;;
  esac
done
shift $(( OPTIND - 1 ))

[[ $top =~ ^[1-9][0-9]*$ ]] || die "-n must be a positive number"
log=${1:-}
[[ -r $log ]] || die "cannot read log file: ${log:-none given}"

tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

declare -A by_status by_ip
re='^([0-9.]+) .*" ([0-9]{3}) '
total=0 bad=0

while IFS= read -r line; do
  if [[ $line =~ $re ]]; then
    bump by_ip "${BASH_REMATCH[1]}"
    bump by_status "${BASH_REMATCH[2]}"
    (( ++total ))
  else
    (( ++bad ))
  fi
done < "$log"

{
  cat <<EOT
Report for ${log##*/}
Parsed: $total, skipped: $bad

Status codes:
EOT
  for code in "${!by_status[@]}"; do
    printf '  %s  %d\n' "$code" "${by_status[$code]}"
  done | sort
  echo "Top $top IPs:"
  for ip in "${!by_ip[@]}"; do
    printf '%d %s\n' "${by_ip[$ip]}" "$ip"
  done | sort -rn | head -n "$top" | sed 's/^/  /'
} > "$tmp"

cat "$tmp"
./logsum.sh access.log
Report for access.log
Parsed: 7, skipped: 1

Status codes:
  200  3
  403  1
  404  2
  500  1
Top 3 IPs:
  3 10.0.0.9
  3 10.0.0.5
  1 10.0.0.7

./logsum.sh -n abc access.log
error: -n must be a positive number

./logsum.sh /no/such/file
error: cannot read log file: /no/such/file

The garbage line from earlier now shows up as skipped: 1 rather than pretending to be a client. The output matched on Ubuntu and RHEL. Want to take it further? This report script builds a similar tool using sort, uniq and tee.

Wrapping up

You don’t need all of this in every script. Most useful scripts get by with a few functions, one array, a getopts loop and a cleanup trap. Start there, and reach for the rest when a script actually needs it.

Run ShellCheck over whatever you write as well. It catches things like the local out=$(...) problem automatically.

Frequently asked questions

How do I return a value from a Bash function?

Print the value with echo or printf and capture it with result=$(my_func). Use return only for success or failure, since it sets an exit status from 0 to 255. To return an array, pass a variable name and use local -n.

Are Bash variables global by default?

Yes. A variable set inside a function is visible to the whole script unless you declare it with local. Local variables are also visible to any functions called from that function.

What is the difference between an indexed and an associative array in Bash?

Indexed arrays use numbers as keys and start at 0. Associative arrays use text keys, must be declared with declare -A, need Bash 4 or newer, and don’t keep any particular order.

What is the difference between command substitution and process substitution?

$(command) replaces itself with the command’s output as text. <(command) replaces itself with a file path, such as /dev/fd/63, that a program can read the output from.

Why is my Bash regex not matching?

The usual cause is quotes around the pattern, which turn it into plain text. Store the regex in a variable and use [[ $text =~ $re ]] without quoting $re. Also replace shortcuts like \d with [0-9].

Does getopts support long options like –help?

No, getopts handles single-letter options only. For long options, use the external getopt command from util-linux, or parse them yourself with a while and case loop.

Why is my Bash trap not running right away?

Bash waits for the current foreground command to finish before it runs a trap. Start the long command in the background with & and then call wait $!, which is interrupted as soon as the signal arrives.

Can trap catch kill -9?

No. SIGKILL can’t be caught, blocked or ignored, so the EXIT trap never runs and cleanup doesn’t happen. SIGSTOP can’t be trapped either.

Asif Khan

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.