Keentune

Bash & Shell curriculum

22 chapters
·
218 concepts
·
free
Everything the adaptive question bank can teach and test in Bash & Shell, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the Bash & Shell guide
A free 17-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. What the shell is, and how a script runs
the kernel reads only the first line; #!/usr/bin/env bash finds bash on PATH
running a script forks a new shell; source/. runs it in the current one
./script needs chmod +x; bash script does not
# starts a comment only at the start of a word, never mid-word or inside quotes
the shell tokenizes the line into words first, then expands, then executes
different startup files run, which is why a cron script sees a different environment
type/command -V says whether a name is a keyword, alias, function, builtin or file
PATH is searched left to right; any name containing a slash skips the search entirely
bash caches resolved paths, so a moved binary needs hash -r
B. Quoting
everything inside is literal, and a single quote can never appear inside
$, backtick and \ still act, but word splitting and globbing do not
outside quotes \ escapes any character; inside double quotes only before $, backtick, ", \ or newline
a bare $var is word-split then globbed; "$var" is the fix, and the single most common bug
quotes are stripped in the final expansion step, so the executed command never sees them
$'...' interprets \n, \t, \x41; plain quotes do not
quoting inside $(...) is parsed independently of the surrounding quotes
quoting a glob stops the shell expanding it so the command receives the pattern verbatim
a newline between quotes is kept, producing one word containing a newline
C. Simple commands, pipelines and lists
words plus redirections; after expansion the first remaining word is the command name
| connects stdout only; |& also routes stderr into the next stage
a pipeline reports the LAST stage's status, so a failing head goes unnoticed
PIPESTATUS holds every stage's status and is overwritten by the next command
each stage runs in its own subshell, so assignments made there are lost
&& runs on success, || on failure; equal precedence, left to right
& returns 0 immediately and puts the pid in $!
! cmd inverts the status and is a reserved word, so it needs a following space
time is shell syntax, which is why it can time a whole pipeline
D. The expansion order, and word splitting
brace, tilde, parameter/arithmetic/command substitution, word splitting, filename expansion, quote removal
an expansion's result is not rescanned for $, so nested variables need ${!x} or eval
splitting applies only to UNQUOTED expansion results, using the characters in IFS
runs of IFS whitespace collapse and edge ones vanish; other IFS characters each delimit a field
IFS= turns word splitting off for that command
IFS=, read -ra parts changes IFS only for that command
{1..$n} fails because braces expand before parameters
{1..10..2} steps, {01..10} zero-pads, {a..e} walks characters
brace expansion invents words whether or not the files exist; a glob only matches
~ is HOME, ~user that user's home, and only unquoted at the start of a word
"$@" yields one word per parameter; "$*" joins them with IFS's first character
E. Parameter expansion operators
${var}x where $varx would name a different variable
${v:-word} substitutes when unset OR empty; ${v-word} only when unset
${v:=word} also assigns, and is illegal on a positional parameter
${v:?message} aborts a non-interactive shell with that message
${v:+word} substitutes only when v IS set, the idiom for an optional flag
${#v} is the character length of a string, not a byte count
${v:offset:length}, and a negative offset needs a space to avoid reading as :-
# trims the shortest matching prefix, ## the longest
% trims the shortest matching suffix, %% the longest
${p##*/} and ${p%/*} do it without forking, and how they differ on edge paths
${v/pat/rep} replaces the first match, ${v//pat/rep} every match
the pattern in #, % and / is a glob, not a regex
${v^^} uppercases and ${v,,} lowercases (bash 4+); the single form hits only the first character
${!name} expands the variable whose NAME is held in name
F. Parameters, variables and attributes
x=1 assigns; x = 1 tries to run a command called x
a variable is invisible to child processes until it is exported
VAR=1 cmd sets VAR for that one command's environment only
unset v removes it, v= leaves it set and empty, and ${v+x} tells them apart
$1$9, and that ${10} needs braces to be the tenth
discards leading positional parameters and renumbers the rest
$# counts positional parameters and never includes $0
$? is the previous command's status and is destroyed by the very next command
$$ is the shell's pid, $! the last background pid, $BASHPID the real current one
declare -r readonly, -i integer, -x export, -a/-A array
declare -n ref=target makes assignments to ref write through to target
G. Command and process substitution
$(...) nests cleanly; backticks re-interpret backslashes at each level
command substitution deletes ALL trailing newlines from the output
$(cmd) unquoted is word-split and globbed, so it must be quoted
variables set inside $(...) do not survive it
the substitution's status becomes $? only when it is the entire command
<(cmd) expands to a /dev/fd path a command can open like a file
the outer command's status says nothing about the substituted command's
while read; do …; done < <(cmd) keeps assignments the pipeline form would lose
H. Arithmetic evaluation
$((expr)) substitutes the value, and variables inside need no $
((expr)) evaluates for its exit status: a nonzero value means success
bash arithmetic has no floats; division truncates toward zero
% is the remainder of that truncating division, so it takes the sign of the LEFT operand: -7 % 3 is -1, not 2
((i++)) returns 1 when i was 0, which kills a set -e script
i++ yields the old value, ++i the new one
a leading 0 means octal, 0x hex, base#digits anything else — so 08 is an error
an unset or empty variable evaluates to 0 rather than erroring
**, then * / %, then + -, then comparisons, then && ||, then ?:, then assignment
an array subscript is evaluated as arithmetic, which makes an untrusted index a code-injection hole
I. Filename expansion and pattern matching
* matches any string including empty; it is not a quantifier on the previous character
? is exactly one character; [abc], [a-z] and [!a] are character sets
[[:digit:]], [[:alpha:]], [[:space:]] inside a bracket expression
a leading . is never matched by a wildcard unless dotglob is set
a glob cannot cross a directory separator, so * stays within one level
shopt -s globstar makes ** descend into subdirectories
an unmatched pattern normally survives literally; these make it vanish or abort instead
?(), *(), +(), @(), !() add alternation and repetition after shopt -s extglob
matches come back sorted, and the collation order depends on the locale
the same syntax drives case, [[ == ]] and the #/%// parameter expansions
J. Redirection and file descriptors
0 stdin, 1 stdout, 2 stderr; an unnumbered redirection defaults to 0 for input and 1 for output
> truncates an existing file, >> appends; both create it
set -o noclobber makes > refuse to overwrite, and >| forces it anyway
>file 2>&1 sends both to the file; 2>&1 >file leaves stderr on the terminal
2>&1 copies where fd 1 points AT THAT MOMENT, not a permanent link
2>&- closes a descriptor so writes to it fail
&>file and &>>file are the bash spelling of >file 2>&1
a redirection written on a loop, group or function covers everything inside it
done > file opens the file once instead of truncating it every iteration
exec > file redirects the shell itself for the remainder of the script
exec 3<file, read -u 3, then exec 3<&- to release it
the target file is created even when the command turns out not to exist
K. Here-documents and here-strings
<<EOF feeds the following lines to stdin until the delimiter appears alone on a line
<<'EOF' disables all expansion inside the body; unquoted expands $ and backticks
<<-EOF removes leading TABS only, so space-indented bodies still break
indentation or trailing whitespace on the delimiter line stops it terminating
<<<"$var" supplies one string as stdin and appends a newline
read -r -d '' v <<'EOF' works but returns nonzero, which trips set -e
L. Conditional expressions: test, [ and [[
[ is an ordinary command whose final argument must be ], so every space is required
[[ ]] is parsed by the shell, so its operands are not word-split or globbed
an empty or spaced value turns [ $x = y ] into a syntax error
= is the portable spelling inside [; [[ ]] accepts == as well
in [[ $f == *.txt ]] the right side is a glob, and quoting it makes it literal
[[ $s =~ re ]] fills BASH_REMATCH, and quoting the regex makes it a literal string
-eq compares integers while < compares strings, so "10" sorts before "9"
-e exists, -f regular file, -d directory, -s non-empty, -L symlink, -r/-w/-x access
-z empty, -n non-empty, and the bare [ "$x" ] shorthand
-a/-o inside [ are deprecated and ambiguous; use && between commands or inside [[
(( x > 1 )) is the clearer numeric test and needs no $
M. Compound commands and control flow
if branches on an exit status; the condition is a command, not an expression
both take a command list as the condition, and until inverts the sense
iterates the words left after expansion, so an empty list runs the body zero times
for ((i=0; i<n; i++)) runs its three parts in arithmetic context
while IFS= read -r line — IFS stops trimming, -r stops backslash eating
read assigns a last line lacking a newline but still returns nonzero
glob patterns with | alternatives; ;; ends, ;& falls through, ;;& retests
the optional count escapes more than one enclosing loop
{ …; } groups in the current shell; ( … ) groups in a subshell
a cd, assignment or trap inside ( ) does not survive it
cmd | while read updates variables in a subshell; use < <(cmd) or lastpipe
N. Shell functions and scoping
name() { …; } is portable; the function keyword is a bash extension
the body needs a space after { and a ; or newline before }
a function gets its own $1$n and $#, but keeps the script's $0
return leaves the function with a status; exit terminates the whole shell
return takes 0–255; to return a value, print it and capture with $(...)
assignments inside a function leak to the whole script unless declared local
a callee can see and modify the caller's locals; bash has no lexical scope
local x=$(cmd) reports local's status, hiding a failing command from set -e
FUNCNAME is the call stack as an array, and recursion is depth-limited
export -f passes a function to a child bash; other shells never see it
O. Arrays and associative arrays
a=(one two three) splits on whitespace; quoting keeps an element containing spaces whole
indices start at 0 and can have holes after an unset
"${a[@]}" gives one word per element; unquoted it re-splits every element
"${a[*]}" collapses the array into ONE word joined by IFS's first character
$a means ${a[0]}, silently dropping the rest
a+=(x) appends an element, while a=x overwrites element zero only
"${a[@]:1:2}" takes a range, and "${@:2}" drops the first argument
declare -A is mandatory (bash 4+); without it the keys collapse to index 0
hash keys come back in no defined order, so sort if order matters
"${!a[@]}" yields indices or keys, which is the only safe way to iterate a sparse or associative array
arrays cannot be placed in the environment, so a child process cannot inherit one
P. Exit status and error handling
0 succeeds; 126 is not-executable, 127 is not-found, 128+n means killed by signal n
set -euo pipefail combines errexit, nounset and pipeline-failure propagation, but each flag retains its documented exceptions
set -e exits when a simple command fails
a command inside if, while, &&, || or after ! never triggers set -e
calling a function in a condition suspends -e for its whole body
only the last stage counts, so false | true succeeds without pipefail
makes a pipeline fail if any stage failed, and exposes SIGPIPE 141 from an early-closing reader
set -u makes expanding an unset variable fatal; ${v:-} is the deliberate opt-out
"${a[@]}" on an empty array aborted under -u before bash 4.4
set -x traces expanded commands, and PS4 can add the script name and line number
cmd || { msg; exit 1; } gives a real message where set -e gives silence
( exit 1 ) ends only the subshell, so the script keeps going
: and true are no-ops returning 0, and || true deliberately swallows a failure
Q. Signals and traps
trap 'code' SIGNAL stores the handler as a STRING that is re-parsed when the signal arrives
a trap on EXIT runs however the shell ends, which is the cleanup idiom
ERR fires on exactly the conditions that would make set -e exit
functions, subshells and substitutions do not inherit ERR/DEBUG/RETURN traps without set -E/set -T
a trap in a non-interactive shell runs after the current foreground command completes
trap '' SIG ignores the signal; trap - SIG restores the default action
SIGKILL and SIGSTOP can be neither trapped nor ignored
the shell reports 128 + the signal number, so Ctrl-C shows 130
the trap must be installed before the resource exists to be leaked, not after the work
R. Job control and processes
jobs lists them and %1, %+, %- and %name address them
resume a stopped job in the foreground or let it continue in the background
Ctrl-Z sends SIGTSTP, leaving the process stopped rather than terminated
wait pid returns that child's status; bare wait waits for all children
wait -n returns as soon as any one child finishes (bash 4.3+)
one removes the job from the shell's table, the other makes it ignore SIGHUP
monitor mode is off in non-interactive shells, so jobspecs and Ctrl-Z do not apply
kill -- -PGID reaches the children a plain kill leaves orphaned
starts a background command with two-way pipes exposed as a file-descriptor array
S. Shell options: set and shopt
set -o toggles the POSIX-style options, shopt -s/-u the bash-only ones
set -- a b c replaces the positional parameters, and -- stops flag parsing
set -f turns filename expansion off for the rest of the shell
set -a exports every variable assigned afterwards, the trick for loading an env file
a called script starts with its own options; only subshells inherit them
shopt -s nocasematch makes case and [[ == ]] case-insensitive
runs the last pipeline stage in the current shell, so its assignments survive (job control must be off)
set -o posix, or invoking bash as sh, changes several behaviors and disables extensions
T. Invocation, startup files and the environment
which of /etc/profile, ~/.bash_profile, ~/.bashrc and ~/.bash_logout run for each shell kind
a plain script sources no rc file at all unless BASH_ENV names one
ssh host cmd gets a non-interactive non-login shell, which is why PATH is different there
a child receives a copy, so nothing it does can change the parent's variables
export marks a variable for children; export -n removes the mark without unsetting it
prepend versus append, and that an empty PATH element silently means the current directory
BASH_VERSION, RANDOM, SECONDS, LINENO, PWD/OLDPWD, BASH_SOURCE
pushd/popd/dirs, and that a cd in a subshell never moves the parent
U. The builtins that carry scripts
read a b c splits the line on IFS and puts all remaining fields into the last variable
without -r, read consumes backslashes as escapes and mangles Windows paths
-a fills an array, -d changes the terminator, -n limits characters
echo's handling of -n, -e and backslashes varies by shell; printf is defined
leftover arguments re-run the format string, which formats a whole list in one call
%q quotes a value so the shell can safely re-read it
eval triggers a whole extra expansion pass, which is exactly why it is an injection risk
exec cmd never returns; with no command it applies only the redirections
command x bypasses functions and aliases, builtin x forces the builtin over a function
parses clustered short options from an optstring, using OPTARG and OPTIND, and cannot do long options
mapfile -t arr < file reads lines into an array with no loop and no word splitting (bash 4+)
V. Portability: bash versus POSIX sh
[[, arrays, local, +=, <<<, $'...' and process substitution are all outside POSIX
Debian and Ubuntu link /bin/sh to dash, so a #!/bin/sh script must avoid extensions
flag and escape handling differ between shells and builtins, so scripts should use printf
only the name() { …; } form is POSIX
. is POSIX; source is a bash spelling of it
[ a == b ] is a bashism; = is the portable string comparison
associative arrays and mapfile need bash 4, wait -n and ${v@Q} need 4.3/4.4, and macOS ships 3.2
the static linter that catches the unquoted-variable, word-splitting and set -e classes automatically
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about Bash & Shell practice
Also on your phone
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.
© 2026 SportaApp LLC