The 60 shell scripting questions interviewers actually ask, with direct answers, runnable Bash, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
A shell script is a file that lists commands for a command-line interpreter to run in sequence, which turns a set of manual terminal steps into one repeatable program. The shell reads each line, expands variables and globs, and runs the resulting commands, so scripting is really the same shell you type into, saved to a file and made executable. Bash (the Bourne Again Shell) is the default interactive shell on most Linux distributions, and its behavior is defined in the GNU Bash Reference Manual, the canonical source these answers follow. In interviews, shell questions probe the parts that bite in real scripts: quoting and word splitting, exit codes and error handling, variable expansion, conditionals and loops, and the text-processing trio of grep, sed, and awk. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, the Bash manual is the reference to keep open; increasingly the first round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Bash Scripting Tutorial for Beginners
Video: Bash Scripting Tutorial for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Shell Scripting certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
A shell script is a text file of shell commands that the interpreter runs in order. It turns a set of manual terminal steps into one repeatable program you can run, schedule, and version.
Teams use them for automation that lives close to the system: backups, log rotation, deployment steps, batch file operations, and glue between other command-line tools. The shell is already on every Linux box, so there's nothing extra to install.
Key point: A one-line definition plus one concrete automation example beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Shell Scripting Crash Course: Beginner Level (Traversy Media, YouTube)
The shebang is the first line, #!/bin/bash, that tells the kernel which interpreter should run the file. Without it, the script runs under whatever shell the caller happens to use, which can change behavior.
It's only honored when you execute the file directly (./script.sh). Running it as bash script.sh ignores the shebang because you've already named the interpreter.
#!/bin/bash
# using /usr/bin/env finds bash on PATH, which is more portable
#!/usr/bin/env bash
echo "Running under: $BASH_VERSION"Key point: #!/usr/bin/env bash for portability matters.
Double quotes allow variable expansion and command substitution while preventing word splitting and globbing. Single quotes are fully literal: nothing inside expands, so $HOME stays the text $HOME.
The practical rule: reach for double quotes around variables so values with spaces stay intact, and use single quotes when you want the text exactly as written.
name="Asha"
echo "Hello $name" # Hello Asha
echo 'Hello $name' # Hello $name
echo "Today is $(date +%A)" # command substitution works in double quotes| Single quotes | Double quotes | |
|---|---|---|
| Variable expansion | No | Yes |
| Command substitution | No | Yes |
| Word splitting | Prevented | Prevented |
| Typical use | Literal text, regex | Values that hold spaces |
Key point: The follow-up is usually 'why quote variables at all?'. Have the filename-with-spaces example ready.
Assign with name=value and no spaces around the equals sign, then read it with $name or ${name}. Everything is a string by default; there are no separate number or boolean types.
Spaces around = are the classic beginner error: x = 5 tries to run a command called x. Use the braces form ${name} when the name touches other text.
count=3
greeting="hello"
echo "${greeting}, ${count} times"
# common mistake: this is NOT assignment
# count = 3 -> tries to run the command "count"Key point: The no-spaces rule around = comes up constantly. State it before you're asked.
Positional parameters hold the arguments passed to a script or function. $1 is the first argument, $2 the second, $0 is the script name, $# is the argument count, and $@ is all arguments.
Quote "$@" when passing arguments along, because it preserves each argument as a separate word even when values contain spaces. Unquoted $* joins everything into one string.
#!/bin/bash
echo "Script: $0"
echo "First arg: $1"
echo "Count: $#"
for arg in "$@"; do
echo "arg: $arg"
doneKey point: The distinction between "$@" and "$*" is a favorite. $@ keeps arguments separate, $* joins them.
Every command returns an exit status: 0 for success, any non-zero value for a specific failure. The special variable $? holds the status of the last command that ran.
Scripts use exit status to make decisions: run the next step only if the previous one succeeded. Your own scripts should exit non-zero on failure so callers can detect it.
grep -q "error" app.log
if [ $? -eq 0 ]; then
echo "found an error line"
fi
# cleaner: test the command directly
if grep -q "error" app.log; then
echo "found an error line"
fiKey point: Reading $? works, but testing the command directly in the if is the idiom interviewers prefer.
if runs a command and branches on its exit status; the [ ] test command (or the Bash [[ ]] keyword) evaluates a condition and returns 0 or non-zero. elif and else add branches, and fi closes the block.
Numeric comparisons use -eq, -lt, -gt; string comparisons use = and !=. Spaces inside the brackets are required, another common early bug.
score=72
if [ "$score" -ge 90 ]; then
echo "A"
elif [ "$score" -ge 70 ]; then
echo "B"
else
echo "needs work"
fifor iterates over a list of words or a glob; while runs while a command succeeds; until runs while a command fails. C-style for ((i=0; i<5; i++)) also works in Bash.
The most common pattern is looping over files with a glob. Guard against no matches with nullglob or a test, because an unmatched glob stays literal by default.
for file in *.log; do
echo "processing $file"
done
i=0
while [ "$i" -lt 3 ]; do
echo "count $i"
i=$((i + 1))
doneThe read builtin captures a line from standard input into one or more variables. read -p shows a prompt, read -s hides typing (for passwords), and read -r stops backslashes from being interpreted.
Almost always use read -r; the plain form mangles backslashes, which surprises people processing file paths.
read -rp "Enter your name: " name
echo "Hi, $name"
read -rsp "Password: " pass
echo
echo "captured ${#pass} characters"Command substitution runs a command and replaces the expression with its standard output. The modern form is $(command); the older backtick form works but nests poorly and is harder to read.
It's how you capture output into a variable or feed one command's result into another line, like storing today's date or a file count.
today=$(date +%Y-%m-%d)
file_count=$(ls -1 | wc -l)
echo "On $today there are $file_count files"Key point: Preferring $() over backticks, and knowing backticks don't nest cleanly, is a small signal of real fluency.
echo prints arguments with a trailing newline and is fine for simple messages, but its handling of flags and escape sequences varies between shells and systems. printf gives you a format string with predictable escapes and field formatting.
For anything beyond a plain line, especially formatted numbers or reliable escapes across systems, printf is the safer choice.
echo "simple line"
printf "%-10s %5.2f\n" "price" 9.5
printf "%s\n" "one" "two" "three" # each on its own lineGive the file execute permission with chmod +x script.sh, then run it as ./script.sh. Without execute permission you can still run it explicitly with bash script.sh.
The ./ prefix matters because the current directory usually isn't on PATH, which is a deliberate safety default so you don't run a stray script by name.
chmod +x deploy.sh
./deploy.sh # runs via the shebang
bash deploy.sh # runs regardless of the execute bitStrings use = and != (and < , > inside [[ ]] for lexical order). Numbers use -eq, -ne, -lt, -le, -gt, -ge. Mixing them up, like using -eq on strings, is a frequent bug.
Always quote string variables in a test so an empty value doesn't break the syntax. Inside [[ ]] the quoting is more forgiving, which is one reason it's preferred in Bash.
name="admin"
if [ "$name" = "admin" ]; then echo "match"; fi
n=5
if [ "$n" -gt 3 ]; then echo "big enough"; fi| Compare | String operator | Number operator |
|---|---|---|
| Equal | = | -eq |
| Not equal | != | -ne |
| Greater than | > (in [[ ]]) | -gt |
| Less than | < (in [[ ]]) | -lt |
File test operators check attributes of a path: -f (regular file exists), -d (directory), -e (exists at all), -r / -w / -x (readable, writable, executable), and -s (exists and is non-empty).
They're how scripts decide whether to read a config, create a directory, or bail out early. Combine them with && or nest them in an if.
config="/etc/app/config.yml"
if [ -f "$config" ]; then
echo "loading $config"
else
echo "no config, using defaults"
fi
[ -d "./logs" ] || mkdir logsWrap integer math in $(( )): result=$((3 + 4)). Inside the double parentheses you don't need $ on variable names, and you get the usual operators plus ++ and +=.
The shell only does integer math natively. For decimals, pipe through bc or use awk, since $(( )) truncates.
a=7
b=2
echo $((a + b)) # 9
echo $((a / b)) # 3 (integer division)
echo "scale=2; $a/$b" | bc # 3.50 (decimals via bc)Key point: $(( )) is integer-only, and reaching for bc or awk for decimals,.
A pipe (|) sends one command's standard output into the next command's standard input, chaining tools into a stream. Redirection sends a stream to or from a file: > overwrites, >> appends, < reads input, and 2> captures errors.
This composition is the heart of shell work: small tools connected by pipes solve problems no single tool does alone.
cat access.log | grep "404" | wc -l # count 404s
sort names.txt | uniq > unique.txt # dedupe to a file
command 2> errors.log # capture only errorsWatch a deeper explanation
Video: Beginner's Guide to the Bash Terminal (Joe Collins (EzeeLinux), YouTube)
Every process has three standard streams: stdin (0) for input, stdout (1) for normal output, and stderr (2) for error messages. Keeping errors on a separate stream lets you log or filter them independently of results.
That's why 2>&1 exists: it merges stderr into stdout so both go to the same place, useful when redirecting all output of a command to one log.
ls /missing 2> err.txt # errors to a file, results to screen
command > out.log 2>&1 # both streams to one file
echo "oops" >&2 # write to stderr from your scriptgrep searches text for lines matching a pattern and prints them. Common flags: -i (ignore case), -v (invert, show non-matches), -r (recurse directories), -n (show line numbers), and -c (count).
grep -E turns on extended regex for alternation and grouping; grep -F treats the pattern as a fixed string, which is faster and avoids regex surprises when searching for literal text.
grep -in "error" app.log # case-insensitive, with line numbers
grep -rc "TODO" ./src # count matches per file, recursively
grep -Ev "^#|^$" config.conf # skip comments and blank linesWatch a deeper explanation
Video: Bash Tutorial: How to Use the Command Line in Linux, Windows, and Mac Terminal (freeCodeCamp.org, YouTube)
Globbing is the shell's filename expansion: * matches any run of characters, ? matches one character, and [abc] matches a character set. The shell expands globs before the command runs.
Globs are not regular expressions. * in a glob means 'any characters', but in regex it means 'zero or more of the previous item'. Confusing the two is a frequent source of bugs when moving between file matching and grep patterns.
ls *.txt # all files ending in .txt
ls report-?.csv # report-1.csv, report-a.csv, one char
ls img[0-9].png # img0.png through img9.pngKey point: The 'glob star vs regex star' distinction is a small trap that catches people who only know one context.
Environment variables are name-value pairs passed to child processes. A plain assignment stays local to the current shell; export makes it visible to programs the shell launches. PATH, HOME, and USER are familiar examples.
Setting VAR=value command runs one command with that variable set, without changing your session, which is handy for one-off overrides.
export API_ENV="staging" # visible to child processes
app_run # sees API_ENV
DEBUG=1 app_run # set for just this one command$$ is the process id of the current shell, handy for unique temp names or logging. $! is the process id of the last command started in the background, which you use to wait on or signal it. $- lists the current shell option flags.
These sit alongside the argument variables ($0, $#, $@) and the exit status ($?). Knowing $! is what lets a script launch a background job and manage it later.
echo "shell pid: $$"
sleep 30 &
bg_pid=$!
echo "started background job $bg_pid"
kill "$bg_pid" # stop it using $!Use ${var:-default}, which yields default when var is unset or empty but doesn't change var. ${var:=default} also assigns the default back to var. The colon makes empty count as unset; drop it to treat only truly-unset as missing.
This is the clean way to handle optional arguments and environment overrides without a separate if block.
env="${APP_ENV:-development}" # use APP_ENV, else development
echo "running in $env"
: "${PORT:=8080}" # assign default back to PORT
echo "port is $PORT"Key point: Knowing the difference between :- (use default) and := (assign default) is the follow-up. Have both ready.
Define with name() { ... } and call it by name, passing arguments that appear inside as $1, $2, and so on. Functions share the script's variables unless you mark them local.
A function returns an exit status with return (0 to 255), not a value. To hand back data, echo it and capture with command substitution.
greet() {
local who="$1"
echo "Hello, $who"
}
greet "Asha"
message=$(greet "Ben") # capture the output
echo "$message"Key point: Knowing a function returns a status, not a value, and that you echo results, separates people who've written real scripts from those who haven't.
For candidates with working experience: text processing, safer scripting, and the mechanics that separate users from understanders.
sed is a stream editor: it reads text line by line and applies edits, most often substitution with s/pattern/replacement/. It's the go-to for search-and-replace across a stream or file without opening an editor.
The g flag replaces every match on a line, not just the first; -i edits files in place; and & in the replacement re-inserts the matched text. sed works on whole streams, so it fits naturally in pipes.
echo "hello world" | sed 's/world/there/' # hello there
sed -i 's/localhost/127.0.0.1/g' config.conf # replace in place, every match
sed -n '10,20p' big.log # print only lines 10 to 20Key point: The g flag and -i are the two things interviewers check you know. Volunteer both.
Watch a deeper explanation
Video: Shell Scripting Tutorial (Derek Banas, YouTube)
awk is a text-processing language built around fields and records. It splits each line into fields ($1, $2, ...) and runs pattern-action rules, which makes column extraction, filtering, and quick aggregation one-liners.
Reach for awk when the data is columnar and you need arithmetic or grouping. It handles sums, averages, and per-key totals that grep and sed can't.
awk '{ print $1, $3 }' data.txt # print columns 1 and 3
awk -F',' '$2 > 100 { print $1 }' sales.csv # comma-separated, filter on col 2
awk '{ total += $1 } END { print total }' nums.txt # sum a columnKey point: The END block for totals is the awk feature that most impresses. Show it.
Watch a deeper explanation
Video: Bash Scripting Full Course 3 Hours (linuxhint, YouTube)
Use grep to find and print lines that match. Use sed to transform text, mostly substitution and line edits. Use awk when data is columnar and you need fields, arithmetic, or grouping.
They overlap, so the honest answer is about the simplest tool that fits: don't reach for awk to do a plain search, and don't chain five greps when one awk rule is clearer.
| Task | Tool | Why |
|---|---|---|
| Find matching lines | grep | Fast, purpose-built for search |
| Search and replace | sed | Stream substitution with s/// |
| Column extraction | awk | Built-in field splitting |
| Sums and per-key totals | awk | Arithmetic and associative arrays |
Key point: Interviewers love the 'simplest tool that fits' framing. It shows judgment, not just recall.
set -e exits on the first unhandled command failure, set -u treats unset variables as errors, and set -o pipefail makes a pipeline fail if any command in it fails, not just the last. Together they turn silent failures into loud, early exits.
It's the common safety header for production scripts. The caveat worth naming: set -e has surprising exceptions (commands in conditionals, for example), so it's a strong default, not a substitute for explicit error handling.
#!/usr/bin/env bash
set -euo pipefail
# now a typo in a variable name aborts instead of expanding to empty
# and a failing curl in a pipe stops the script
curl -fsS "$URL" | tar -xzKey point: Knowing that set -e has exceptions (it won't fire inside an if condition) is the senior-sounding detail.
Bash indexed arrays are declared with arr=(a b c) and accessed by index ${arr[0]}. Expand all elements safely with "${arr[@]}" and get the count with ${#arr[@]}.
Associative arrays (declare -A) map string keys to values, which is Bash's dictionary. Both are Bash-only; plain POSIX sh has no arrays, worth flagging if portability matters.
fruits=("apple" "ripe banana" "cherry")
echo "${fruits[1]}" # ripe banana
echo "count: ${#fruits[@]}" # count: 3
for f in "${fruits[@]}"; do echo "$f"; done
declare -A ages=([asha]=31 [ben]=26)
echo "${ages[asha]}" # 31Parameter expansion is the ${...} syntax that transforms a variable without calling external tools. It provides defaults, substring extraction, prefix and suffix removal, and search-and-replace, all inside the shell.
Using it instead of piping to sed or cut is faster and avoids a subprocess. The pattern operators (#, ##, %, %%) trim from the front or back, greedy or not.
path="/var/log/app.log"
echo "${path##*/}" # app.log (strip longest leading */)
echo "${path%.log}" # /var/log/app (strip .log suffix)
name="${1:-default}" # use $1, or "default" if unset/empty
upper="${name^^}" # uppercase (Bash 4+)Key point: Trimming a filename with ${var##*/} instead of basename shows you know the shell can do more than glue tools.
A heredoc feeds a block of literal text to a command's standard input, ending at a chosen marker. It's how you embed multi-line content: a config file, an SQL query, an email body, without escaping each line.
Quote the opening marker ('EOF') to stop variable expansion inside the block; leave it unquoted to expand variables. The <<- variant strips leading tabs so you can indent the block.
cat > config.ini <<EOF
[app]
env=$APP_ENV
port=8080
EOF
# quoted marker keeps text literal
cat <<'EOF'
This $variable is not expanded.
EOFtrap runs a command when the script receives a signal or hits a pseudo-event. The common use is trap 'cleanup' EXIT, which guarantees a cleanup function runs whether the script finishes normally, errors out, or is interrupted.
It's how you remove temp files, release locks, or restore state reliably. Catching INT and TERM lets a long-running script shut down gracefully on Ctrl+C or a kill.
tmp=$(mktemp)
cleanup() { rm -f "$tmp"; }
trap cleanup EXIT
# use $tmp freely; it is removed on any exit path
echo "work" > "$tmp"Key point: trap ... EXIT for cleanup is the answer that indicates production experience. Bare temp files without it read as junior.
Running (./script.sh or bash script.sh) starts a new child shell, so any variables or directory changes vanish when it ends. Sourcing (source script.sh or . script.sh) runs the lines in your current shell, so its changes persist.
That's why you source environment files and activation scripts but execute normal programs. Sourcing a script that calls exit will close your current shell, a surprise worth knowing.
# env.sh sets DEPLOY_KEY
source env.sh # DEPLOY_KEY now set in your shell
. env.sh # same thing, POSIX form
./env.sh # runs in a child; DEPLOY_KEY does NOT persistKey point: The 'changes persist vs vanish' framing plus the exit gotcha is the complete answer.
cut extracts columns by delimiter or character position; tr translates or deletes characters; sort orders lines; uniq collapses adjacent duplicate lines. Chained together they cover a lot of quick data shaping.
The key gotcha: uniq only removes adjacent duplicates, so you almost always sort first. sort -u does both in one step.
cut -d',' -f1,3 data.csv # columns 1 and 3
tr 'a-z' 'A-Z' < file.txt # uppercase everything
sort access.log | uniq -c | sort -rn # frequency count, most common firstKey point: Explaining that uniq needs sorted input is the detail that catches people who copied the one-liner without understanding it.
xargs reads items from standard input and builds them into arguments for another command. It bridges tools that produce a list (like find) with tools that take arguments (like rm or grep).
Use find ... -print0 | xargs -0 to handle filenames with spaces or newlines safely. -n limits arguments per call and -P runs calls in parallel.
find . -name '*.tmp' -print0 | xargs -0 rm # safe delete, handles spaces
echo "1 2 3 4" | xargs -n2 echo # echo 1 2 / echo 3 4
find . -name '*.log' | xargs -P4 gzip # gzip 4 files at a timefind walks a directory tree and matches by name, type, size, time, or permission, then acts on the matches. -name globs the filename, -type f or d filters kind, -mtime filters by age, and -exec or a pipe to xargs runs a command per match.
-exec cmd {} + batches matches into fewer command calls, which is faster than the one-per-file + \; form for large trees.
find /var/log -name '*.log' -mtime +7 -delete # logs older than 7 days
find . -type f -size +100M # files over 100 MB
find . -name '*.py' -exec grep -l 'TODO' {} + # files containing TODOcase matches a value against patterns, which reads far cleaner than a long if/elif chain when branching on one variable. Each branch is a glob-style pattern ending in ;;, and *) is the catch-all default.
It's the standard shape for parsing a command argument or a menu choice, and patterns can use |, wildcards, and character classes.
case "$1" in
start) echo "starting" ;;
stop) echo "stopping" ;;
restart) echo "restarting" ;;
*) echo "usage: $0 {start|stop|restart}" ;;
esacCheck exit status explicitly where you need to react: cmd || { echo 'failed' >&2; exit 1; }. Group cleanup in a trap on EXIT. Validate inputs early and exit with a clear message rather than letting a later command fail cryptically.
For a pipeline, remember only the last command's status is $? unless pipefail is set. Writing errors to stderr (>&2) keeps them out of captured output.
deploy() {
cp "$1" /opt/app/ || { echo "copy failed" >&2; return 1; }
systemctl restart app || { echo "restart failed" >&2; return 1; }
}
deploy "build.tar" || exit 1A reliable error-handling pattern
The technical decision depends on whether failures are loud and cleanup is guaranteed, not whether you memorized every flag.
[ ] is the POSIX test command, portable but prone to word-splitting problems with unquoted variables. [[ ]] is a Bash keyword with safer parsing, pattern matching, regex via =~, and && / || inside. (( )) is for integer arithmetic and numeric comparisons.
In Bash scripts, use [[ ]] for string and file tests and (( )) for math. Fall back to [ ] only when the script must run under plain POSIX sh.
name="admin user"
[[ "$name" == admin* ]] && echo "starts with admin" # pattern match
[[ "$email" =~ ^[^@]+@[^@]+$ ]] && echo "looks like an email"
((count = 3 + 4))
((count > 5)) && echo "big"| Construct | Use for | Portable to POSIX sh |
|---|---|---|
| [ ] | Basic string and file tests | Yes |
| [[ ]] | Safe tests, patterns, regex | No (Bash/Zsh) |
| (( )) | Integer arithmetic and comparison | No (Bash/Zsh) |
Process substitution, <(command), makes a command's output look like a file, so you can feed the output of one command where a filename is expected. The classic use is comparing two commands' output with diff without temp files.
It's a Bash feature (not POSIX sh) implemented with named pipes. It's the clean way to diff, join, or paste live command output.
diff <(sort file1.txt) <(sort file2.txt) # compare sorted output, no temp files
while read -r line; do
echo "got: $line"
done < <(grep "error" app.log)Key point: Diffing two sorted streams with <( ) is the example interviewers recognize instantly.
cron is the classic scheduler: a crontab line has five time fields (minute, hour, day of month, month, day of week) followed by the command. crontab -e edits your jobs, and each line runs the command on that schedule.
Two production tips worth naming: cron runs with a minimal environment and PATH, so use absolute paths, and redirect output to a log or you'll miss errors. systemd timers are the modern alternative on many systems.
# run backup.sh every day at 2:30 AM, log output
30 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# every 15 minutes
*/15 * * * * /usr/local/bin/healthcheck.shKey point: The minimal-environment gotcha (absolute paths, no interactive PATH) is what separates people who've been burned by cron from those who haven't.
By default every variable in a shell script is global, so a variable set inside a function leaks out and can clobber one with the same name elsewhere. Declaring it with local scopes it to the function.
In any script with functions, local is the difference between reusable functions and mysterious action-at-a-distance bugs. It's Bash-specific but widely available.
count=100
bump() {
local count=0 # without local, this would overwrite the outer count
count=$((count + 1))
echo "$count"
}
bump # 1
echo "$count" # 100, unchangedRun it with bash -x script.sh (or add set -x inside) to trace every command with its expanded values, which shows exactly what the shell saw after expansion. set -v prints lines before expansion. bash -n does a syntax check without running.
For targeted tracing, wrap a section between set -x and set +x. Combined with set -u to catch unset variables, this finds most bugs quickly.
bash -x ./deploy.sh # trace the whole run
# trace just one section
set -x
risky_step "$arg"
set +x
bash -n ./deploy.sh # syntax check onlyKey point: Naming set -x for tracing and bash -n for a dry syntax check covers the two questions that follow this one.
Use while IFS= read -r line; do ...; done < file. IFS= keeps leading and trailing whitespace, -r stops backslash mangling, and reading from a redirect avoids loading the whole file into memory.
Avoid for line in $(cat file): it splits on all whitespace, not newlines, and breaks on spaces and globs. The while-read idiom is the answer the question expects.
while IFS= read -r line; do
echo "processing: $line"
done < input.txt
# the anti-pattern that splits on spaces and expands globs:
# for line in $(cat input.txt); do ...; doneKey point: Rejecting for line in $(cat ...) and giving the while IFS= read -r idiom is one of the most reliable signals of shell competence.
advanced rounds probe resilience, portability, and production scars. Expect every answer here to draw a follow-up.
Target the POSIX shell feature set: use #!/bin/sh, avoid Bash-only constructs (arrays, [[ ]], process substitution, ${var^^}), and test with a strict shell like dash. Stick to [ ], printf over echo -e, and parameter expansion that POSIX defines.
The honest trade-off to name: portability costs convenience. Many teams standardize on Bash and #!/usr/bin/env bash instead, because the machines they run on all have it. Portability matters most for install scripts and containers with minimal shells.
| Bash-only | POSIX-portable alternative |
|---|---|
| arr=(a b c) | Space-separated string or positional params |
| [[ x == y* ]] | case statement or [ ] with grep |
| <(command) | Temp file with trap cleanup |
| ${var^^} | tr '[:lower:]' '[:upper:]' |
Key point: Naming dash as the strict test shell, and that many shops just standardize on Bash, is the balanced production-ready answer.
After expansion, the shell splits unquoted results on the characters in IFS (space, tab, newline by default) and then expands globs. So an unquoted $file holding 'my report.txt' becomes two arguments, and a value containing * expands to matching filenames.
Quoting expansions ("$file", "$@", "${arr[@]}") turns off splitting and globbing, keeping each value intact. This one habit prevents the majority of real-world shell bugs, which is exactly why linters like shellcheck flag every unquoted expansion.
file="my report.txt"
rm $file # tries to remove "my" and "report.txt" (two args)
rm "$file" # removes the one file, correct
# IFS controls splitting; change it deliberately, restore it after
OLD_IFS=$IFS; IFS=","
read -ra parts <<< "a,b,c"
IFS=$OLD_IFSKey point: Connecting word splitting to shellcheck's quote-everything rule shows you've adopted the tooling, not just the theory.
shellcheck is a static analyzer for shell scripts. It catches unquoted expansions, useless use of cat, wrong test operators, portability issues, and dozens of other bug classes before the script ever runs.
In a real workflow it runs in CI and in the editor, so problems surface at write time. Treating its warnings as errors is the fastest way to raise a team's shell quality, and it matters.
shellcheck deploy.sh # lint a single script
# disable one check on one line, with a reason
# shellcheck disable=SC2086 # word splitting is intentional here
rm $filesKey point: Bringing up shellcheck in practice is a strong signal. Most under-prepared candidates never mention tooling.
Trap the signals you care about: INT (Ctrl+C), TERM (default kill and container stop), and HUP. In the handler, stop accepting new work, finish or abort in-flight work, release resources, and exit with a status that reflects the signal.
For a script that manages child processes, forward the signal to the children and wait for them, otherwise orphaned processes linger. In containers, handling TERM is what lets your process shut down cleanly before the runtime sends KILL.
running=true
shutdown() { echo "draining..."; running=false; }
trap shutdown INT TERM
while $running; do
process_one_item
sleep 1
done
echo "clean exit"Key point: The container angle (TERM before KILL) is what makes this The production-ready answer rather than a textbook one.
Use mktemp, which creates a file (or directory with -d) with a random, unpredictable name and safe permissions, then returns its path. Never build a temp name from $$ or a fixed string, because that invites race conditions and predictable-name attacks.
Pair mktemp with a trap on EXIT to remove it on every exit path. For directories, mktemp -d gives a private workspace you clean up in one rm -rf.
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
curl -fsS "$URL" -o "$tmpdir/download.tar.gz"
tar -xzf "$tmpdir/download.tar.gz" -C "$tmpdir"
# everything is cleaned up automatically on exitKey point: The security angle (predictable names are a real vulnerability class) is why this question exists, not just tidiness.
A subshell is a child copy of the current shell, created by parentheses ( ... ), each side of a pipe, and command substitution. Changes to variables inside a subshell don't propagate back to the parent, which is a frequent surprise.
The classic bug: piping into a while loop runs the loop in a subshell, so counters set inside it are lost afterward. Fixes are process substitution or lastpipe. Spawning many subshells in a hot loop also costs measurable time versus a single tool call.
count=0
grep "x" file | while read -r _; do
count=$((count + 1)) # runs in a subshell
done
echo "$count" # 0, the increments were lost
# fix: process substitution keeps the loop in the current shell
while read -r _; do count=$((count + 1)); done < <(grep "x" file)
echo "$count" # correctKey point: The pipe-into-while-loop counter bug is a favorite trap. Recognizing it instantly marks real experience.
The shell does integer arithmetic only; $(( )) truncates. For decimals, pipe an expression to bc (with a scale for precision) or use awk, which has full floating-point built in and is usually the cleaner choice inside a pipeline.
For anything beyond a couple of calculations, that's often the sign to move the logic to a language with real numeric types rather than fighting bc syntax.
echo "scale=4; 22/7" | bc # 3.1428
average=$(awk "BEGIN { print (12.5 + 7.3) / 2 }")
echo "$average" # 9.9
# comparing decimals also needs awk or bc, not (( ))
awk "BEGIN { exit !(3.2 > 3.1) }" && echo "greater"getopts is the built-in for short options: it loops through flags, sets a variable to each option letter, and puts an argument in OPTARG when the option string marks it with a colon. It handles bundling and stops at the first non-option.
For long options (--verbose), getopts doesn't help; teams either use a manual while/case loop or an external getopt. Naming that limitation shows you've built a real CLI, not just a demo.
verbose=0
output=""
while getopts ":vo:" opt; do
case "$opt" in
v) verbose=1 ;;
o) output="$OPTARG" ;;
\?) echo "unknown option: -$OPTARG" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1)) # drop parsed options, leave the restKey point: Knowing getopts is short-options-only, and how you'd handle long options, is the follow-up. Have it ready.
An idempotent script produces the same end state whether it runs once or many times: it checks before it acts (mkdir -p, test before create, append only if missing) instead of assuming a clean slate. Running it twice doesn't duplicate or break anything.
It matters for automation and configuration management, where scripts rerun on retries, cron, or partial failures. A non-idempotent deploy that appends a line every run is a classic production bug.
mkdir -p /opt/app/data # safe to run repeatedly
# add a line only if it is not already there
grep -qxF "export PATH=/opt/bin:\$PATH" ~/.bashrc \
|| echo "export PATH=/opt/bin:\$PATH" >> ~/.bashrcKey point: The 'append every run' anti-pattern is the concrete example the question needs. The it.
0 means success, 1 to 125 are application-defined failures, 126 means the command was found but not executable, 127 means command not found, and 128+n means the process was killed by signal n (so 130 is Ctrl+C, SIGINT). Codes above 255 wrap around.
Well-behaved scripts return distinct non-zero codes for distinct failure modes, so callers and monitoring can react differently. Reserving a small set of meaningful codes beats returning 1 for everything.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1-125 | Application-defined error |
| 126 | Found but not executable |
| 127 | Command not found |
| 128+n | Killed by signal n (130 = Ctrl+C) |
Stream it, never load it. Pipe through line-oriented tools (awk, sed, grep) that read one record at a time so memory stays flat regardless of file size. Prefer a single awk pass over chaining many tools, since each stage is another process reading the whole stream.
For CPU-bound work, split the file and process chunks in parallel with xargs -P or GNU parallel. And know when to stop: past a certain complexity, a streaming program in Python or Go is more maintainable than a heroic awk script.
# one streaming pass: count and sum in a single awk, no full load
awk '{ n++; total += $2 } END { print n, total }' huge.csv
# parallel processing of many files
find logs/ -name '*.gz' | xargs -P8 -I{} sh -c 'zcat {} | grep ERROR'Use a lock. flock ties an advisory lock to a file descriptor and releases it automatically when the script exits, which avoids stale locks from a plain if-file-exists check that a crash would leave behind. Wrapping the script in flock -n on a lock file is the clean pattern.
The manual mkdir-as-lock trick works because directory creation is atomic, but flock is safer because it self-releases. This comes up for cron jobs that might overlap when one run runs long.
#!/usr/bin/env bash
# re-exec under flock; -n fails fast if another copy holds the lock
[ "${FLOCKED:-}" ] || exec env FLOCKED=1 flock -n /tmp/job.lock "$0" "$@"
echo "only one instance runs this section"
sleep 30Key point: Preferring flock over a hand-rolled lockfile, because it self-releases on crash, is the detail that lands here.
The big ones: unquoted expansions that allow injection or unexpected globbing, passing untrusted input to eval or a shell, predictable temp file names, secrets in the script or in ps output, and a permissive PATH that lets an attacker shadow a command.
Defenses: quote everything, avoid eval on data you don't control, use mktemp, read secrets from the environment or a secret store rather than arguments (which are visible in ps), and set an explicit PATH at the top of privileged scripts.
Key point: Mentioning that command-line arguments are visible in ps, so secrets don't belong there, is the pitfall most people miss.
Switch when logic outgrows the shell's strengths: complex data structures, non-trivial parsing, math beyond integers, code that needs unit tests, error handling that must be precise, or a script pushing past a few hundred lines. Those are the signs the shell is fighting you.
The shell stays the right tool for gluing commands, file operations, and short automation. The senior instinct is recognizing the crossover early, before a 400-line Bash script becomes the thing nobody wants to touch.
Key point: the key point is you reach for the right tool, not defend Bash to the death.
Beyond signals, trap can fire on the ERR pseudo-signal (when a command fails under set -e) to print context, and on DEBUG before every command. A trap on ERR that reports the failing line and command turns a silent set -e exit into an actionable message.
Combining trap ERR with $LINENO and BASH_COMMAND gives you a lightweight backtrace, which is the kind of self-diagnosing script that saves hours in production.
#!/usr/bin/env bash
set -euo pipefail
trap 'echo "error on line $LINENO: $BASH_COMMAND" >&2' ERR
risky_command_that_might_fail
echo "reached only if the command above succeeded"Key point: A trap on ERR reporting $LINENO and $BASH_COMMAND is the answer that separates people who ship maintainable scripts.
eval takes a string, expands it a second time, and runs the result as a command. That extra expansion is exactly the danger: any untrusted content in the string can inject arbitrary commands, so eval on data you don't fully control is a security hole.
Legitimate uses are rare and narrow, like reifying dynamically named variables. Most cases people reach for eval are better solved with arrays, indirect expansion (${!name}), or restructuring the code so no second evaluation is needed.
user_input='x"; rm -rf /tmp/data"'
# eval "echo $user_input" # DANGER: runs the injected rm
# safer alternative for dynamic variable names: indirect expansion
key="HOME"
echo "${!key}" # prints the value of $HOME, no eval neededKey point: Offering indirect expansion ${!name} as the safe alternative to eval is what turns 'eval is bad' into The production-ready answer.
Shell scripting wins for gluing command-line tools together: file operations, running other programs, piping text, and short automation that lives close to the system. It loses the moment logic gets complex, data structures matter, or you need portability and tests, which is where Python fits better. Compiled programs are for performance-sensitive or long-lived services. Knowing where the shell stops being the right tool is itself an interview signal, because reaching for Bash on a 500-line data-processing job is a common mistake reviewers watch for.
| Tool | Best at | Data handling | Watch out for |
|---|---|---|---|
| Shell (Bash) | Gluing CLI tools, file ops, automation | Text streams, arguments | Quoting bugs, no real data types |
| Python | Logic-heavy scripts, data processing | Rich types, libraries | Heavier startup, more to install |
| Perl | Text munging, legacy sysadmin scripts | Strong regex, text focus | Declining ecosystem, read-only in many shops |
| Compiled (Go, C) | Long-running services, speed | Full type systems | Build step, slower to iterate |
Prepare in layers and practice at a real terminal. Most shell rounds move from concept questions to writing a small script live to debugging or a one-liner challenge, so rehearse each stage rather than only reading answers.
The typical shell scripting interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Shell Scripting questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works
Q12. How do you write comments in a shell script?
Anything after a # to the end of the line is a comment, except inside quotes and except the shebang on line one. There's no block-comment syntax.
A common trick for a multi-line block is a heredoc sent to the no-op colon command, but most scripts just prefix each line with #.