Skip to content

Bash Environment Variables: export, unset, Subshells, and Why Cron Can't See Yours

environmentexportcron-readyvariableserror-handling
8 min read
Matching toolCron Job Builder

Quick Answer

A bash environment variable is a shell variable that has been exported: VAR=value creates it in the current shell only, and export VAR copies it into the environment every child process inherits. A script you run is a child process, so it sees exported variables and nothing else — the most common cause of a script that works after you paste its commands into the terminal but fails when you execute it. A ( ) subshell is a fork of the current shell and sees everything; a sourced file (source ./file or . ./file) runs in the current shell, so its assignments persist. cron starts every job with an almost empty environment, which env -i reproduces so you can test for it. Read variables with ${VAR:-default} to supply a fallback and ${VAR:?message} to abort with a clear error when a required one is missing; unset VAR removes it entirely, and printenv shows what the environment actually contains.

The deploy script read DEPLOY_ENV and fell back to staging when it was empty, which was a sensible default right up until the afternoon someone set DEPLOY_ENV=production in their terminal, ran ./deploy.sh, watched it print "deploying to staging", and assumed the prompt had lagged. It had not. DEPLOY_ENV=production without export creates a variable in the terminal's shell and nowhere else. ./deploy.sh is a new process; it inherited the environment, the variable was not in it, and the fallback did precisely what it was written to do. The production release went to staging, the staging release that followed went to staging, and the incident report's root cause was one missing word.

The rules for which process can see which variable are short, but they are the kind of short that gets guessed at instead of learned. The script on this page runs each rule and prints what happened, so the version in your head matches the version in the kernel.

set -u turns a silent empty into a loud error

Every trap on this page gets louder with set -u: a reference to an unset variable stops the script with its name instead of expanding to nothing. The Safe Bash Script Template puts set -euo pipefail on line 4 for exactly this reason and walks through what each flag catches. And when the variable that is missing is PATH itself — or has three copies of the same directory — paste it into the Bash $PATH Debugger before reading further.

The Script

bash
#!/bin/bash # Script: bash-environment-variables.sh # Purpose: A variable that was set but never exported is invisible to every # child process — the deploy script reads an empty API_TOKEN and runs # against production anyway. This shows each inheritance rule with # live output so you meet the trap here instead of in an outage. # Usage: ./bash-environment-variables.sh # Tested: Kali 2026.3 (bash 5.3) set -euo pipefail CHECK="✓" CROSS="✗" # ── CONFIGURATION ────────────────────────────────────────────── SHOWN_VALUE="from-parent" # the value every test below tries to read back # child_sees NAME — what a freshly started bash (a real child process, like # a script you execute) sees for NAME. ${!1} is indirect expansion: the # variable whose name is in $1. Unset is reported as text, never fatal. child_sees() { bash -c 'printf "%s" "${!1:-<unset>}"' _ "$1" } # empty_env_sees NAME — the same, but the child starts with NO environment. # env -i empties it first: the closest thing to how cron launches a job. empty_env_sees() { env -i bash -c "printf '%s' \"\${!1:-<unset>}\"" _ "$1" } section() { printf '\n── %s ──\n' "$1"; } section "1. Shell variable vs exported variable" PLAIN="$SHOWN_VALUE" # shell variable: lives in this process only export EXPORTED="$SHOWN_VALUE" # environment variable: copied into every child printf '%-22s parent=%-12s child=%s\n' "PLAIN (not exported)" "$PLAIN" "$(child_sees PLAIN)" printf '%-22s parent=%-12s child=%s\n' "EXPORTED" "$EXPORTED" "$(child_sees EXPORTED)" section "2. Subshell ( ) vs child process" # A ( ) subshell is a fork of THIS shell, so it inherits everything, exported # or not. A separately executed script is a new program: exports only. ( printf '%-22s %s\n' "subshell sees PLAIN:" "${PLAIN:-<unset>}" ) printf '%-22s %s\n' "bash -c sees PLAIN:" "$(child_sees PLAIN)" section "3. Running a file vs sourcing it" SETTER=$(mktemp) trap 'rm -f "$SETTER"' EXIT echo 'FROM_FILE="set-inside-file"' > "$SETTER" bash "$SETTER" # child process: sets FROM_FILE, then exits with it printf '%-22s %s\n' "after bash file:" "${FROM_FILE:-<unset>}" # shellcheck source=/dev/null source "$SETTER" # same process: the assignment survives printf '%-22s %s\n' "after source file:" "${FROM_FILE:-<unset>}" section "4. env -i: the environment cron starts your job with" # PATH survives only because bash sets a built-in default when none is given. # Everything you exported in .bashrc, and HOME itself, is gone. printf '%-22s %s\n' "PATH under env -i:" "$(empty_env_sees PATH)" printf '%-22s %s\n' "HOME under env -i:" "$(empty_env_sees HOME)" printf '%-22s %s\n' "EXPORTED under env -i:" "$(empty_env_sees EXPORTED)" section "5. Defaults and required variables" printf '%-22s %s\n' "\${PORT:-8080}:" "${PORT:-8080}" # :? aborts the shell with the message when the variable is unset or empty. # Run in a child so this demo survives; in a real script the abort is the point. API_CHECK_EXIT=0 API_CHECK_MSG=$(bash -c ': "${API_TOKEN:?must be set before deploy}"' 2>&1) || API_CHECK_EXIT=$? if [ "$API_CHECK_EXIT" -eq 0 ]; then echo "$CHECK API_TOKEN is set" else echo "$CROSS \${API_TOKEN:?...} aborted with exit $API_CHECK_EXIT:" echo " $API_CHECK_MSG" fi section "6. unset vs empty — not the same thing" export EMPTY="" unset EXPORTED # ${VAR-x} (no colon) substitutes only when unset; ${VAR:-x} also when empty. printf '%-22s %s\n' "EMPTY, \${EMPTY-x}:" "$(bash -c 'printf "%s" "${EMPTY-<unset>}"')" printf '%-22s %s\n' "EMPTY, \${EMPTY:-x}:" "$(bash -c 'printf "%s" "${EMPTY:-<empty>}"')" printf '%-22s %s\n' "EXPORTED after unset:" "$(child_sees EXPORTED)" section "7. printenv shows the environment, not shell variables" if printenv PLAIN; then echo "$CHECK PLAIN is in the environment" else echo "$CROSS printenv PLAIN found nothing (exit $?) — PLAIN is a shell variable, not environment" fi

What does it print?

Verbatim from bash 5.3 on the machine this was written on:

text
$ ./bash-environment-variables.sh ── 1. Shell variable vs exported variable ── PLAIN (not exported) parent=from-parent child=<unset> EXPORTED parent=from-parent child=from-parent ── 2. Subshell ( ) vs child process ── subshell sees PLAIN: from-parent bash -c sees PLAIN: <unset> ── 3. Running a file vs sourcing it ── after bash file: <unset> after source file: set-inside-file ── 4. env -i: the environment cron starts your job with ── PATH under env -i: /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:. HOME under env -i: <unset> EXPORTED under env -i: <unset> ── 5. Defaults and required variables ── ${PORT:-8080}: 8080 ✗ ${API_TOKEN:?...} aborted with exit 127: bash: line 1: API_TOKEN: must be set before deploy ── 6. unset vs empty — not the same thing ── EMPTY, ${EMPTY-x}: EMPTY, ${EMPTY:-x}: <empty> EXPORTED after unset: <unset> ── 7. printenv shows the environment, not shell variables ── ✗ printenv PLAIN found nothing (exit 1) — PLAIN is a shell variable, not environment

Section 1 is the deploy incident in two lines. Both variables hold the same value in the parent; the child sees only the exported one.

Why can't a child process see my variable?

Because there are two kinds of variable and only one of them travels. PLAIN=value creates a shell variable: a name in the memory of this one bash process. export PLAIN (or export PLAIN=value) additionally marks it for the environment, which is the block of NAME=value strings the kernel hands to every program this shell starts. A script you execute is a program this shell starts. It gets the environment and nothing else — not your functions, not your aliases, not your plain variables.

The subshell in section 2 is the exception that confuses people. ( ... ) does not start a new program; it forks a copy of the current bash, memory and all, so PLAIN is visible inside it. Command substitution $( ... ) and each side of a pipeline are subshells too. The rule is not "child processes see exports" — it is "a new program sees exports; a forked copy of bash sees everything." ./script.sh and bash -c are new programs.

