Bash & the POSIX Shell Module 18 – Choosing tools and interview preparation
Shell scripting interview questions, explained
Twenty-four shell interview questions on quoting, pipelines, set -e, file names, awk, sed, cron and traps, answered with the rule and most with a run.
What you will learn
- Answer shell interview questions with a short test that proves each answer
- Explain why a variable set in a piped while loop is lost, and fix it two ways
- Handle awkward file names with find, xargs and --
- Debug a broken script or pipeline stage by stage
- Predict when a crontab line runs and what a trap cleans up
Before you start
What you’ll learn · Before you start
What you will learn
- Answer shell interview questions with a short test that proves each answer
- Explain why a variable set in a piped while loop is lost, and fix it two ways
- Handle awkward file names with find, xargs and --
- Debug a broken script or pipeline stage by stage
- Predict when a crontab line runs and what a trap cleans up
Before you start
On this page
This page answers 24 shell interview questions, each about one documented behaviour of the shell or a standard tool, and proves 21 of the answers with a recorded run. The first is a classic: a loop counts three lines, and afterwards the counter still says 0.
The questions are original and grouped by topic, with links to the lessons of this track that go deeper. You will see
how a strong answer is built, then questions on quoting, how scripts run, set -e, awkward file names, awk and sed
one-liners, cron and traps, and debugging, with a checkpoint after each group and a practice round at the end.
A strong answer restates the question, proves it in two lines and names the rule
An interviewer who asks a shell question listens for two things: whether the answer is right, and whether you know why. One way to give both is to take the same five steps every time: restate the question, prove the behaviour with a two-line test, name the rule and where it is written, name the trap, and give the fix. Step through them on the first question of this page:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Restate it. The question was why n is still 0 after printf a b c | while read. Restated: which shell process ran n=$((n + 1)), and did the change reach the shell that prints n afterwards?
- Test in two lines. Run it: the pipeline version printed n=0, and the same loop fed by done < <(printf ...) printed n=3. Two lines settle what you saw, so the rest of the answer explains a fact instead of a guess.
- Name the rule. The Bash manual says each command of a multi-command pipeline runs in its own subshell, a separate process. The loop counted to 3 in its copy, and the copy ended with the pipeline.
- Name the trap. shopt -s lastpipe fixes it only when job control is off: the script printed n=3, while the same line at an interactive prompt still printed n=0. dash also printed 0.
- Give the fix. Feed the loop with a redirection, done < <(cmd) or done < file, so it runs in the current shell, or use lastpipe in a script. Then stop: an answer that ends with its fix leaves room for the next question.
Why does a counter stay at 0 after printf … | while read?
The test is short. The session runs the loop in a pipeline, then fed by a process substitution, then in this script with lastpipe on, then with lastpipe at the prompt, and finally in dash:
#!/usr/bin/env bash
# The counter of the interview question, in a script: no job control here, so lastpipe runs the loop in this shell.
shopt -s lastpipe
n=0
printf '%s\n' a b c | while read -r _; do n=$((n + 1)); done
echo "n=$n" from session import files, run
# Why a counter stays 0 after cmd | while read: the loop runs in a subshell. Two fixes: feed the loop with a
# redirection from a process substitution, or turn on lastpipe in a script. At an interactive prompt (as here, with
# job control on) lastpipe does nothing; dash also runs the loop in a subshell.
files("lastpipe.sh")
run("""
n=0; printf '%s\\n' a b c | while read -r line; do n=$((n + 1)); done; echo "n=$n"
n=0; while read -r line; do n=$((n + 1)); done < <(printf '%s\\n' a b c); echo "n=$n"
bash lastpipe.sh
shopt -s lastpipe; n=0; printf '%s\\n' a b c | while read -r line; do n=$((n + 1)); done; echo "n=$n"
dash -c 'n=0; printf "%s\\n" a b c | while read -r line; do n=$((n + 1)); done; echo "n=$n"'
""") Output
$ n=0; printf '%s\n' a b c | while read -r line; do n=$((n + 1)); done; echo "n=$n" n=0 $ n=0; while read -r line; do n=$((n + 1)); done < <(printf '%s\n' a b c); echo "n=$n" n=3 $ bash lastpipe.sh n=3 $ shopt -s lastpipe; n=0; printf '%s\n' a b c | while read -r line; do n=$((n + 1)); done; echo "n=$n" n=0 $ dash -c 'n=0; printf "%s\n" a b c | while read -r line; do n=$((n + 1)); done; echo "n=$n"' n=0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 counter_session.py
The pipeline version printed n=0 and the fed loop n=3. The Bash manual gives the rule: each command of a
multi-command pipeline runs in its own subshell: A copy of the shell that runs commands apart from it, as Bash does for each command of a pipeline. It starts with the shell's variables, and every change it makes to them is lost when it exits. More in “Reading files line by line with read”, a separate process, so the loop counted to 3
in a copy of n that ended with the pipeline. POSIX lets a shell run parts of a pipeline in the current shell as an
extension, but dash printed 0 as well. The fix keeps the loop out of the pipeline: done < <(printf …) feeds it through
a process substitution: <(command) or >(command): Bash starts the command and puts a file name such as /dev/fd/63 in its place. Reading that file gives the command's output; writing to it feeds the command's input. More in “Command and process substitution”, and done < file does the same for a file. The
subshells lesson shows the other commands that Bash runs in a
subshell.
In a script, shopt -s lastpipe runs the last stage in the current shell, and lastpipe.sh printed n=3. At an
interactive prompt the lastpipe: A Bash option, turned on with shopt -s lastpipe, that runs the last command of a pipeline in the current shell when job control is off, as it is in scripts, so a while loop at the end of a pipeline keeps the variables it sets. option does nothing while job control is on, so the same line
printed n=0 again. That is the trap worth naming: it is where people try the fix, see 0, and decide it does not work.
Step through which process holds n in each version:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The pipeline starts two copies. For printf a b c | while read, Bash starts both commands of the pipeline in subshells. The loop gets its own copy of every variable, n=0 included.
- Three lines reach the loop. printf writes a, b and c into the pipe, and the loop reads them one by one. Each pass adds 1 to the copy.
- The copy ends with the loop. At the end of the input the loop ends, and its subshell ends with it. Nothing is sent back, so echo "n=$n" in your shell printed n=0.
- A redirection keeps one shell. With done < <(printf ...), only printf runs apart. The loop runs in your shell and reads from the substitution, so the n it changes is the n that echo prints: n=3.
Key idea
Prove the behaviour first, then explain it with the rule and its source; an answer that rests on a run is hard to argue with.
Checkpoint
Check: Building a strong answer
Quoting questions turn on when Bash splits words and expands globs
Most quoting questions ask one thing in different clothes: when does Bash cut a value into words, and when does it treat
* as a pattern? A folder with my report.txt and notes.txt answers three of them:
from session import run, write
# Three quoting questions: looping over $(ls) or a glob when a name holds a space, [ -n $x ] with an empty x, and an
# unquoted variable that holds a star.
write("my report.txt", "draft\n")
write("notes.txt", "todo\n")
run("""
ls
for f in $(ls); do echo "[$f]"; done
for f in *; do echo "[$f]"; done
for f in "$(ls)"; do echo "[$f]"; done
x=''
[ -n $x ] && echo 'the test said non-empty'
[ -n "$x" ] || echo 'the quoted test said empty'
[[ -n $x ]] || echo 'the [[ ]] test said empty'
var='*'
echo $var
echo "$var"
""") Output
$ ls my report.txt notes.txt $ for f in $(ls); do echo "[$f]"; done [my] [report.txt] [notes.txt] $ for f in *; do echo "[$f]"; done [my report.txt] [notes.txt] $ for f in "$(ls)"; do echo "[$f]"; done [my report.txt notes.txt] $ x='' $ [ -n $x ] && echo 'the test said non-empty' the test said non-empty $ [ -n "$x" ] || echo 'the quoted test said empty' the quoted test said empty $ [[ -n $x ]] || echo 'the [[ ]] test said empty' the [[ ]] test said empty $ var='*' $ echo $var my report.txt notes.txt $ echo "$var" *
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 quoting_session.py
Which loop handles a file name with a space: for f in $(ls), for f in * or for f in "$(ls)"?
Only for f in *. The $(ls) loop ran three times, with my and report.txt as two names, because Bash splits the
unquoted result of a command substitution at the characters of IFS. The quoted version ran once, with both names and
the newline between them in one word. The results of a glob are never split, so * gave exactly two words, and it
needs no ls at all. The word splitting lesson shows the rule step by step.
Why does [ -n $x ] say yes when x is empty?
Unquoted, the empty $x disappears, so [ receives a single argument, -n. POSIX test with one argument is true
when that argument is not empty, and -n is not empty, so the session printed the test said non-empty. Quoted,
[ -n "$x" ] receives two arguments and is false. [[ -n $x ]] was false even unquoted, because [[ ]] is part of
Bash’s own grammar and does no word splitting inside. The test lesson covers
the other operators.
What does echo $var print when var holds *?
The file names, my report.txt notes.txt. Unquoted, the value went through word splitting and then pathname expansion,
and * matched both files. Quoted, echo "$var" printed the star itself. Every unquoted expansion takes both steps,
which is why careful scripts quote expansions by default and leave one unquoted only on purpose.
Quoting only the values that might hold a space
Wrong: leaving $var unquoted because it “never has spaces”. Right: "$var". The star above held no space at all
and still turned into two file names.
Checkpoint
Check: Splitting and globbing questions
Questions on how a script runs: source, its own folder, 126 and 127
Three questions are about the process a script runs in. The session runs a file that changes folder and sets a variable, a script that prints where it lives, a file without execute permission and a command that does not exist:
from session import run, write
# How a script runs: bash file and ./file start a new Bash, source runs the file in this shell; the script's own
# folder comes from BASH_SOURCE, even when it is sourced; 126 and 127 are the statuses for "not executable" and "not found".
write("setdir.sh", "cd sub\nfavourite=blue\n")
write("tools/where.sh", '#!/usr/bin/env bash\nhere=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)\necho "called as: $0"\necho "script folder: $here"\n')
write("plain.txt", "echo hi\n")
run("""
mkdir sub
chmod +x tools/where.sh
bash setdir.sh; pwd; echo "favourite=${favourite-unset}"
source setdir.sh; pwd; echo "favourite=${favourite-unset}"
cd ~/demo
tools/where.sh
cd tools && ./where.sh && cd ..
source tools/where.sh
!./plain.txt
!no-such-command
""") Output
$ mkdir sub
$ chmod +x tools/where.sh
$ bash setdir.sh; pwd; echo "favourite=${favourite-unset}"
~/demo
favourite=unset
$ source setdir.sh; pwd; echo "favourite=${favourite-unset}"
~/demo/sub
favourite=blue
$ cd ~/demo
$ tools/where.sh
called as: tools/where.sh
script folder: ~/demo/tools
$ cd tools && ./where.sh && cd ..
called as: ./where.sh
script folder: ~/demo/tools
$ source tools/where.sh
called as: bash
script folder: ~/demo/tools
$ ./plain.txt
bash: ./plain.txt: Permission denied
[exit status 126]
$ no-such-command
bash: no-such-command: command not found
[exit status 127]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 running_session.py
What is the difference between bash setdir.sh and source setdir.sh?
bash setdir.sh started a new Bash, which changed its own folder, set its own favourite and ended, so pwd still
printed ~/demo and favourite stayed unset. source, or ., reads the file into the current shell, as the Bash
manual puts it, so the shell moved to ~/demo/sub and favourite became blue. Use it for settings and function
libraries, and a child process for anything that must not change the caller; the
sourcing lesson lays out a script that does both.
How does a script find the folder it lives in?
From ${BASH_SOURCE[0]}, the file the running code came from. The line
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) printed ~/demo/tools whether the script ran as
tools/where.sh, as ./where.sh from its own folder or through source. $0 changed every time, to
tools/where.sh, ./where.sh and, when sourced, bash, so it is the wrong variable for this.
What do exit statuses 126 and 127 mean?
Both come from the shell, before any program runs. 126 means the file was found but cannot be executed: ./plain.txt
had no execute permission and gave Permission denied. 127 means that no command of that name was found, as with
no-such-command. The manual’s section on exit status defines both, and a script that reports them as a plain
“failed” throws away the most useful clue. The exit status lesson covers
the other conventional statuses.
Checkpoint
Check: How a script runs
Errexit questions are about the places it does not look
Interviewers like set -e, the errexit: Bash's set -e option (set -o errexit): the shell ends the script when a simple command returns non-zero, outside the exemption spots (if/while/until condition, every command but the last of && or ||, after !, inside a function called from those, a command substitution, and non-last pipeline stages). More in “set -e, set -u and pipefail: what they really do” option, because its exceptions are easy to forget. The
errexit lesson tries the full list; these two cases show the kind of answer
an interviewer listens for:
from session import run, write
# Two set -e questions: a function that runs as the left side of || ignores set -e inside, and a script whose last
# line is an && list ends with that list's status.
write("deploy.sh", '''#!/usr/bin/env bash
set -e
deploy() {
echo "step 1"
false
echo "step 2 ran although false failed"
}
deploy || echo "deploy failed"
echo "status of the || list: $?"
''')
write("ends-with-and.sh", '''#!/usr/bin/env bash
echo "work done"
[ -f app.lock ] && rm app.lock
''')
run("""
bash deploy.sh
!bash -c 'set -e; deploy() { echo "step 1"; false; echo "step 2"; }; deploy; echo "after deploy"'
!bash ends-with-and.sh
touch app.lock
bash ends-with-and.sh
""") Output
$ bash deploy.sh
step 1
step 2 ran although false failed
status of the || list: 0
$ bash -c 'set -e; deploy() { echo "step 1"; false; echo "step 2"; }; deploy; echo "after deploy"'
step 1
[exit status 1]
$ bash ends-with-and.sh
work done
[exit status 1]
$ touch app.lock
$ bash ends-with-and.sh
work done
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 errexit_session.py
Under set -e, why did deploy || echo "deploy failed" run every line of deploy after false failed?
Because the function ran as the left side of ||, a place where set -e is ignored, and the manual says that inside a
function called from such a place none of its commands are affected. So false failed, step 2 ran, the function ended
with the status of its last echo, which is 0, and the || branch never ran. Called on a line of its own, the same
function stopped after step 1 with status 1. A function that must stop at its first failure checks each step itself,
for example with cmd || return.
Why does a script that ends with [ -f app.lock ] && rm app.lock exit with status 1?
A script ends with the status of its last command, and an && list whose test fails ends with that test’s status. With
no app.lock the test returned 1, so the script printed work done and still ended with status 1; with the file there,
it ended with 0. Nothing went wrong, yet a CI step or a calling script sees a failure. End such a script with
if [ -f app.lock ]; then rm app.lock; fi, which returns 0 when the test is false, or with rm -f app.lock.
Name the place, then the check that replaces it
“set -e is ignored there” is half an answer. Name the place, such as the left side of ||, an if test or a stage
of a pipeline before the last, and say which explicit check you would write instead.
Checkpoint
Check: Where set -e does not look
File-name questions are about spaces, newlines and a leading dash
Linux and macOS accept file names with spaces, newlines and a leading dash, so a script that lists files has to
survive all of them. The session makes four such names and passes them through GNU find and xargs 4.11.0 and the
Mac’s own sort and cat:
from session import run, write
# Four awkward file names: a plain one, one with a space, one with a newline and one that starts with a dash. GNU find
# and xargs 4.11.0 read them; directory order is not fixed, so every list is sorted first.
write("in/plain.log", "plain\n")
write("in/two words.log", "space\n")
write("in/line\nbreak.log", "newline\n")
write("in/-n.log", "dash file\n")
run("""
cd in
find . -name '*.log' | sort
find . -name '*.log' -print0 | sort -z | xargs -0 printf '[%s]\\n'
find . -name '*.log' -exec sh -c 'echo "one run, $# names"' sh {} \\;
find . -name '*.log' -exec sh -c 'echo "one run, $# names"' sh {} +
!cat -n.log
cat -- -n.log
cat ./-n.log
""") Output
$ cd in
$ find . -name '*.log' | sort
./-n.log
./line
./plain.log
./two words.log
break.log
$ find . -name '*.log' -print0 | sort -z | xargs -0 printf '[%s]\n'
[./-n.log]
[./line
break.log]
[./plain.log]
[./two words.log]
$ find . -name '*.log' -exec sh -c 'echo "one run, $# names"' sh {} \;
one run, 1 names
one run, 1 names
one run, 1 names
one run, 1 names
$ find . -name '*.log' -exec sh -c 'echo "one run, $# names"' sh {} +
one run, 4 names
$ cat -n.log
cat: illegal option -- .
usage: cat [-belnstuv] [file ...]
[exit status 1]
$ cat -- -n.log
dash file
$ cat ./-n.log
dash file
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 names_session.py
Why is a plain list from find unsafe for a loop, and what is safe?
Plain output ends each name with a newline, so the name with a newline in it printed as two lines: four files became
five lines. -print0 ends each path with a null byte instead, the one byte that no path name can contain, and
xargs -0 splits on it, so ./line and break.log arrived as one argument. POSIX.1-2024 defines both options, and
sort -z kept the list in order. The find and xargs lesson covers these forms in depth.
What is the difference between -exec cmd {} \; and -exec cmd {} +?
How many times the command runs. The small sh prints $#, the number of
positional parameter: An argument of a script or function, read by its position as $1, $2 and from the tenth on ${10}; $# counts them and "$@" passes them all on. More in “Positional and special parameters” it was given, and with \; find started it once per
file: four runs of one name each. With +, it packed the names into as few runs as the
ARG_MAX: The limit the kernel sets on how many bytes one command line (argv plus the environment) may hold. getconf ARG_MAX prints it; find -exec {} + and xargs batch files so each run fits, and 'Argument list too long' is what a run that spilled over gets. More in “find and xargs: working with many files” allows, here one run with four names. + is faster on many files; \;
is for a command that takes one name only.
How do you open or delete a file named -n.log?
Put -- before it, or give it a path. cat -n.log read the name as options and failed with illegal option -- ..
cat -- -n.log worked, because POSIX’s utility guidelines make the first -- the end of the options, and
cat ./-n.log worked because ./-n.log does not start with a dash. A glob such as *.log can produce such a name too,
so rm -- *.log is the safe habit.
Checkpoint
Check: Awkward file names
One-liner questions: the tenth line, first-seen order, a block and a sum
Live questions often ask for one line of sed or awk. Here are four, on small files, with GNU sed 4.10 and GNU awk 5.4.1:
from session import run, write
# Four one-liners an interviewer may ask for, on small files: the tenth line, duplicates removed in their first order,
# the lines between two markers, and the sum of a column. GNU awk 5.4.1 and GNU sed 4.10.
write("lines.txt", "".join(f"line {i}\n" for i in range(1, 13)))
write("visits.txt", "asha\nravi\nasha\nmeera\nravi\nasha\n")
write("app.conf", "name=shop\nBEGIN\nport=8080\nworkers=4\nEND\nmode=live\n")
write("sizes.txt", "a.log 120\nb.log 75\nc.log 305\n")
run("""
sed -n '10p' lines.txt
sed -n '10{p;q;}' lines.txt
awk 'NR == 10' lines.txt
head -n 10 lines.txt | tail -n 1
awk '!seen[$0]++' visits.txt
sort -u visits.txt
sed -n '/^BEGIN$/,/^END$/p' app.conf
awk '/^BEGIN$/ { inside = 1; next } /^END$/ { inside = 0 } inside' app.conf
awk '{ total += $2 } END { print total }' sizes.txt
""") Output
$ sed -n '10p' lines.txt
line 10
$ sed -n '10{p;q;}' lines.txt
line 10
$ awk 'NR == 10' lines.txt
line 10
$ head -n 10 lines.txt | tail -n 1
line 10
$ awk '!seen[$0]++' visits.txt
asha
ravi
meera
$ sort -u visits.txt
asha
meera
ravi
$ sed -n '/^BEGIN$/,/^END$/p' app.conf
BEGIN
port=8080
workers=4
END
$ awk '/^BEGIN$/ { inside = 1; next } /^END$/ { inside = 0 } inside' app.conf
port=8080
workers=4
$ awk '{ total += $2 } END { print total }' sizes.txt
500
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 oneliners_session.py
How do you print the tenth line of a file?
Three answers printed line 10: sed -n '10p', awk 'NR == 10' and head -n 10 | tail -n 1. The first two read the
whole file; sed -n '10{p;q;}' quits after printing, which matters on a large file. Saying so is what turns a correct
answer into a strong one.
How do you remove duplicate lines but keep the first order?
awk '!seen[$0]++' printed asha, ravi and meera. The first time a line appears, seen[$0] is 0, so the pattern
is true and the line prints; the count then rises, so later copies are skipped. sort -u also removed the duplicates,
but printed asha, meera and ravi, in sorted order.
How do you print the lines between two markers?
sed -n '/^BEGIN$/,/^END$/p' printed the block with both markers. To leave them out, keep a flag in awk: set it on
BEGIN and skip that line with next, clear it on END, and print while it is set, which printed only port=8080 and
workers=4.
How do you add up a column?
awk '{ total += $2 } END { print total }' printed 500 for 120, 75 and 305: awk splits each line into fields at blanks,
reads $2 as a number, and runs the END block once after the last line. The
sed lesson and the awk lesson go further.
Checkpoint
Check: sed and awk one-liners
Cron and trap questions: when a job runs, and what cleans up after it
Each crontab line starts with five time fields and ends with a command that cron hands to sh; in order, the fields
are the minute, the hour, the day of the month, the month and the day of the week. No cron daemon runs in these sessions, so the three cron answers rest on the POSIX crontab page and
the manual of cronie, a Linux cron daemon. The cron expression generator lists the
next run times of any line you write.
When does */15 9-17 * * 1-5 /opt/jobs/report.sh run?
Every 15 minutes, at minutes 0, 15, 30 and 45, of every hour from 9 to 17, Monday to Friday: the first run is at 09:00
and the last at 17:45. 9-17 is an inclusive range, and 1-5 counts the days of the week from 0 for Sunday. */15 is
a step value, which cronie’s manual documents; POSIX itself defines only *, numbers, ranges and lists, so a strictly
POSIX cron needs 0,15,30,45.
What does 0 9 1 * 1 mean?
Not “the 1st of the month when it is a Monday”. When both the day of the month and the day of the week are restricted, POSIX says that a day matches if it matches either one, so this line runs at 09:00 on the 1st of every month and also on every Monday. To run only on a Monday that is the 1st, keep one field and test the other inside the command.
Why does date +%F break in a crontab command?
Cron turns every unescaped % in the command field into a newline: the command ends at the first %, and the rest
becomes its standard input, as POSIX describes. A command such as backup.sh "$(date +%F)" then reaches sh cut off
after date +, which it cannot parse. Write \% instead, as in date +\%F.
Does trap cleanup EXIT run when the script receives TERM?
In Bash 5.3, yes; in dash, not by itself. The session sends TERM to the script’s own process:
from session import run, write
# A trap on EXIT and a TERM signal the script sends itself. Bash 5.3.20 runs the EXIT trap before it dies of an
# untrapped TERM; dash 0.5.13.5 does not, until TERM gets a trap that calls exit.
write("trap-demo.sh", '''trap 'echo "cleanup ran"' EXIT
echo "working"
kill -TERM $$
echo "not reached"
''')
write("trap-fixed.sh", '''trap 'echo "cleanup ran"' EXIT
trap 'exit 143' TERM
echo "working"
kill -TERM $$
echo "not reached"
''')
run("""
!bash trap-demo.sh
!dash trap-demo.sh
!dash trap-fixed.sh
""") Output
$ bash trap-demo.sh working cleanup ran [exit status 143] $ dash trap-demo.sh working [exit status 143] $ dash trap-fixed.sh working cleanup ran [exit status 143]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 traps_session.py
Bash printed cleanup ran and then died of the signal with status 143, which is 128 plus 15, the number of TERM. dash
0.5.13.5 printed nothing more and ended with 143. When a script may run under sh, add trap 'exit 143' TERM: the
exit in that handler runs the EXIT trap: A Bash builtin that registers code to run on a signal or a shell event: EXIT when the shell exits, ERR when a command's failure would end a set -e script, RETURN when a function returns, DEBUG before every simple command. trap action signal …; the action is one shell word. More in “Traps and cleanup: EXIT, ERR and signals”, and dash then printed cleanup ran too. The
traps lesson follows the EXIT trap through the other ways a script ends, and what 143
and its neighbours mean has its own question in the signals lesson.
Checkpoint
Check: Cron lines and traps
Debugging questions: find the failing stage before you guess
A debugging question hands you a script that misbehaves and watches what you do first. A good order is the ladder below, and the session after it shows what each step found:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Check the syntax. bash -n reads a script without running it. It passed typo.sh, whose third line calls gerp instead of grep, because a misspelled command is still valid syntax.
- Lint it. ShellCheck reads the script and names quoting, test and portability mistakes. It also passed typo.sh: no rule knows which commands exist on your machine.
- Run it and read stderr. bash typo.sh printed "line 3: gerp: command not found" and still ended with status 0, because the script has no set -e and its last command succeeded. Read the messages, not only the status.
- Shorten the pipeline. report.sh printed counts of 200 and 500 instead of paths. Running the first stage alone, cut -f4, showed status codes; field 3 holds the paths. Cut a pipeline from the right until the output is what you expect.
- Trace with file and line. With PS4 set to "+ ${BASH_SOURCE}:${LINENO}: ", bash -x printed typo.sh and the line number before each command it traced, so the failing line 3 stood out in a long trace.
#!/usr/bin/env bash
# The two busiest paths of access.log (lines "client method path status ms"). It has a bug: it cuts field 4.
cut -d' ' -f4 access.log | sort | uniq -c | sort -rn | head -n 2 from session import files, run, write
# Debugging: a report that prints the wrong thing, cut stage by stage; a script with a misspelled command that bash -n
# and ShellCheck both pass; and xtrace with a PS4 that names the file and the line of each command.
files("report.sh")
write("access.log", "192.0.2.4 GET /search 200 61\n198.51.100.7 GET /cart 200 90\n192.0.2.4 GET /search 500 812\n203.0.113.2 POST /cart 201 120\n198.51.100.7 GET /search 200 58\n")
write("typo.sh", "#!/usr/bin/env bash\necho start\ngerp -c ERROR app.log\necho end\n")
run("""
bash report.sh
cut -d' ' -f4 access.log | head -n 2
cut -d' ' -f3 access.log | head -n 2
bash -n typo.sh && echo 'bash -n: no syntax error'
shellcheck typo.sh && echo 'shellcheck: no finding'
bash typo.sh
env PS4='+ ${BASH_SOURCE}:${LINENO}: ' bash -x typo.sh
""") Output
$ bash report.sh
3 200
1 500
$ cut -d' ' -f4 access.log | head -n 2
200
200
$ cut -d' ' -f3 access.log | head -n 2
/search
/cart
$ bash -n typo.sh && echo 'bash -n: no syntax error'
bash -n: no syntax error
$ shellcheck typo.sh && echo 'shellcheck: no finding'
shellcheck: no finding
$ bash typo.sh
start
typo.sh: line 3: gerp: command not found
end
$ env PS4='+ ${BASH_SOURCE}:${LINENO}: ' bash -x typo.sh
+ typo.sh:2: echo start
start
+ typo.sh:3: gerp -c ERROR app.log
typo.sh: line 3: gerp: command not found
+ typo.sh:4: echo end
end
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 debug_session.py
report.sh prints counts of 200 and 500 instead of paths. How do you find the bug?
Cut the pipeline from the right and look at each stage’s output. The first stage alone, cut -d' ' -f4, printed 200,
the status codes, so the fault is there before sort ever runs; -f3 printed /search and /cart. Five stages are
found faster this way than by reading them, and the habit shows an interviewer that you test before you guess.
bash -n and ShellCheck both passed. Why did the script still fail?
bash -n reads commands without running them, so it checks syntax only, and ShellCheck reads the text for known
mistakes; neither knows which commands exist on the machine. Run for real, typo.sh printed gerp: command not found
on line 3 and then ended with status 0, because it has no set -e and its last command succeeded. Read the error
messages, not only the status.
How do you make a trace show where each command is?
Set PS4: The prompt Bash prints before each command that set -x traces. It defaults to a plus sign and a space and is expanded each time, so a value such as '+ ${BASH_SOURCE}:${LINENO}: ' adds the file and line of every traced command., the prompt Bash prints before each traced command. With
PS4='+ ${BASH_SOURCE}:${LINENO}: ', the trace printed + typo.sh:3: gerp -c ERROR app.log, the file and the line of
the failing command. The debugging lesson covers tracing only part of a script.
Checkpoint
Check: Debugging stage by stage
Practise: choose the stronger answer
Each question below either gives an interview question with answers of different quality or asks what a command from the sessions printed. Choose, then read what each answer gets right or leaves out:
Practice that adapts to you
Practise this lesson
17 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): when a script hands its arguments to another command, why write "$@" and not "$*"?
"$@" expands to each positional parameter as a separate word, exactly as it was received, so a first argument a b
stays one argument: printf '[%s]\n' "$@" printed [a b] and [c]. "$*" joins them all into one word, separated
by the first character of IFS, a space by default, so it printed [a b c], and with IFS=, it printed [a b,c].
Unquoted, $@ and $* are split and globbed again, which printed [a], [b] and [c]. Pass arguments on with
"$@", as a wrapper’s exec program "$@" does, and use "$*" only to build one string, such as a log message.
from session import run
# The warm-up question: "$@" against "$*" when a script passes its arguments on. Two arguments, the first with a
# space; printf prints each argument it receives in brackets.
run("""
set -- 'a b' c
printf '[%s]\\n' "$@"
printf '[%s]\\n' "$*"
printf '[%s]\\n' $@
printf '[%s]\\n' $*
IFS=,; printf '[%s]\\n' "$*"; unset IFS
""") Output
$ set -- 'a b' c $ printf '[%s]\n' "$@" [a b] [c] $ printf '[%s]\n' "$*" [a b c] $ printf '[%s]\n' $@ [a] [b] [c] $ printf '[%s]\n' $* [a] [b] [c] $ IFS=,; printf '[%s]\n' "$*"; unset IFS [a b,c]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 args_session.py
Three questions go further, each with a model answer: a failed-login report to write in about a quarter of an hour, a runner that keeps four commands going at once and names every failure, and a review in which you defend the choices of your own script. Plan your first two minutes before you open each one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
Live task, about 15 minutes. An auth log has lines such as sshd[812]: Failed password for root from 203.0.113.9 port 51022 ssh2, and some say for invalid user admin. Write a script that prints the five source addresses with the most failed logins, with counts, and fails clearly when the log cannot be read.
-
Mid to staff
Live task. A file lists one shell command per line. Write a Bash script that runs them with at most four running at any moment, waits for all of them, prints every failed command with its exit status, and exits 1 if any failed.
-
Mid to staff
In a review of your failed-logins script, the interviewer asks why you used awk rather than a while read loop, why the sort has two keys rather than sort -rn, and why the script sets -uo pipefail but not -e. Defend each choice, and say what would make you change it.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What makes an interview answer about shell behaviour strong?
It restates the question, proves the behaviour with a two-line test, names the rule and its source, names the trap and gives the fix.
-
Why does a script hand its arguments on with "$@" and not "$*"?
"$@" keeps each argument a separate word, so an argument a b stays one: printf '[%s]\n' "$@" printed [a b] and [c]. "$*" glues every argument together into a single word, with IFS's first character as the glue.
-
Why does a counter set in a loop at the end of a pipeline vanish?
The loop runs in a subshell. Feed it with done < <(cmd) instead, or set lastpipe in a script, where job control is off.
-
Does set -e stop a function that fails when the function was called from the left of ||?
No. set -e does nothing inside a function called from the left of || or &&, however many of its commands fail.
-
Which one-liners print the tenth line of a file and drop repeated lines in first-seen order?
sed -n '10{p;q;}' prints line 10 and stops reading, and awk '!seen[$0]++' prints each line the first time it appears.
-
Does an EXIT trap run when an untrapped TERM ends the script?
In Bash 5.3 it does; in dash it does not.
-
How do you debug a broken pipeline in an interview question?
Find the failing stage before guessing: check the stages one at a time, with a PS4 that adds the line number to each trace line.
Key takeaways
- A strong answer restates the question, proves it with a two-line test, names the rule and its source, names the trap and gives the fix, as the counter question showed.
- A loop at the end of a pipeline runs in a subshell, so its changes vanish; feed it with
done < <(cmd), or use lastpipe in a script, where job control is off. - Unquoted expansions are split and globbed, which broke
for f in $(ls), made[ -n $x ]true for an emptyxand turned*into file names. sourceruns a file in the current shell,${BASH_SOURCE[0]}finds a script’s folder, 126 and 127 mean not executable and not found, andset -edoes nothing inside a function called from||.- File names need
-print0withxargs -0and--before a leading dash, and one-liners such asawk '!seen[$0]++'andsed -n '10{p;q;}'answer the live questions in one line each. - Cron matches either day field when both are set and turns
%into a newline; Bash 5.3 runs an EXIT trap when an untrapped TERM ends a script, and dash does not; a broken pipeline is found stage by stage, withPS4adding the line to each trace.
Terms in this lesson
- lastpipe
- A Bash option, turned on with shopt -s lastpipe, that runs the last command of a pipeline in the current shell when job control is off, as it is in scripts, so a while loop at the end of a pipeline keeps the variables it sets.
- PS4
- The prompt Bash prints before each command that set -x traces. It defaults to a plus sign and a space and is expanded each time, so a value such as '+ ${BASH_SOURCE}:${LINENO}: ' adds the file and line of every traced command.
References
- Bash Reference Manual: Pipelines (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: The Shopt Builtin (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Special Parameters (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Word Splitting (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Conditional Constructs (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bourne Shell Builtins (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Exit Status (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: The Set Builtin (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bash Variables (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Command Language (The Open Group and IEEE)
- POSIX.1-2024: Definitions (Filename, Pathname) (The Open Group and IEEE)
- POSIX.1-2024: test (The Open Group and IEEE)
- POSIX.1-2024: find (The Open Group and IEEE)
- POSIX.1-2024: xargs (The Open Group and IEEE)
- POSIX.1-2024: Utility Conventions (The Open Group and IEEE)
- POSIX.1-2024: sed (The Open Group and IEEE)
- POSIX.1-2024: awk (The Open Group and IEEE)
- POSIX.1-2024: crontab (The Open Group and IEEE)
- crontab(5): files used to schedule the execution of programs (Linux man-pages project (cronie))
- ShellCheck wiki (ShellCheck project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress