Skip to main content
Bash · lesson 22 of 22
Bash advanced 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

  1. Fix buggy.sh from 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
  2. Parse bob,25,"New York, NY" so the city is exactly New York, NY.

    Solution

    gawk’s FPAT describes what a field is, not what separates fields. Call gawk by name: FPAT is a gawk extension, and the default awk on Debian and Ubuntu is often mawk, 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]
  3. 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

Frequently Asked Questions

What level of Bash knowledge do DevOps interviews expect?
Most DevOps and SRE interviews expect solid knowledge of variables, quoting, pipelines, redirection, error handling, and common tools like grep/awk/sed. Senior roles add process management, signal handling, and security awareness.
Should I memorize exact syntax or understand concepts?
Both matter. Interviewers want to see you can write working code, but they also probe whether you understand why something works — like why pipelines create subshells, or why set -e sometimes surprises people.
Are Bash questions common in FAANG interviews?
Not in algorithm rounds, but in system design, SRE, and DevOps-focused roles they are common. Expect practical scripting tasks, debugging broken scripts, and questions about Unix fundamentals.