Skip to content

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.

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.

Terminal window
greeting="hello" # shell variable only
export API_URL="https://api.example.com" # in the environment too
export greeting # promote an existing variable
declare -x TOKEN=abc123 # same as export
Terminal window
bash -c 'echo "greeting=${greeting:-unset} api=${API_URL:-unset}"'
# => greeting=unset api=https://api.example.com

Inspecting and removing:

Terminal window
printenv # every environment variable
printenv PATH # one of them
env # 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 variable
unset greeting # remove entirely
export -n API_URL # keep the variable, stop exporting it

A NAME=value prefix sets a variable for that command only:

Terminal window
LC_ALL=C sort file # locale changed for sort alone
DEBUG=1 ./deploy.sh
PATH=/usr/bin:/bin env # a restricted PATH for one command
env -i HOME="$HOME" bash -l # start with an EMPTY environment (-i) plus what you name
env -u LD_PRELOAD ./app # run with one variable removed

Environment inheritance is one-way and by copy. A child process can never modify its parent’s variables or working directory.

Terminal window
# setenv.sh contains: export FOO=bar
./setenv.sh ; echo "${FOO:-unset}" # => unset
source setenv.sh ; echo "${FOO:-unset}" # => bar

This is why virtualenv activation, nvm, and ssh-agent setup are all sourced or wrapped in a shell function rather than run as scripts.

PATH is a colon-separated list of directories searched left to right for an executable file.

/bin
echo "$PATH"
export PATH="$HOME/.local/bin:$PATH" # prepend: wins over system versions
export PATH="$PATH:/opt/tool/bin" # append: only used if nothing earlier matches

Order 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:

Terminal window
hash # show the cache
hash -r # clear it entirely
hash -d python3 # forget one entry

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

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:

Terminal window
( cd /tmp && ls ) # explicit grouping with ( )
x=$(pwd) # command substitution
cmd1 | cmd2 # every stage of a pipeline
long_task & # background jobs

These run in the current shell:

Terminal window
{ cd /tmp; ls; } # brace grouping — note the required ; before }
source script.sh
while ...; do ...; done < file
Terminal window
x=1
( x=2; echo "inside: $x" ) # => inside: 2
echo "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:

Terminal window
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 &&.

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
Terminal window
ls *.txt
ls report?.csv
ls log[0-9].txt
ls /etc/*.d/

Behaviour-changing options (shopt -s to enable, shopt -u to disable, shopt alone to list):

Terminal window
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 scripts
shopt -s dotglob # * also matches dotfiles
shopt -s nocaseglob # case-insensitive matching
shopt -s globstar # ** matches across directories (bash 4.0+)
shopt -s extglob # extended patterns (below)
Terminal window
shopt -s globstar
ls **/*.ts # every .ts file at any depth
ls **/ # every directory recursively
Terminal window
shopt -s extglob
ls !(*.txt) # everything except .txt files
ls *.@(jpg|png|gif) # exactly one of these alternatives
ls +(ab)c # one or more "ab" then c
ls ?(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 is not globbing: it is pure text generation and does not check the filesystem.

Terminal window
echo file{1,2,3}.txt # => file1.txt file2.txt file3.txt
echo {a,b}{1,2} # => a1 a2 b1 b2 (nesting multiplies)
echo {1..5} # => 1 2 3 4 5
echo {5..1} # => 5 4 3 2 1 (descending)
echo {01..10} # => 01 02 ... 10 (zero-padded)
echo {a..e} # => a b c d e
echo {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.bak
mv app.log{,.1} # rename with a suffix

The critical ordering rule: brace expansion happens first, before variable expansion. So this does not work:

Terminal window
n=5
echo {1..$n} # => {1..5} — literally, because $n was not yet expanded
for i in $(seq 1 "$n"); do ...; done # use seq
for (( i = 1; i <= n; i++ )); do ...; done # or a C-style loop (no extra process)

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.

Terminal window
long_task & # start in the background; prints [1] 12345
jobs # list jobs of this shell
jobs -l # with PIDs
fg %1 # bring job 1 to the foreground
bg %1 # resume a stopped job in the background
kill %1 # signal a job by job spec
wait # wait for all background jobs
wait "$pid" # wait for one
wait -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 bg to resume in the background or fg to 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:

Terminal window
pids=()
for host in web1 web2 web3; do
ssh "$host" 'systemctl restart app' &
pids+=("$!")
done
fail=0
for p in "${pids[@]}"; do
wait "$p" || fail=1 # wait returns the job's exit status
done
(( fail )) && echo "at least one host failed" >&2

When a terminal closes, the kernel sends SIGHUP to the foreground process group, which usually kills background jobs too.

Terminal window
nohup long_task & # ignore SIGHUP; output goes to ./nohup.out
nohup ./backup.sh > backup.log 2>&1 &
long_task &
disown -h %1 # keep the job but shield it from SIGHUP
disown %1 # remove it from the job table entirely
setsid long_task # start in a new session, fully detached

For anything that must genuinely outlive your session, use tmux/screen or a systemd unit rather than nohup.

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
Terminal window
kill 12345 # sends SIGTERM
kill -TERM 12345 # explicit; also -15
kill -9 12345 # SIGKILL — last resort, no cleanup happens
kill -HUP 12345 # ask a daemon to reload
kill -0 12345 # send nothing; just test whether the process exists
kill -l # list all signal names
pkill -f 'python app.py' # by command line pattern
pgrep -af nginx # find matching PIDs first — safer

Graceful shutdown pattern:

Terminal window
kill -TERM "$pid" 2>/dev/null
for _ in {1..10}; do
kill -0 "$pid" 2>/dev/null || break # gone
sleep 1
done
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid"

trap installs a handler. See best practices for the EXIT-trap cleanup pattern; here is the signal side:

#!/usr/bin/env bash
set -euo pipefail
running=1
trap 'echo "shutting down..." >&2; running=0' TERM INT
trap 'echo "reloading config" >&2; load_config' HUP
while (( running )); do
do_work
sleep 1
done
echo "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 and waitwait is interrupted by signals.
  • trap '' INT ignores a signal; trap - INT restores 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, use exit 130 to keep that convention.
Terminal window
# interruptible sleep
sleep 300 & wait "$!"

When bash sees a command word, it resolves it in this order:

  1. Alias — expanded while the line is being read, before anything else.
  2. Function — a shell function with that name.
  3. Builtincd, echo, read, test, printf, …
  4. File — the first match found in PATH.
Terminal window
type -a echo
# echo is a shell builtin
# echo is /usr/bin/echo

This explains why echo sometimes behaves differently from man echo: you are running the builtin, not /usr/bin/echo.

Overriding a level explicitly:

Terminal window
\ls # backslash prevents ALIAS expansion (the file/builtin still runs)
'ls' # quoting does the same
command ls # skip functions and aliases; run the builtin or the file
builtin cd /tmp # force the builtin even if a `cd` function exists
enable -n echo # disable a builtin so the external one is found
env ls # run the external binary via PATH lookup, ignoring shell lookup
/bin/ls # absolute path: unambiguous

The wrapper-function idiom relies on this. Without command, it would recurse forever:

Terminal window
ls() { command ls --color=auto "$@"; }

type is the diagnostic tool:

Terminal window
type -t cd # => builtin one word: alias|keyword|function|builtin|file
type -a ls # every definition, in resolution order
type -p ls # the PATH file only, empty if it is a builtin/function
type -f ls # ignore functions
command -v ls # POSIX; script-friendly existence check
Terminal window
if ! command -v docker >/dev/null 2>&1; then
echo "docker is required" >&2; exit 1
fi

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

  • Shell variables are private; export copies 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 source is for.
  • PATH is searched left to right; keep . out of it, and hash -r after installing things.
  • ( ), pipelines, &, and $( ) create subshells whose changes vanish; { } and source do 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 -a will always tell you which one you got.