opscape

Bash scripting guide · part 4 of 32

Environment variables

How export passes variables to child processes, the one-command NAME=value prefix, loading .env files, and why a child cannot change its parent.

Every process on Unix carries an environment: a list of NAME=value strings it received when it started. Programs read it for configuration: PATH for where to find commands, HOME, LANG for the locale, TZ for the time zone, EDITOR, and whatever your own tools look for, like DATABASE_URL.

The shell keeps two kinds of variables. Shell variables live only inside the shell. Environment variables are shell variables marked for export: whenever the shell starts a program, it copies the exported ones into that program's environment. export sets the mark.

export decides what children see

Only exported variables reach child processes

color=blue
export size=large
bash -c 'echo "child sees color=[$color] size=[$size]"'
export color
bash -c 'echo "after export: color=[$color]"'
output
child sees color=[] size=[large]
after export: color=[blue]

The child bash got size because it was exported, but not color. Once color is exported too, every program started afterwards receives it. export can mark an existing variable or assign and mark in one step.

The copy happens at the moment the child starts, and it is one-way. A child gets its own environment and can change it freely, but nothing it does travels back up to the parent. A script cannot set a variable in the shell that ran it; only sourceing the script can, because then no child is involved.

A child cannot change its parent's environment

export STAGE=dev
bash -c 'export STAGE=prod; echo "child set STAGE=$STAGE"'
echo "parent still has STAGE=$STAGE"
output
child set STAGE=prod
parent still has STAGE=dev

The child's export changed only the child's copy. This is why tools that "set environment variables for you" either print export lines for you to eval, or ask you to source a file.

Setting a variable for one command

Writing NAME=value directly before a command puts it in that command's environment only, without touching the shell. It is the cleanest way to adjust one run, and you can stack several pairs.

Change the environment of a single command

TZ=Asia/Tokyo date -d '2026-03-14 12:00 UTC' '+%H:%M %Z'
date -d '2026-03-14 12:00 UTC' '+%H:%M %Z'
LC_ALL=C TZ=America/New_York date -d '2026-03-14 12:00 UTC' '+%a %H:%M %Z'
output
21:00 JST
12:00 UTC
Sat 08:00 EDT

Only the first date ran with TZ=Asia/Tokyo; the second used the shell's own UTC. The prefix assignments apply to the command they precede and are gone afterwards.

There is a classic trap in that form. The shell expands the command's arguments before it applies the prefix assignment, and the assignment goes into the child's environment, not into the current shell. So $NAME on the same line still shows the old value.

The prefix does not affect expansions on the same line

GREETING=hello echo "greeting is [$GREETING]"
GREETING=hello bash -c 'echo "greeting is [$GREETING]"'
output
greeting is []
greeting is [hello]

In the first line, $GREETING was expanded by the current shell, where it is not set, before echo ran. In the second, the single quotes delay the expansion until the child bash runs, and the child does have GREETING in its environment.

Looking at the environment

env and printenv with no arguments print the whole environment; printenv NAME prints one value and exits 1 when it is not set. Both show exported variables only. declare -p NAME shows a shell variable and its flags: an x among them means exported.

Inspect what is and is not exported

local_only=1
export SHARED=2
printenv SHARED
printenv local_only || echo "local_only is not in the environment"
declare -p local_only SHARED
env | grep -E '^(HOME|LANG|PATH|SHARED)='
output
2
local_only is not in the environment
declare -- local_only="1"
declare -x SHARED="2"
HOME=/home/dev
LANG=C.UTF-8
SHARED=2
PATH=/usr/local/bin:/usr/bin:/bin

printenv finds SHARED and fails on local_only. declare -p shows the difference as -- versus -x. The sandbox environment is small and predictable: HOME, LANG, PATH and a few others.

export -n NAME removes the export mark but keeps the variable; unset NAME removes both. Exporting only works for scalar values: bash arrays cannot be put in the environment, because the environment is a flat list of strings.

Loading a .env file

Many projects keep settings in a .env file of NAME=value lines, like the fixture's:

API_URL=http://localhost:8080
DEBUG=false

source .env would create shell variables but not export them, so programs you start would not see them. set -a ("allexport") marks every variable assigned while it is on for export.

Export every variable from a .env file

source .env
python3 -c 'import os; print("without set -a:", os.environ.get("API_URL"))'
set -a
source .env
set +a
python3 -c 'import os; print("with set -a:", os.environ.get("API_URL"))'
output
without set -a: None
with set -a: http://localhost:8080

After a plain source, Python sees no API_URL. Wrapped in set -a / set +a, the same file's assignments are exported. Remember that sourcing runs the file as shell code: values with spaces need quotes, and a .env file from an untrusted source can run commands.

PATH

PATH is a colon-separated list of directories searched in order for commands. Add to it, never replace it: prepend when your copy should win, append when it should only fill gaps. An empty element (a leading or trailing colon, or ::) means the current directory, which is a security hole, so check your edits.

Prepend a directory to PATH

echo "$PATH" | tr ':' '\n'
export PATH="$HOME/bin:$PATH"
echo "now first: ${PATH%%:*}"
output
/usr/local/bin
/usr/bin
/bin
now first: /home/dev/bin

tr shows one directory per line. After prepending, ~/bin is searched first. ${PATH%%:*} strips everything from the first colon, leaving the first element. In a script, the change lasts until the script exits; for your login shell, put the export in ~/.profile or ~/.bashrc.

A clean environment

Scripts that work in your terminal and fail under cron, systemd or CI usually depend on something in your interactive environment: a PATH entry, LANG, an exported token. Running with env -i reproduces the bare environment those systems give you.

Test a script with an empty environment

report.sh
#!/usr/bin/env bash
echo "HOME=${HOME:-<unset>} LANG=${LANG:-<unset>}"
echo "PATH=$PATH"
bash report.sh
env -i /bin/bash report.sh
output
HOME=/home/dev LANG=C.UTF-8
PATH=/usr/local/bin:/usr/bin:/bin
HOME=<unset> LANG=<unset>
PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:.

With env -i the script gets no HOME or LANG, and bash falls back to its compiled-in default PATH. That default differs from the sandbox's and even ends in ., the current directory. Nothing is inherited, so whatever your script needs must be set explicitly.

Conventions

  • Environment variable names are UPPERCASE with underscores. Keep your script's internal variables lowercase so they never collide with one.
  • Export only what child programs need. Everything exported leaks into every process the script starts, including secrets.
  • Do not put secrets on the command line (TOKEN=x cmd is fine in a script, but on an interactive command line it lands in your history). Other users cannot read another user's environment through /proc on Linux, but command-line arguments are visible to everyone in ps.

Related