Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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

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:

Interactive · stage by stage Five steps of a strong answer Step through the answer to one question, from restating it to the fix.
Five steps of a strong answer, step 5 of 5. Showing Restate it, Test in two lines, Name the rule, Name the trap, Give the fix. In focus: Give the fix.Restate itwhat is really askedTest in two linesa run, not a memoryName the ruleand where it is writtenName the trapthe case that bitesGive the fixtwo ways if there are two

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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?
  2. 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.
  3. 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.
  4. 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.
  5. 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:

lastpipe.sh: the counter in a script, with lastpipe on Shell · lastpipe.sh
#!/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"
A counter in a piped loop, a fed loop, a script and dash Python · counter_session.py
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:

Interactive · message by message Who holds n in a pipeline Step through the two versions and watch where n changes.
Who holds n in a pipeline, step 4 of 4. Messages so far: Your shell to while loop: a copy of n; printf to while loop: a b c; printf to Your shell: a b c.Your shelln=3printfwhile loopnot useda copy of na b ca b c

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. Question 1 of 2 In the five steps of a strong answer, what comes right after restating the question?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A two-line test that shows the behaviour

    Restate, test in two lines, name the rule and its source, name the trap, give the fix.

  2. Question 2 of 2 At an interactive Bash prompt, which change makes n print 3 after a loop that reads the lines a, b and c?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Feeding the loop with done < <(printf '%s\n' a b c) instead of a pipe

    The loop at the end of a pipeline runs in a subshell. A redirection keeps it in the current shell; lastpipe does the same only in a script, where job control is off.

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:

Loops over names, an empty test and an unquoted star Python · quoting_session.py
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

  1. Question 1 of 2 A folder holds my report.txt and notes.txt. How many times does for f in $(ls); do echo "[$f]"; done run its body?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Three times

    Unquoted command substitution is split at the characters of IFS; the glob * needs no ls and is not split.

  2. Question 2 of 2 With x='', which of these tests is false?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [ -n "$x" ]

    The session printed "the test said non-empty" for [ -n $x ] and "the quoted test said empty" for the quoted form.

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:

bash, source, BASH_SOURCE, 126 and 127 Python · running_session.py
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

  1. Question 1 of 2 setdir.sh runs cd sub and sets favourite=blue. After source setdir.sh, what did the next pwd print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ~/demo/sub

    The session showed favourite=unset after bash setdir.sh and favourite=blue after source setdir.sh.

  2. Question 2 of 2 A job log shows exit status 126 for ./deploy.sh. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The file exists but cannot be executed, for example because it has no execute permission

    Both 126 and 127 come from the shell before any program runs, which makes them the most useful statuses to report as they are.

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:

set -e in a function left of ||, and a script that ends with && Python · errexit_session.py
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

  1. Question 1 of 2 Under set -e, a function f runs false and then echo ok. What does f || echo "f failed" print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ok

    deploy.sh printed "step 2 ran although false failed" and "status of the || list: 0".

  2. Question 2 of 2 Why did ends-with-and.sh exit with status 1 when there was no app.lock?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its last command was the && list, and the failed test made that list return 1

    End such a script with if [ -f app.lock ]; then rm app.lock; fi, or with rm -f app.lock.

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:

Four awkward names through find, xargs and cat Python · names_session.py
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

  1. Question 1 of 2 Four files, one with a newline in its name. How many lines does find . -name '*.log' | sort print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 5

    With -print0, sort -z and xargs -0 the session printed four bracketed names, one of them over two lines.

  2. Question 2 of 2 Which command prints the file named -n.log?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: cat -- -n.log

    Use -- or a path such as ./-n.log, which does not start with a dash.

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:

Four sed and awk one-liners Python · oneliners_session.py
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

  1. Question 1 of 2 For the lines asha, ravi, asha, meera, ravi, asha, what does awk '!seen[$0]++' print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: asha, ravi, meera

    seen[$0]++ returns the old count, so !seen[$0]++ is true only on the first sight of each line.

  2. Question 2 of 2 Which command prints the lines between BEGIN and END without the two marker lines?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: awk '/^BEGIN$/ { inside = 1; next } /^END$/ { inside = 0 } inside'

    The session printed port=8080 and workers=4 with awk, and the same lines with both markers with sed.

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:

An EXIT trap and a TERM signal in Bash and dash Python · traps_session.py
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

  1. Question 1 of 3 How many times on a Wednesday does the line */15 9-17 * * 1-5 /opt/jobs/report.sh run?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: 36

    POSIX ranges are inclusive. The step */15 is an extension that cronie documents; a strictly POSIX cron needs 0,15,30,45.

  2. Question 2 of 3 On which days does 0 9 1 * 1 run?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: On the 1st of every month and on every Monday

    To run only on a Monday that is the 1st, keep one day field and test the other inside the command.

  3. Question 3 of 3 A script with trap 'echo cleanup ran' EXIT sends itself TERM. Which shell printed cleanup ran?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Bash 5.3.20; dash 0.5.13.5 only once the script also had trap 'exit 143' TERM

    Every run ended with status 143, which is 128 plus 15, the number of TERM.

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:

