Environment and Jobs
Two questions cause most “it works in my terminal but not in the script” confusion: which process is this running in, and what does that process inherit? This page answers both, then covers the process-management side of bash — jobs, signals, and how a command name becomes a running program.
Shell variables vs environment variables
Section titled “Shell variables vs environment variables”Every variable you set is a shell variable, visible only to the current shell. The
environment is a separate list of NAME=value strings that the kernel copies into every child
process at exec time. export marks a shell variable for inclusion in that copy.
greeting="hello" # shell variable onlyexport API_URL="https://api.example.com" # in the environment tooexport greeting # promote an existing variabledeclare -x TOKEN=abc123 # same as exportbash -c 'echo "greeting=${greeting:-unset} api=${API_URL:-unset}"'# => greeting=unset api=https://api.example.comInspecting and removing:
printenv # every environment variableprintenv PATH # one of themenv # same list, plus it can modify the environment (below)set # ALL shell variables and functions (long)declare -p API_URL # => declare -x API_URL="https://api.example.com"export -p # every exported variableunset greeting # remove entirelyexport -n API_URL # keep the variable, stop exporting itPer-command environments
Section titled “Per-command environments”A NAME=value prefix sets a variable for that command only:
LC_ALL=C sort file # locale changed for sort aloneDEBUG=1 ./deploy.shPATH=/usr/bin:/bin env # a restricted PATH for one commandenv -i HOME="$HOME" bash -l # start with an EMPTY environment (-i) plus what you nameenv -u LD_PRELOAD ./app # run with one variable removedChildren cannot change the parent
Section titled “Children cannot change the parent”Environment inheritance is one-way and by copy. A child process can never modify its parent’s variables or working directory.
# setenv.sh contains: export FOO=bar./setenv.sh ; echo "${FOO:-unset}" # => unsetsource setenv.sh ; echo "${FOO:-unset}" # => barThis is why virtualenv activation, nvm, and ssh-agent setup are all sourced or wrapped in a
shell function rather than run as scripts.
PATH and command resolution
Section titled “PATH and command resolution”PATH is a colon-separated list of directories searched left to right for an executable file.
echo "$PATH"export PATH="$HOME/.local/bin:$PATH" # prepend: wins over system versionsexport PATH="$PATH:/opt/tool/bin" # append: only used if nothing earlier matchesOrder decides which of two same-named binaries runs. type -a name shows every match in order, and
command -v name shows the one that would win.
Bash caches lookups in a hash table, which is why a newly installed program can appear “not found” until you clear it:
hash # show the cachehash -r # clear it entirelyhash -d python3 # forget one entryA command containing a / bypasses PATH entirely: ./script.sh and /usr/bin/ls are looked up as
paths. That is why running a script in the current directory requires the ./ prefix.
Subshells vs the current shell
Section titled “Subshells vs the current shell”A subshell is a forked copy of the shell. It inherits everything (including non-exported variables, functions, and the current directory) but its changes are discarded when it exits.
These all create a subshell:
( cd /tmp && ls ) # explicit grouping with ( )x=$(pwd) # command substitutioncmd1 | cmd2 # every stage of a pipelinelong_task & # background jobsThese run in the current shell:
{ cd /tmp; ls; } # brace grouping — note the required ; before }source script.shwhile ...; do ...; done < filex=1( x=2; echo "inside: $x" ) # => inside: 2echo "outside: $x" # => outside: 1
# the classic use: a scoped directory change( cd build && make ) ; pwd # you are still where you started$BASH_SUBSHELL counts nesting depth; $$ is the original shell’s PID and does not change in a
subshell, while $BASHPID is the actual current process:
echo "$$ $BASHPID" # => 4242 4242( echo "$$ $BASHPID" ) # => 4242 4257( ) vs { } in one line: use ( ) when you want isolation, { } when you only want to group
commands for redirection or &&.
Globbing
Section titled “Globbing”Pathname expansion turns patterns into matching filenames. If nothing matches, bash leaves the pattern as a literal string — the source of many surprises.
| Pattern | Matches |
|---|---|
* |
Any string, including empty; never matches a leading . or a / |
? |
Exactly one character |
[abc] |
One character from the set |
[a-z] |
One character from the range (locale-dependent; LC_ALL=C for ASCII) |
[!abc] or [^abc] |
One character not in the set |
[[:digit:]] |
One character of a POSIX class |
ls *.txtls report?.csvls log[0-9].txtls /etc/*.d/Behaviour-changing options (shopt -s to enable, shopt -u to disable, shopt alone to list):
shopt -s nullglob # no match -> expands to NOTHING (loops just don't run)shopt -s failglob # no match -> an error; good for catching typos in scriptsshopt -s dotglob # * also matches dotfilesshopt -s nocaseglob # case-insensitive matchingshopt -s globstar # ** matches across directories (bash 4.0+)shopt -s extglob # extended patterns (below)shopt -s globstarls **/*.ts # every .ts file at any depthls **/ # every directory recursivelyextglob
Section titled “extglob”shopt -s extglobls !(*.txt) # everything except .txt filesls *.@(jpg|png|gif) # exactly one of these alternativesls +(ab)c # one or more "ab" then cls ?(v)1.2 # zero or one "v"rm !(keep.txt|also.txt) # delete everything except two files@() one of, ?() zero or one, *() zero or more, +() one or more, !() anything but. These also
work in [[ x == pattern ]] and in case.
Brace expansion
Section titled “Brace expansion”Brace expansion is not globbing: it is pure text generation and does not check the filesystem.
echo file{1,2,3}.txt # => file1.txt file2.txt file3.txtecho {a,b}{1,2} # => a1 a2 b1 b2 (nesting multiplies)echo {1..5} # => 1 2 3 4 5echo {5..1} # => 5 4 3 2 1 (descending)echo {01..10} # => 01 02 ... 10 (zero-padded)echo {a..e} # => a b c d eecho {0..20..5} # => 0 5 10 15 20 (with a step, bash 4.0+)
mkdir -p project/{src,test,docs}cp config.yml{,.bak} # => cp config.yml config.yml.bakmv app.log{,.1} # rename with a suffixThe critical ordering rule: brace expansion happens first, before variable expansion. So this does not work:
n=5echo {1..$n} # => {1..5} — literally, because $n was not yet expandedfor i in $(seq 1 "$n"); do ...; done # use seqfor (( i = 1; i <= n; i++ )); do ...; done # or a C-style loop (no extra process)Job control
Section titled “Job control”A job is a pipeline started by the shell. Job control lets you suspend, background, and resume them. It is on by default in interactive shells and off in scripts.
long_task & # start in the background; prints [1] 12345jobs # list jobs of this shelljobs -l # with PIDsfg %1 # bring job 1 to the foregroundbg %1 # resume a stopped job in the backgroundkill %1 # signal a job by job specwait # wait for all background jobswait "$pid" # wait for onewait -n # wait for the next one to finish (bash 4.3+)Job specs: %1 by number, %% or %+ the current job, %- the previous, %name by command prefix,
%?text by substring.
Keyboard control in an interactive shell:
- Ctrl-C sends SIGINT — terminate the foreground job.
- Ctrl-Z sends SIGTSTP — suspend it; then
bgto resume in the background orfgto resume in front. - Ctrl-D sends end-of-file, not a signal — it ends input, and at a prompt it exits the shell.
$! holds the PID of the most recent background command, which is how you run work in parallel:
pids=()for host in web1 web2 web3; do ssh "$host" 'systemctl restart app' & pids+=("$!")done
fail=0for p in "${pids[@]}"; do wait "$p" || fail=1 # wait returns the job's exit statusdone(( fail )) && echo "at least one host failed" >&2Surviving logout
Section titled “Surviving logout”When a terminal closes, the kernel sends SIGHUP to the foreground process group, which usually kills background jobs too.
nohup long_task & # ignore SIGHUP; output goes to ./nohup.outnohup ./backup.sh > backup.log 2>&1 &
long_task &disown -h %1 # keep the job but shield it from SIGHUPdisown %1 # remove it from the job table entirely
setsid long_task # start in a new session, fully detachedFor anything that must genuinely outlive your session, use tmux/screen or a systemd unit rather
than nohup.
Signals
Section titled “Signals”A signal is an asynchronous notification delivered to a process. The ones that matter:
| Signal | Number | Default action | Catchable | Typical source |
|---|---|---|---|---|
| SIGHUP | 1 | terminate | yes | terminal closed; also “reload config” by convention |
| SIGINT | 2 | terminate | yes | Ctrl-C |
| SIGQUIT | 3 | terminate + core | yes | Ctrl-\ |
| SIGKILL | 9 | terminate | no | kill -9 |
| SIGTERM | 15 | terminate | yes | kill default, orchestrators shutting down |
| SIGSTOP | 19 | stop | no | kill -STOP |
| SIGTSTP | 20 | stop | yes | Ctrl-Z |
| SIGCONT | 18 | continue | — | fg / bg |
kill 12345 # sends SIGTERMkill -TERM 12345 # explicit; also -15kill -9 12345 # SIGKILL — last resort, no cleanup happenskill -HUP 12345 # ask a daemon to reloadkill -0 12345 # send nothing; just test whether the process existskill -l # list all signal namespkill -f 'python app.py' # by command line patternpgrep -af nginx # find matching PIDs first — saferGraceful shutdown pattern:
kill -TERM "$pid" 2>/dev/nullfor _ in {1..10}; do kill -0 "$pid" 2>/dev/null || break # gone sleep 1donekill -0 "$pid" 2>/dev/null && kill -KILL "$pid"Trapping signals
Section titled “Trapping signals”trap installs a handler. See best practices for the EXIT-trap
cleanup pattern; here is the signal side:
#!/usr/bin/env bashset -euo pipefail
running=1trap 'echo "shutting down..." >&2; running=0' TERM INTtrap 'echo "reloading config" >&2; load_config' HUP
while (( running )); do do_work sleep 1doneecho "clean exit"Important mechanics:
- Bash does not run a trap while waiting for a foreground command to finish; it runs after that
command completes. For long
sleeps, background them andwait—waitis interrupted by signals. trap '' INTignores a signal;trap - INTrestores the default.- Traps are reset to defaults in child processes.
- A process killed by signal N exits with status
128 + N(SIGINT gives 130, SIGTERM gives 143). If you trap and exit yourself, useexit 130to keep that convention.
# interruptible sleepsleep 300 & wait "$!"Command precedence
Section titled “Command precedence”When bash sees a command word, it resolves it in this order:
- Alias — expanded while the line is being read, before anything else.
- Function — a shell function with that name.
- Builtin —
cd,echo,read,test,printf, … - File — the first match found in
PATH.
type -a echo# echo is a shell builtin# echo is /usr/bin/echoThis explains why echo sometimes behaves differently from man echo: you are running the builtin,
not /usr/bin/echo.
Overriding a level explicitly:
\ls # backslash prevents ALIAS expansion (the file/builtin still runs)'ls' # quoting does the samecommand ls # skip functions and aliases; run the builtin or the filebuiltin cd /tmp # force the builtin even if a `cd` function existsenable -n echo # disable a builtin so the external one is foundenv ls # run the external binary via PATH lookup, ignoring shell lookup/bin/ls # absolute path: unambiguousThe wrapper-function idiom relies on this. Without command, it would recurse forever:
ls() { command ls --color=auto "$@"; }type is the diagnostic tool:
type -t cd # => builtin one word: alias|keyword|function|builtin|filetype -a ls # every definition, in resolution ordertype -p ls # the PATH file only, empty if it is a builtin/functiontype -f ls # ignore functionscommand -v ls # POSIX; script-friendly existence checkif ! command -v docker >/dev/null 2>&1; then echo "docker is required" >&2; exit 1fiReserved words (if, for, while, [[, function, time, !) are recognized during parsing, so
they take effect before this lookup even applies — you cannot shadow if with a function.
Key points
Section titled “Key points”- Shell variables are private;
exportcopies them into every child at exec time, one way only. - A child process can never change its parent’s variables or directory — that is what
sourceis for. PATHis searched left to right; keep.out of it, andhash -rafter installing things.( ), pipelines,&, and$( )create subshells whose changes vanish;{ }andsourcedo not.- Brace expansion is text generation and runs before variables expand; globbing looks at the filesystem and leaves the pattern intact when nothing matches.
- Prefer SIGTERM and a trap-based cleanup; SIGKILL and SIGSTOP cannot be caught.
- Resolution order is alias, function, builtin, file — and
type -awill always tell you which one you got.