Module 4: Permissions, Processes, and Environment

6. Environment variables and the PATH

Description

By the end of this lesson you will be able to diagnose the vast majority of command not found errors with nothing reinstalled, explain why a variable you see with echo $VAR sometimes disappears inside a script and sometimes does not, and use which, type, and command -v to answer a question that looks trivial and is not: when you type python3 in the terminal, which of the three or four python3 installs on your machine actually runs?

This is not operating-system trivia. The day a coworker tells you "it works on my machine" and it does not on yours, the cause almost always lives in one of two places: an environment variable they have defined and you do not, or a PATH that on their machine finds a different version of the same binary first. The day a continuous integration pipeline fails with command not found for a command you ran a minute ago on your own terminal, the cause is the same: that other shell's environment is not a copy of yours, even though the program exists somewhere on disk.

Connection to the module: the previous lesson showed you a child process inherits a kinship relationship from its parent process. Today you are going to see exactly what it inherits from that parent: a frozen copy of its environment, taken at the exact instant it is born. You are not going to touch the files that make a PATH change persist between one terminal and another yet — .bashrc, .zshrc — that is exactly next lesson's topic. Today's goal is understanding what an environment variable is, what the PATH is, and how to precisely diagnose which binary runs when there is more than one with the same name.

A note on your desk is not the same as a copy in the folder you hand someone

Imagine you work in an office and have notes stuck to your own desk: reminders, data that only serves you while you are sitting there. When you send an assistant to run an errand, you do not hand them your whole desk — you hand them a folder with photocopies of a few specific notes, the ones you decided they need for that task. The assistant leaves with that folder, and while they work, they neither see nor can modify the notes that stayed stuck to your desk. And there is something even subtler: if after the assistant has already left you stick a new note to your desk, they never find out — they work with the folder they had in hand when they left, not with a version updated in real time.

Your shell does exactly this every time you run a program. python3 script.py, git status, any command: the shell launches a child process, and that child receives a copy of certain variables at the instant it is born. The variables you decided to "photocopy and hand over" are environment variables. The ones that stay stuck to your desk, visible only to you while your shell stays alive, are shell variables.

  • Shell variable: exists only inside your current shell's process (bash, zsh, whichever you use). You create it with NAME=value — no spaces around the = — and it disappears when you close that shell. No child process sees it.
  • Environment variable: a shell variable you added the export attribute to. Any process the shell launches from that moment on receives a copy of its value.
  • export is exactly the boundary between the two. export NAME takes an existing shell variable and marks it for export; export NAME=value does both things — assigning and marking — in one step.

Within the same shell, $NAME works the same for both: the $ symbol does not distinguish whether something is exported or not. The difference only shows up when the value has to cross the boundary into a child process: a script, a program, a subshell.

Worked example

# Shell variable: exists only in this shell
GREETING="hello from the parent shell"
echo "Inside this shell: $GREETING"

# A child process (a new subshell) does not see it yet
bash -c 'echo "Inside the child: ${GREETING:-(does not exist)}"'

# export marks the boundary: now it really is an environment variable
export GREETING
bash -c 'echo "Inside the child: ${GREETING:-(does not exist)}"'

# env and printenv only list exported variables, not shell variables
env | grep GREETING
printenv GREETING

# unset deletes the whole variable: value and export attribute
unset GREETING
printenv GREETING
echo "exit code: $?"

What to expect:

Inside this shell: hello from the parent shell
Inside the child: (does not exist)
Inside the child: hello from the parent shell
GREETING=hello from the parent shell
hello from the parent shell
exit code: 1

Read it line by line. The first subshell does not find GREETING because it had not yet crossed the export boundary: it was a note stuck to your desk, not a photocopy in any folder. After the export, the second subshell does receive it, because it is born once the variable already had the export attribute. env (with no arguments, lists the whole environment) and printenv GREETING (asks about one specific variable) only know about exported variables — that is why they are the right tool for confirming whether something really crossed the boundary, instead of trusting only echo $VAR, which distinguishes nothing. And unset does not empty the value: it deletes the entire variable, export attribute included — that is why printenv after the unset prints nothing and returns exit code 1, the standard code for "not found."

The PATH: the only reason python3 finds python3