What is the difference between running a script and sourcing it?

Section 3 runs the same one-line file both ways. bash "$SETTER" starts a child, the child sets FROM_FILE, the child exits, and the value dies with it — the parent still reports <unset>. source "$SETTER" reads the file's lines into the current shell as if you had typed them, so the assignment lands in the parent and survives. . "$SETTER" is the same thing in POSIX spelling.

This is why a config.sh full of KEY=value lines has to be sourced, not executed, and why a "setup script" that says "run me to configure your shell" has to be sourced to have any effect. It is also why sourcing an untrusted file is running it with full access to your shell's state — treat source as eval with a filename.

Why does my script work in the terminal but not in cron?

Section 4 is the answer, and env -i is how you see it before cron does. env -i command runs the command with the environment emptied first: no HOME, no LANG, none of the exports from .bashrc, and a PATH that only exists because bash falls back to a compiled-in default when the environment supplies none. cron's environment is nearly that sparse — it sets HOME, LOGNAME, a SHELL of /bin/sh, and a PATH that is typically /usr/bin:/bin, and reads none of your dotfiles.

So a script that calls aws, node, or anything installed under /usr/local or ~/.local/bin fails with command not found under cron, and a script that reads $API_TOKEN from an export in .bashrc reads an empty string. env -i bash ./script.sh reproduces both failures on demand. The fix is to stop depending on inherited state: set PATH explicitly at the top of the script, and load secrets from a file the script sources itself. The cron half of that story — locking, logging, timeouts — is the Bash Scripts That Survive Cron guide.

How do I read a variable with a default, or require one?

${PORT:-8080} in section 5 expands to 8080 because PORT is unset. It does not assign anything; use ${PORT:=8080} if you also want PORT to hold the default afterwards. ${API_TOKEN:?must be set before deploy} is the opposite: when the variable is unset or empty, bash prints the message to stderr with the variable's name and the script stops. The demo runs it inside bash -c so the page's script survives; in a real script you want the abort. bash 5.3 on this box exits 127 for it — treat that as "non-zero", not as a number to match on. Put one : "${VAR:?message}" line per required variable at the top of every script, and a missing secret stops the run with a name on it instead of an empty-string request to production. That belongs next to set -euo pipefail in the error handling pattern, and set -u catches the plain references the :? lines did not cover.

Section 6 shows the one subtlety: EMPTY="" is set, so ${EMPTY-x} yields the empty string and only ${EMPTY:-x} supplies the fallback. Use the colon forms unless you have a reason not to. unset EXPORTED removes the variable from both the shell and the environment — EXPORTED="" would have kept an empty one in the environment, which is a different state and a different bug.

How do I see what is actually in the environment?

printenv prints the environment and nothing else; printenv NAME prints one variable and exits 1 when it is absent, which section 7 uses as a test. env with no arguments does the same as printenv. export -p lists exported variables in re-sourceable form. set prints everything — shell variables, environment, and functions. On this box printenv | wc -l is 121 and set | wc -l is 245; the gap is the set of things a child process will never see.

Frequently Asked Questions

What is the difference between a shell variable and an environment variable in bash?

A shell variable exists only inside the bash process that created it: NAME=value. An environment variable is a shell variable that has also been marked for export with export NAME or export NAME=value. The distinction only matters when bash starts a child process: the child receives a copy of every exported variable and none of the plain ones. Inside the shell that set them, they behave identically, which is why the difference stays invisible until a script runs as a child and gets nothing.

Why can't my script see a variable I set in the terminal?

Because you assigned it without export, or you exported it in a different shell — another tab, a previous session, or a .bashrc never re-read. Confirm with printenv NAME in the terminal you run the script from: no output means it is not in the environment. Fix it with export NAME=value in that terminal, or put the export in ~/.bashrc and open a new shell. A ( ) subshell would have seen it either way, but ./script.sh is a new bash process, not a subshell.

Why does my script work in the terminal but fail in cron?

cron does not read .bashrc or .profile. A cron job starts with a nearly empty environment — HOME, LOGNAME, SHELL=/bin/sh, and a minimal PATH such as /usr/bin:/bin — so every variable you exported interactively and every directory you added to PATH is missing. Reproduce it with env -i bash ./script.sh. Then set the variables at the top of the crontab (cron accepts NAME=value lines) or source a config file explicitly inside the script.