Interactive · stage by stage Five checks for a broken script Step through the checks in order and note what each one can and cannot find.
Five checks for a broken script, step 5 of 5. Showing bash -n, ShellCheck, Run it, Stage by stage, Trace with PS4. In focus: Trace with PS4.bash -nsyntax onlyShellCheckreads, never runsRun itread status and stderrStage by stageshorten the pipelineTrace with PS4file and line

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

report.sh: the two busiest paths, with a bug Shell · report.sh
#!/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
A wrong field, a misspelled command and a trace with line numbers Python · debug_session.py
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

  1. Question 1 of 2 A five-stage pipeline prints the wrong thing. What finds the stage at fault fastest?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Running it with stages cut from the right and looking at each output

    cut -d' ' -f4 printed 200 and 200; cut -f3 printed /search and /cart.

  2. Question 2 of 2 What does PS4='+ ${BASH_SOURCE}:${LINENO}: ' change?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each line of a set -x trace starts with the file name and the line number

    The trace printed + typo.sh:3: gerp -c ERROR app.log for the failing line.

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.

  1. Question 1 of 17 Asked why n stays 0 after a piped while loop, which answer is strongest?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: A run that prints n=0, the manual's rule that pipeline commands run in subshells, the lastpipe catch at the prompt, and done < <(cmd) as the fix

    The five steps turn a known fact into an answer that survives the follow-up questions.

  2. Question 2 of 17 Why does a strong answer to a shell question include a two-line test?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: It shows the behaviour, so the explanation rests on a fact

    Every answer on this page begins from a recorded run for the same reason.

  3. Question 3 of 17 What does echo "$var" print when var holds a star?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: The star itself

    Unquoted, the session printed my report.txt notes.txt; quoted, it printed *.

  4. Question 4 of 17 Which loop over the files of a folder keeps every name whole?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: for f in *; do printf '%s\n' "$f"; done

    Use the glob for files in a folder, and find with -print0 for a whole tree.

  5. Question 5 of 17 A script prints $0. You run it with source from an interactive Bash. What does it print?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: The shell's own name, bash

    ${BASH_SOURCE[0]} gave ~/demo/tools in every case, which is why scripts use it to find their folder.

  6. Question 6 of 17 Which status does Bash return for a command name it cannot find?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: 127

    A script that reports 127 as it is tells its caller to look for a missing program or a wrong PATH.

  7. Question 7 of 17 Under set -e, without pipefail, a script runs false | true and then echo after. Does after print?

    Choose one answer.

    Show the answer: Question 7 of 17

    Answer: Yes: a failing stage before the last does not stop the script

    set -o pipefail makes such a pipeline return the failing status, which set -e then sees.

  8. Question 8 of 17 Which last line keeps a script's exit status 0 when there is no lock file?

    Choose one answer.

    Show the answer: Question 8 of 17

    Answer: if [ -f app.lock ]; then rm app.lock; fi

    rm -f app.lock is the shortest ending: it returns 0 whether or not the file exists.

  9. Question 9 of 17 For four .log files, how many times does find . -name '*.log' -exec sh -c '…' sh {} + start sh?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: Once

    The session printed "one run, 4 names" with + and four lines of "one run, 1 names" with \;.

  10. Question 10 of 17 A folder may hold a file named -rf.log. Which line removes every .log file safely?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: rm -- *.log

    Globs produce names, not safe arguments: -- or a ./ prefix such as ./*.log makes them safe.

  11. Question 11 of 17 Which command prints the tenth line of a large file and stops reading right after it?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: sed -n '10{p;q;}'

    All of them printed line 10; only the one that quits saves reading the rest of a large file.

  12. Question 12 of 17 What does awk '{ total += $2 } END { print total }' print for the lines a.log 120, b.log 75 and c.log 305?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: 500

    The session printed 500.

  13. Question 13 of 17 A crontab line runs tar -czf /backup/site-$(date +%F).tgz /srv/site. What goes wrong?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: Cron ends the command at the first %, so sh gets a cut-off command; write \% instead

    With date +\%F the command reaches sh whole.

  14. Question 14 of 17 In a crontab line, what is the third field?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: The day of the month

    The command follows the five time fields, and cron runs it with sh.

  15. Question 15 of 17 A script for /bin/sh must clean up when it ends normally and when it is stopped with TERM. Which lines does it need?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: trap cleanup EXIT and trap 'exit 143' TERM

    With the TERM handler, dash printed cleanup ran and still ended with status 143.

  16. Question 16 of 17 What does bash -n script.sh check?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: The syntax only, without running anything

    Syntax first, then ShellCheck, then a real run: each step finds what the one before it cannot.

  17. Question 17 of 17 typo.sh prints gerp: command not found on line 3, then end. It has no set -e. What is its exit status?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: 0

    Read the error messages as well as the status: the session ended with 0 after a command was not found.

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.

Two arguments through $@ and $* Python · args_session.py
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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. Does an EXIT trap run when an untrapped TERM ends the script?

    In Bash 5.3 it does; in dash it does not.

  7. 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 empty x and turned * into file names.
  • source runs a file in the current shell, ${BASH_SOURCE[0]} finds a script’s folder, 126 and 127 mean not executable and not found, and set -e does nothing inside a function called from ||.
  • File names need -print0 with xargs -0 and -- before a leading dash, and one-liners such as awk '!seen[$0]++' and sed -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, with PS4 adding 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.