When you type a command's name and press Enter, the shell does not have a database of "where every program is installed." It has a single environment variable, PATH, with an ordered list of folders, and it does exactly what you would do if you were looking for a document in a filing cabinet with several drawers labeled in a fixed order: it opens the first drawer, looks for the exact name you typed. If it is there, it stops right there — it does not even check the following drawers, even if they also had a file with that name. If it is not there, it moves to the second drawer, and so on. If it goes through every drawer and finds nothing, that is when you see command not found.

Formally: PATH is an environment variable whose value is a list of absolute paths separated by colons (:). The shell walks it left to right and runs the first executable file it finds with the exact name you typed.

Worked example

echo "$PATH"

What to expect (the real value comes on a single line; here it is shown one directory per line so the order reads clearly):

/opt/homebrew/bin
/usr/local/bin
/usr/bin
/bin
/usr/sbin
/sbin

Suppose you have Python installed twice: the copy the system ships at /usr/bin/python3, and another one you installed with a package manager at /opt/homebrew/bin/python3. Both exist on disk at the same time. The question "which one runs?" does not depend on which one you installed more recently or which one is "the good one" — it depends exclusively on PATH's order.

which python3
type -a python3
command -v python3

What to expect:

$ which python3
/opt/homebrew/bin/python3

$ type -a python3
python3 is /opt/homebrew/bin/python3
python3 is /usr/bin/python3

$ command -v python3
/opt/homebrew/bin/python3

which gives you only the first result — the one that really runs if you type python3 right now — because it does the same search the shell does: it stops at the first drawer with a match. type -a is more honest: it shows you every match across the whole PATH, in order, so you can see exactly which version got "covered up" by another one appearing earlier in the list. command -v does, for practical purposes, the same thing as which, but it is a builtin of the shell itself defined by the POSIX standard — not a separate external program, as which usually is — and that is why it is the recommended option inside scripts: it is guaranteed to exist on any POSIX-compliant shell, it recognizes shell aliases and functions in addition to files on disk, and it does not depend on which being installed on that machine.

type with no -a also resolves a special case: commands like cd or export are not files in any PATH drawer — they are the shell's own builtins. which cd on many systems simply finds nothing, while type cd correctly answers cd is a shell builtin. When you are unsure whether something you run is a program on disk, an alias you defined yourself months ago, or a shell function, type is the command that is not going to lie to you.

Common variables you already had, even if you never looked at them

From the moment you opened your first terminal, your shell already came with a handful of environment variables configured — nobody asked you to create them, they exist because the system and the programs you use need them to work.

printenv HOME USER SHELL EDITOR LANG TERM

What to expect (example values — yours are going to vary depending on your system and your configuration):

/home/dev
dev
/bin/zsh
vim
en_US.UTF-8
xterm-256color
  • HOME: your personal directory. It is what cd with no arguments uses, and what the ~ symbol expands to every time you type it in a path.
  • USER: your username according to the system. On some minimal containers it might not be defined — do not assume it always exists if you write a script meant to run in any environment.
  • SHELL: your default login shell's path. Watch out for a nuance that confuses a lot of people: $SHELL is not always the shell you are standing in right now, but the one the system has configured as the default for your user — you can be inside bash with $SHELL pointing at zsh if you opened bash manually.
  • EDITOR: which text editor other programs invoke when they need you to write something — git commit with no -m, crontab -e. If it is not defined, many of those programs fail or fall back to a default editor that might not be the one you expect.
  • LANG: the language and regional format programs use for messages, alphabetical sorting, and date format.
  • TERM: what type of terminal the system believes you are using, and therefore what capabilities (colors, special characters) a program like less or vim can assume when drawing on screen.

Secrets and credentials: why they never go in the history or in the code

Sooner or later you are going to need your program to use an API key or a database password. The two quick paths that tempt you are also the two most common ways to leak a real secret: writing it directly as text inside the source code, or writing it directly in the terminal with something like export API_KEY=sk-live-....

The first path leaves it trapped in git's history forever, even if you delete it in the next commit — the version with the secret is still alive in the repository's history, accessible to anyone who has or gets access to it. The second path leaves it written, in plain text, in the command history file your shell automatically saves to disk — a file that survives long after you close that session. The next lesson shows you exactly how that history works and how to keep a secret from getting recorded there; for now, the operating rule is simple: no real secret ever gets typed on a command line or hardcoded into the source code.