How do I make an environment variable permanent in Linux?

For one user's interactive shells, add export NAME=value to ~/.bashrc and open a new terminal. For every user and for non-shell services, /etc/environment takes plain NAME=value lines with no export keyword — PAM reads it at login, not bash, so no shell syntax works there. For a systemd service use Environment= or EnvironmentFile= in the unit; for cron, put NAME=value at the top of the crontab. None of these change processes already running.

What is the difference between ${VAR:-default} and ${VAR-default}?

The colon widens the test. ${VAR-default} substitutes only when VAR is unset — a variable set to an empty string yields the empty string. ${VAR:-default} substitutes when VAR is unset or empty. The same rule applies to :=, :? and :+. You almost always want the colon versions, because an empty value is nearly always a mistake: a config with TOKEN= and nothing after it should behave like a missing token.

Part of the bash snippets collection

Raw script, MIT licensed: scripts/bash-environment-variables.sh on GitHub

PAID RESOURCE — $9

The Production Bash Toolkit

An operational script system + a 31-function shared library + a 52-page field guide. The production layer the free snippets don't cover.

Get the Toolkit →
curl -O bashlib-starter.sh

Get the bashlib starter

Ten functions I source into every script on my own boxes — strict-mode setup, an ERR trap that names the failing line, lock and timeout wrappers, and cleanup that runs on every exit path. One email, no sequence.

BashSnippets logo

Written by Travis

Creator of BashSnippets.xyz

bashsnippets.xyz/about

Related Snippets

Frequently Asked Questions

faq — snippet

What is the difference between a shell variable and an environment variable in bash?

A shell variable exists only inside the bash process that created it: NAME=value. An environment variable is a shell variable that has also been marked for export, either with export NAME after the assignment or export NAME=value in one step. The distinction only matters at one moment — when bash starts a child process. The child receives a copy of every exported variable and none of the plain ones. Inside the shell that set them both, they behave identically, which is why the difference is invisible right up until the script that needs the value runs as a child and gets nothing.

faq — snippet

Why can't my script see a variable I set in the terminal?

Because you assigned it without exporting it, or because the script is running as a child process and the variable was exported in a different shell (another terminal tab, a previous session, or a .bashrc that was never re-read). Confirm with printenv NAME in the same terminal you run the script from: no output means the variable is not in the environment, so no child can see it. Fix it with export NAME=value in that terminal, or put the export in ~/.bashrc and open a new shell. A ( ) subshell would have seen it either way, but ./script.sh is not a subshell — it is a new bash process.

faq — snippet

Why does my script work in the terminal but fail in cron?

cron does not read your .bashrc or .profile. A cron job starts with a nearly empty environment — typically HOME, LOGNAME, SHELL set to /bin/sh, and a minimal PATH such as /usr/bin:/bin — so every variable you exported interactively, and every directory you added to PATH, is missing. The script then fails with command not found, or reads an empty variable and takes the fallback path. Reproduce it before cron does: env -i bash ./script.sh runs the script with no environment at all. Then either set the variables at the top of the crontab (cron supports NAME=value lines) or source a config file explicitly inside the script.

faq — snippet

How do I make an environment variable permanent in Linux?

For one user's interactive shells, add export NAME=value to ~/.bashrc (or ~/.bash_profile on systems where login shells read only that) and open a new terminal. For every user and for services that are not shells, /etc/environment takes plain NAME=value lines with no export keyword — it is read by PAM at login, not by bash, so no shell syntax works there. For a systemd service, use Environment= or EnvironmentFile= in the unit; for cron, put NAME=value lines at the top of the crontab. None of these affect processes that are already running; only new ones inherit the change.

faq — snippet

What is the difference between ${VAR:-default} and ${VAR-default}?

The colon widens the test. ${VAR-default} substitutes the default only when VAR is unset — a variable set to an empty string counts as set and yields the empty string. ${VAR:-default} substitutes when VAR is unset or empty. The same rule applies to the :=, :? and :+ forms. In practice you almost always want the colon versions, because an empty value is nearly always a mistake: a config file with TOKEN= and nothing after it should behave like a missing token, not like a valid one. The no-colon forms exist for the rare case where empty is a meaningful, deliberate value.