Bash · lesson 22 of 22
Bash Interview Preparation
Top 25 Bash and Shell scripting interview questions with real examples, covering everything from basics to advanced DevOps scenarios.
By SkillByExample · Updated
Every answer below comes with a small script and its real output, captured with bash 5.2
on Ubuntu 24.04. In an interview, the strongest answers work the same way: say what
happens, then show it. Each question links back to the lesson that covers it in depth.
Q1: What does set -euo pipefail do?
-e exits on the first failing command, -u makes unset variables an error, and
pipefail makes a pipeline fail if any stage fails.
cat missing.txt | sort
echo "default: pipeline status $?"
set -o pipefail
cat missing.txt | sort
echo "pipefail: pipeline status $?"
set -eu
echo "unset var: $NOPE"
echo "never printed"
$ bash q1.sh
cat: missing.txt: No such file or directory
default: pipeline status 0
cat: missing.txt: No such file or directory
pipefail: pipeline status 1
q1.sh: line 7: NOPE: unbound variable
Without pipefail, sort succeeded on empty input and hid the failure. Follow-up points
that impress interviewers: set -e is ignored inside if conditions and || chains, and
under pipefail the status is the rightmost failure’s (see error handling).
Q2: "$@" vs "$*"?
demo() {
printf ' @ [%s]\n' "$@"
printf ' * [%s]\n' "$*"
}
demo "hello world" foo "bar baz"
$ bash q2.sh
@ [hello world]
@ [foo]
@ [bar baz]
* [hello world foo bar baz]
"$@" keeps every argument separate, including ones with spaces. "$*" joins them into one
string. Use "$@" whenever you pass arguments on to another command.
Q3: How do you tell unset, empty and set apart?
check() {
if [[ -v MY_VAR ]]; then state="declared"; else state="undeclared"; fi
[[ -n ${MY_VAR:-} ]] && state+=", non-empty" || state+=", empty"
echo "$1 -> $state"
}
check "never set"
MY_VAR=""; check 'MY_VAR=""'
MY_VAR="x"; check 'MY_VAR="x"'
$ bash q3.sh
never set -> undeclared, empty
MY_VAR="" -> declared, empty
MY_VAR="x" -> declared, non-empty
[[ -v VAR ]] tests whether the variable exists, even if empty. ${VAR:-} safely reads a
possibly unset variable under set -u. : "${VAR:?message}" stops the script if it’s
missing (see variables).
Q4: [ ] vs [[ ]]?
file="my file.txt"; touch "$file"
[ -f $file ] 2>&1 && echo "[ ]: found" || echo "[ ]: not found"
[[ -f $file ]] && echo "[[ ]]: found"
name="deploy.sh"
[[ $name == *.sh ]] && echo "glob match: shell script"
$ bash q4.sh
q4.sh: line 2: [: my: binary operator expected
[ ]: not found
[[ ]]: found
glob match: shell script
[ is an ordinary command, so the unquoted file name split into two arguments. [[ ]] is
syntax: it doesn’t word-split, and it supports globs, =~ regexes and &&/|| inside.
Use [ ] only in POSIX sh scripts, and quote everything there.
Q5: What happens when you pipe into a while loop?
printf 'ERROR a\nINFO b\nERROR c\n' > log.txt
count=0
grep ERROR log.txt | while IFS= read -r line; do (( ++count )); done
echo "pipe into while: count=$count"
count=0
while IFS= read -r line; do (( ++count )); done < <(grep ERROR log.txt)
echo "process substitution: count=$count"
$ bash q5.sh
pipe into while: count=0
process substitution: count=2
Each part of a pipeline runs in a subshell, so the counter was incremented in a copy and
then thrown away. < <(cmd) keeps the loop in the current shell. shopt -s lastpipe is
the other fix (see control flow).
Q6 and Q15: Redirecting stdout and stderr, and why order matters
{ echo "to stdout"; echo "to stderr" >&2; } > both.log 2>&1
{ echo "to stdout"; echo "to stderr" >&2; } 2>&1 > only-out.log
echo "both.log: $(paste -sd'|' both.log)"
echo "only-out.log: $(paste -sd'|' only-out.log)"
$ bash q6.sh
to stderr
both.log: to stdout|to stderr
only-out.log: to stdout
Redirections are applied left to right, and 2>&1 copies wherever fd 1 points at that
moment. In the second line, fd 1 still pointed at the terminal, so stderr leaked out,
which is the stray to stderr on the first output line. &> file is Bash shorthand for
> file 2>&1.
Q7: How does a function return a value?
get_os() { uname -s | tr '[:upper:]' '[:lower:]'; }
get_files() { local -n _out="$1"; mapfile -t _out < <(printf '%s\n' a.sh "b c.sh"); }
is_running() { pgrep -x "$1" > /dev/null; }
os=$(get_os); echo "1) captured: $os"
declare -a files; get_files files; echo "2) nameref: ${#files[@]} files, second is [${files[1]}]"
is_running bash && echo "3) status: bash is running"
is_running nginx || echo "3) status: nginx is not running"
$ bash q7.sh
1) captured: linux
2) nameref: 2 files, second is [b c.sh]
3) status: bash is running
3) status: nginx is not running
There are three ways: print the value and capture it with $(...), write into the
caller’s variable through a nameref (the only clean way to return an array), or use the
exit status as a boolean. return can only carry 0-255 (see functions).
Q8: Why IFS= read -r?
printf ' indented\\tline\n' > f.txt
while read line; do echo "read: [$line]"; done < f.txt
while read -r line; do echo "read -r: [$line]"; done < f.txt
while IFS= read -r line; do echo "IFS= read -r: [$line]"; done < f.txt
$ bash q8.sh
read: [indentedtline]
read -r: [indented\tline]
IFS= read -r: [ indented\tline]
Plain read stripped the indentation and ate the backslash. -r keeps backslashes, and
IFS= keeps leading and trailing whitespace. Only the last form reads the line exactly as
written.
Q9: How do you clean up on exit and on signals?
tmp=$(mktemp)
cleanup() { rm -f "$tmp"; echo "cleanup: removed temp file (exit $1)" >&2; }
trap 'cleanup $?' EXIT
trap 'echo "got TERM"; exit 143' TERM
echo "working..."
kill -TERM $$
echo "never printed"
$ bash q9.sh
working...
got TERM
cleanup: removed temp file (exit 143)
The TERM handler called exit 143, and exiting ran the EXIT trap, so cleanup happens
however the script ends. Senior follow-up: a trap only runs between commands, so while
a foreground sleep 60 is running, the handler waits for it to finish. Use sleep 60 & wait $! (see process management).
Q10: Subshell vs child process?
x=10
export y=20
( echo "subshell: x=$x y=$y" )
bash -c 'echo "child: x=$x y=$y"'
$ bash q10.sh
subshell: x=10 y=20
child: x= y=20
A subshell (( ), $( ), pipeline stages) is a copy of the shell, so it sees every
variable. A new program only receives exported variables. Neither can change the
parent’s variables.
Q11: How do you handle file names with spaces?
mkdir -p docs && touch "docs/my notes.txt" docs/todo.txt
files=()
while IFS= read -r -d '' f; do files+=("$f"); done < <(find docs -name '*.txt' -print0)
echo "${#files[@]} files:"; printf ' [%s]\n' "${files[@]}" | sort
$ bash q11.sh
2 files:
[docs/my notes.txt]
[docs/todo.txt]
Quote every expansion, store lists in arrays, and pass names between programs separated by
NUL bytes (-print0, read -d '', xargs -0). find -exec cmd {} + avoids the loop
altogether.
Q12: How do you do arithmetic?
a=10 b=3
echo "int: $(( a / b )) rem $(( a % b )) pow $(( a ** b ))"
echo "bc: $(echo 'scale=2; 22/7' | bc)"
awk 'BEGIN { printf "awk: %.4f\n", sqrt(2) }'
$ bash q12.sh
int: 3 rem 1 pow 1000
bc: 3.14
awk: 1.4142
$(( )) is 64-bit integer arithmetic only. It truncates, and it overflows silently. Use
bc or awk for decimals (see operators).
Q13: How do you replace text in a file in place?
printf 'host=old_value\nport=5432\n' > config.txt
sed -i.bak 's/old_value/new_value/g' config.txt
cat config.txt; ls config.txt*
$ bash q13.sh
host=new_value
port=5432
config.txt
config.txt.bak
-i.bak works on both GNU and BSD sed. A bare -i differs between them: BSD needs
-i ''. For the fully portable form, sed ... > tmp && mv tmp file.
Q14: How do you parse CSV, and where does it break?
printf 'name,age,city\nalice,30,Paris\nbob,25,"New York, NY"\n' > data.csv
tail -n +2 data.csv | while IFS=, read -r name age city; do
echo "name=$name age=$age city=[$city]"
done
$ bash q14.sh
name=alice age=30 city=[Paris]
name=bob age=25 city=["New York, NY"]
IFS=, read works for simple CSV. Here city was the last variable, so it caught the rest
of the line, quotes included. With any column after it, the comma inside the quotes would
split that field. For real CSV, use gawk’s FPAT (Practice task 2), csvkit, or Python.
Q16: How do you retry with backoff?
calls=0
flaky() { (( ++calls < 3 )) && return 1; echo "succeeded on call $calls"; }
retry() {
local max="$1" delay="$2" i=1; shift 2
until "$@"; do
(( i >= max )) && { echo "failed after $max attempts" >&2; return 1; }
echo "attempt $i failed; sleeping ${delay}s"
sleep "$delay"
(( ++i, delay = delay * 2 > 4 ? 4 : delay * 2 ))
done
}
retry 5 1 flaky
$ bash q16.sh
attempt 1 failed; sleeping 1s
attempt 2 failed; sleeping 2s
succeeded on call 3
"$@" runs any command with its arguments exactly as given. The ternary doubles the delay
up to a cap. flaky stands in for curl -sf ....
Q17: How do you check that a command exists?
for c in jq curl docker; do
if command -v "$c" >/dev/null; then echo "$c: $(command -v "$c")"; else echo "$c: MISSING"; fi
done
$ bash q17.sh
jq: /usr/bin/jq
curl: /usr/bin/curl
docker: MISSING
command -v is POSIX, and unlike which it also finds shell builtins and functions.
Check all dependencies at startup, so a script doesn’t fail halfway through.
Q18: How does flock work?
exec 9> job.lock
flock -n 9 && echo "first holder: locked"
( exec 8> job.lock; flock -n 8 && echo "second: locked" || echo "second: already running, exit" )
$ bash q18.sh
first holder: locked
second: already running, exit
The lock belongs to an open file descriptor, and the kernel releases it when that
descriptor closes, even if the script crashes. A hand-made “check, then touch a file” lock
has a race window between the two steps. In a crontab:
flock -n /tmp/job.lock /path/job.sh.
Q19: How do you process JSON?
cat > users.json <<'EOF'
[{"name":"alice","role":"admin"},{"name":"bob","role":"dev"},{"name":"carol","role":"admin"}]
EOF
echo "first: $(jq -r '.[0].name' users.json)"
echo "admins: $(jq -r '.[] | select(.role == "admin") | .name' users.json | paste -sd, )"
jq -nc --arg user "O'Brien \"Jr\"" --arg env prod '{user: $user, env: $env}'
$ bash q19.sh
first: alice
admins: alice,carol
{"user":"O'Brien \"Jr\"","env":"prod"}
jq -r prints raw strings without quotes. --arg builds JSON with correct escaping, so
you never paste variables into a JSON string by hand.
Q20: source vs executing a script?
echo 'export DB_HOST="prod-db"; cd /tmp' > setenv.sh
bash setenv.sh
echo "executed: DB_HOST=[${DB_HOST:-}] pwd=$PWD"
source setenv.sh
echo "sourced: DB_HOST=[$DB_HOST] pwd=$PWD"
$ bash q20.sh
executed: DB_HOST=[] pwd=/home/alice/work
sourced: DB_HOST=[prod-db] pwd=/tmp
Executing runs the script in a child process, so its export and its cd disappear when
it exits. Sourcing runs it in the current shell. That’s why nvm, virtualenv’s activate
and .bashrc are sourced, not executed.
Q21: How do you debug a script?
cat > buggy.sh <<'EOF'
total=0
for n in 1 2 x; do total=$(( total + n )); done
echo "total=$total"
EOF
bash -x buggy.sh
$ bash q21.sh
+ total=0
+ for n in 1 2 x
+ total=1
+ for n in 1 2 x
+ total=3
+ for n in 1 2 x
+ total=3
+ echo total=3
total=3
The trace shows the bug the final answer hides. On the x pass, total didn’t change.
Inside $(( )), x is read as a variable name, it’s unset, so it counts as 0, with no
error. Other tools: set -x/set +x around one section, declare -p var to inspect a
variable, and shellcheck before running anything. Practice task 1 fixes the loop.
Q22: &&, || and ;?
false && echo "&&: not printed"
false || echo "||: runs because false failed"
false ; echo ";: runs regardless"
true && false || echo "a && b || c: c ran because b failed"
$ bash q22.sh
||: runs because false failed
;: runs regardless
a && b || c: c ran because b failed
The last line is the follow-up question interviewers like: a && b || c is not
if/else. c also runs when b fails. Write a real if when that matters.
Q23: How do you make a file work both sourced and executed?
cat > lib.sh <<'EOF'
greet() { echo "Hello, $1!"; }
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
greet "${1:-World}"
fi
EOF
echo "-- executed:"; bash lib.sh Alice
echo "-- sourced:"; source lib.sh; echo "(no output on source)"; greet Bob
$ bash q23.sh
-- executed:
Hello, Alice!
-- sourced:
(no output on source)
Hello, Bob!
BASH_SOURCE[0] is the file being read. $0 is the program that was started. They are
equal only when the file is executed directly. This is Bash’s version of Python’s
if __name__ == "__main__".
Q24: How do you layer configuration?
Defaults, then a config file, then environment variables, then validation. Each layer overrides the one before it:
DB_HOST="localhost" # 1. default
[[ -f /etc/myapp.conf ]] && source /etc/myapp.conf # 2. file (trusted, root-owned only)
DB_HOST="${MYAPP_DB_HOST:-$DB_HOST}" # 3. environment wins
: "${DB_HOST:?DB_HOST must be set}" # 4. fail fast
The script-patterns lesson runs this pattern with real output. Mention that source runs
the config file as code, so it has to be trusted.
Q25: Write a disk-usage alert.
set -euo pipefail
threshold="${1:-85}"
alerts=()
while read -r pct target; do
pct=${pct%\%}
(( pct >= threshold )) && alerts+=("$target: ${pct}%")
done < <(df --output=pcent,target "${@:2}" | tail -n +2)
if (( ${#alerts[@]} )); then
printf 'DISK ALERT on %s (threshold %s%%):\n' "$(hostname)" "$threshold"
printf ' %s\n' "${alerts[@]}"
exit 1
fi
echo "All disks OK (threshold: ${threshold}%)"
$ bash q25.sh 85 /; bash q25.sh 1 /; echo "exit $?"
All disks OK (threshold: 85%)
DISK ALERT on devbox (threshold 1%):
/: 6%
exit 1
A threshold of 1% forces the alert, which is how you test the failure path. Points worth
naming: df --output instead of scraping column 5, one df call instead of one per mount,
alerts collected into an array, and a non-zero exit status so cron or monitoring notices.
Sending the alert (mail, a Slack webhook through curl) is one more line.
Quick Reference
name="value"; echo "${name:-default}"; unset name # variables
[[ -f $f ]] [[ -z $s ]] [[ $a -eq $b ]] [[ $s =~ re ]] # tests
for x in "${arr[@]}"; do ...; done # loops
while IFS= read -r line; do ...; done < file
f() { local x="$1"; echo "$x"; }; r=$(f arg) # functions
set -euo pipefail; trap cleanup EXIT # safety
cmd > out.log 2>&1; cmd 2>/dev/null; a | b | c # redirection & pipes
Practice
-
Fix
buggy.shfrom Q21 so that a non-number is reported and skipped, not silently counted as 0.Solution
total=0 for n in 1 2 x; do [[ $n =~ ^-?[0-9]+$ ]] || { echo "not a number: '$n'" >&2; continue; } total=$(( total + n )) done echo "total=$total"$ bash p1.sh not a number: 'x' total=3 -
Parse
bob,25,"New York, NY"so the city is exactlyNew York, NY.Solution
gawk’s
FPATdescribes what a field is, not what separates fields. Callgawkby name:FPATis a gawk extension, and the defaultawkon Debian and Ubuntu is oftenmawk, which ignores it:printf 'name,age,city\nbob,25,"New York, NY"\n' > data.csv gawk -v FPAT='([^,]*)|("[^"]*")' 'NR > 1 { gsub(/"/, "", $3); print "name=" $1 " city=[" $3 "]" }' data.csv$ bash p2.sh name=bob city=[New York, NY] -
An interviewer asks for the busiest client IP in an access log, and the number of distinct IPs, as one-liners.
Solution
printf '%s\n' "10.0.0.1 GET /" "10.0.0.2 GET /a" "10.0.0.1 POST /b" "10.0.0.3 GET /" "10.0.0.1 GET /c" > access.log awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -1 echo "unique IPs: $(awk '{print $1}' access.log | sort -u | wc -l)"$ bash p3.sh 3 10.0.0.1 unique IPs: 3