The industry convention for this is an .env file: plain text, at the project's root, with NAME=value lines. It is not shell magic — nothing loads it automatically on its own. It gets read by specific tools (libraries like python-dotenv, docker-compose, Node frameworks) or by you yourself, exporting its content into your current session when you need it.

Worked example

cat > .env <<'EOF'
DATABASE_URL=postgresql://app:supersecret@localhost:5432/appdb
API_KEY=sk-live-not-a-real-key-0000000000
EOF

chmod 600 .env
ls -l .env

What to expect:

-rw------- 1 dev dev 88 Jul 21 09:20 .env

chmod 600 trims the permissions exactly like you saw them in lesson 3: read and write for the owner, and zero permissions for group and others. Without that chmod, a freshly created .env file usually inherits permissions like -rw-r--r--, which leave the secret readable by any other account on the same system — the principle of least privilege applied to a file, not a user.

To bring that content into your current shell as real environment variables:

set -a
source .env
set +a
printenv API_KEY

What to expect:

sk-live-not-a-real-key-0000000000

set -a tells the shell to mark for automatic export every variable created from that point on; source .env runs the file's lines in your current shell, as if you had typed them yourself; set +a turns that automatic mode off the moment it finishes. The .env file, on top of that, never gets committed — it gets added to .gitignore before the project's first commit, not afterward.

Common mistakes

"If echo $VAR shows me the value, it's already an environment variable" (conceptual). What happens: the student defines VAR=something, runs echo $VAR, sees the value, and concludes export is a decorative step that changes nothing real. Later they write a script or a program that depends on that variable and the program cannot find it. Why it happens: within the same shell, $VAR expands the same whether the export attribute exists or not — the difference only exists for child processes, and a plain echo never launches one. How to spot it: if a value "disappears" the moment you use it inside a script, a subshell (bash -c '...'), or any external program, that is the exact sign. How to fix it: verify with env | grep VAR or printenv VAR instead of echo $VAR — those two only list what is actually exported — and if it is missing, add export to it.

Overwriting PATH instead of adding a folder to it (conceptual). What happens: to add a folder of personal scripts, the student writes export PATH=/home/dev/scripts. Seconds later, almost every command — ls, git, cat — starts failing with command not found. Why it happens: that line adds nothing: it replaces PATH's entire value. The only folder the shell is going to check from then on is /home/dev/scripts; every one the system shipped — /usr/bin, /bin, /usr/local/bin — is left out of the search, even though the binaries are still exactly where they were on disk. How to spot it: almost any command stops being found immediately after a line like that, and opening a new terminal solves the problem on its own — because that new terminal reloads its PATH from scratch, with no inheritance of your mistake — which confirms the damage lived only in that shell session. How to fix it: always preserve the existing value when modifying PATH: export PATH="/home/dev/scripts:$PATH" prepends your folder without losing any of the previous ones.

"I installed a new version, but the terminal keeps running the old one" (troubleshooting). What happens: the student installs a newer version of a tool — with a version manager, with a new package — in a folder that should have priority, but running the command still runs the previous binary. Why it happens: there are two different causes and they need to be ruled out in order. The first is simply PATH's order: the new folder might be configured after the old one in the list, so the old one still wins. The second, subtler one, is that bash (and zsh, equivalently) remembers each command you already ran in this session in an internal table, so it does not have to walk the whole PATH every time — if you installed the new program after already having run the old one in this same shell, bash keeps using the location it memorized, even though PATH is perfectly ordered. How to spot it and fix it: run type -a command-name to see every match in your PATH's real order — if the one you expect already shows up first in that list and the other one still runs anyway, the problem is the cached table, not the order. In bash it gets fixed with hash -r, which forces it to forget everything memorized and search from scratch; in zsh the equivalent command is rehash. Opening a new terminal also solves the problem, because that table never survives between sessions.

Exercises

1. Before running anything, predict this script's full output, line by line, and explain why each line is what it is:

COLOR="blue"
bash -c 'echo "Color seen by the child: ${COLOR:-no value}"'
export COLOR
bash -c 'echo "Color seen by the child: ${COLOR:-no value}"'
unset COLOR
bash -c 'echo "Color seen by the child: ${COLOR:-no value}"'
See solution
Color seen by the child: no value
Color seen by the child: blue
Color seen by the child: no value

The first subshell is born before any export exists, so COLOR is still just a shell variable — the child does not inherit it and falls back to the default value (no value). The second subshell is born after the export, so it receives a copy of COLOR at the instant it is born, and prints it. The third subshell is born after the unset, which did not empty the value but deleted the whole variable — export attribute included — so it falls back to the default value again, exactly as at the start. This works because every subshell receives a frozen copy of the environment from the exact moment it is launched, never a live view of whatever happens afterward in the parent shell.

2. Your PATH is /home/dev/.local/bin:/usr/local/bin:/usr/bin:/bin. On disk there are /usr/local/bin/node and /usr/bin/node, but there is no node at all in /home/dev/.local/bin. Which binary runs when you type node, and what would type -a node print?

See solution

/usr/local/bin/node runs. The shell walks PATH left to right: first it checks /home/dev/.local/bin, finds nothing there, and moves on; the next folder, /usr/local/bin, does have a node, so it stops right there — it never gets to check /usr/bin, even though that directory also has a match. type -a node would show both, in that same order:

node is /usr/local/bin/node
node is /usr/bin/node

This works because type -a does not stop at the first result the way the shell's real search does — it shows you every match across the entire PATH, so you can see which one wins and which one stays covered up.

3. A coworker pastes this line into their terminal to add their personal scripts folder:

export PATH=/home/dev/scripts

Right after, almost every command — including ls — fails with command not found. What happened, and how would you fix it both to unblock that terminal right now and to do it right next time?

See solution

That line does not add a folder: it replaces PATH's entire value. From that moment the shell only looks for commands inside /home/dev/scripts; every folder it had before — /usr/bin, /bin, /usr/local/bin — got left out of the search, so even ls stops being found, even though the binary is still exactly in the same spot on disk. To unblock that terminal right now, the simplest way is closing it and opening a new one: a new terminal reloads PATH from scratch in its own startup files, with no inheritance of the mistake typed by hand in the previous session. The correct way to add a folder is always preserving the existing value: export PATH="/home/dev/scripts:$PATH" prepends the new folder without losing any of the previous ones. This works because PATH is a single variable with a whole list inside — modifying it correctly always means starting from the value it already had ($PATH) and adding something, never replacing it entirely.

4. You see this listing a file in your project:

$ ls -l .env
-rw-r--r-- 1 dev dev 64 Jul 21 09:20 .env

That file stores a database password. What is wrong with these permissions, and what single command fixes them?

See solution

-rw-r--r-- grants read permission to both the group and "others" — the r in the fifth and eighth positions — meaning any other account on the same system can open the file and read the password in plain text, even though only the owner should ever need to touch it. The command that fixes it is chmod 600 .env, which leaves read and write for the owner only and strips all permission from group and others. This works because it is the same rwx/octal model from lesson 3, applied here to a file storing a secret instead of code — the principle of least privilege makes no distinction between the two cases.

Summary and next step

Today you saw that export is the exact boundary between a shell variable — visible only within your own session — and an environment variable, which a child process receives as a frozen copy at the instant it is born, not as a live view of whatever happens afterward. You saw that PATH is nothing more than an ordered list of folders the shell walks left to right, stopping at the first match, and that command not found almost always means "it is not in any folder on this list" or "it got covered up by another version appearing earlier." You saw which, type, and command -v as three different ways of diagnosing which binary wins when more than one is installed, and you saw that a real secret only belongs in an .env file with 600 permissions, never in the source code or in a hand-typed command line.

Before moving on you should be able to: explain from memory, with no notes, the difference between a shell variable and an environment variable; read your own $PATH and confidently say which folder wins if two have a binary with the same name installed; use type -a to diagnose why an "old" version of a program keeps running after installing a new one; and create a test .env with 600 permissions.

What you learned today — that every new shell starts with its own PATH and its own environment, and that a change you type by hand lives only as long as that session stays open — is exactly the problem the next lesson solves: why "it works in one terminal and not in another" almost always means one terminal loaded a startup file — .bashrc, .zshrc, .bash_profile — the other did not, and how to make a PATH change survive beyond the session you typed it in.

Resources