intermediate19 min

Environment Variables, PATH, and Executable Files

The variables every process inherits, PATH's role in turning a bare command name into a real program, and what actually makes a file executable in the first place.

What you'll learn

  • Explain the difference between a shell variable and an exported environment variable
  • Explain precisely how PATH resolves a bare command name to a real executable file
  • Explain what the executable permission bit actually controls, and why a script needs it even with a correct shebang

Prerequisites

Explanation

Every real command below runs only in your own terminal — this lesson's exercises model PATH resolution and permission bits as data, never executing anything.

A plain shell variable (NAME=value) exists only in the current shell session — a program launched from that shell doesn't automatically see it. export NAME=value (or export NAME after already setting it) makes it an environment variable, which is inherited by any child process launched from that shell going forward. This distinction matters precisely because it explains a genuinely common confusion: setting a variable, then being surprised a script or program you run doesn't see it — the fix is exporting it, not just setting it.

PATH is itself an environment variable holding a colon-separated list of directories (/usr/local/bin:/usr/bin:/bin, for instance) — when you type a bare command name (python3, git) with no / in it, the shell searches each directory in PATH, in order, for an executable file with that exact name, and runs the first match found. This is exactly why the order of directories in PATH matters: if two directories both contain a program named python3, whichever directory comes first in PATH wins, silently — a common, real cause of "why is the wrong version running" confusion, especially once multiple tools (a system Python, a version manager's Python, a virtual environment's Python) are all installed and all potentially reachable via PATH.

A file needs the executable permission bit set (chmod +x script.sh) before it can be run directly (./script.sh) — this is a genuinely separate, additional requirement from having correct content: a perfectly correct script with a valid #!/bin/bash shebang line still fails with a "Permission denied" error if the executable bit isn't set, because the operating system checks that permission bit before it even looks at the file's content to figure out how to run it. The shebang line itself (#!/bin/bash or #!/usr/bin/env bash, the latter searching PATH for bash rather than assuming a fixed location — generally the more portable choice) tells the OS which interpreter to hand the script's content to, but only once the executable bit has already granted permission to run it at all.

Example

Modeling PATH's first-match-wins resolution and the separate executable-bit check, as pure logic -- no real filesystem or process is involved.

function resolveCommand(commandName, pathDirs, filesPerDir) {
  // pathDirs: an ORDERED array of directories; filesPerDir: { dirName: [file names present] }
  for (const dir of pathDirs) {
    if ((filesPerDir[dir] ?? []).includes(commandName)) {
      return dir + "/" + commandName; // FIRST match wins -- search stops immediately
    }
  }
  return null; // "command not found"
}

const pathDirs = ["/usr/local/bin", "/usr/bin", "/bin"];
const files = { "/usr/local/bin": ["python3"], "/usr/bin": ["python3", "git"], "/bin": ["ls"] };
console.log(resolveCommand("python3", pathDirs, files)); // "/usr/local/bin/python3" -- found FIRST, /usr/bin's copy never even checked
console.log(resolveCommand("nonexistent", pathDirs, files)); // null -- "command not found"

function canExecuteDirectly(hasExecuteBit, hasValidShebang) {
  // The OS checks the executable bit BEFORE ever looking at the shebang/content.
  if (!hasExecuteBit) return { canRun: false, reason: "Permission denied -- execute bit not set" };
  if (!hasValidShebang) return { canRun: false, reason: "no interpreter specified" };
  return { canRun: true, reason: "ok" };
}
console.log(canExecuteDirectly(false, true)); // Permission denied, EVEN with a perfectly correct shebang

Try it yourself

Call canExecuteDirectly with hasExecuteBit true but hasValidShebang false, and observe the different failure reason.

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

This models PATH resolution only -- no real filesystem lookup happens. Write resolveCommand(commandName, pathDirs, filesPerDir) that returns the full path of the FIRST directory (in pathDirs order) containing commandName, or null if no directory has it.

Checks: the first matching directory in PATH order wins · finds a command only present in a later directory · returns null for a command not found anywhere in PATH

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

This models environment-variable export/inheritance only -- no real process is launched. Write childProcessSees(parentVars, exportedNames, varName) returning true only if varName exists in parentVars AND is listed in exportedNames (modeling that only EXPORTED variables are visible to a child process, not every shell variable).

Checks: an exported variable is visible to a child process · a plain, non-exported variable is not visible to a child process · a never-set variable is not visible

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Setting a variable (NAME=value) and expecting a script or program launched from that shell to see it, without exporting it -- only EXPORTED variables are inherited by child processes; a plain shell variable stays local to the current shell session.
  • Being surprised the 'wrong' version of a tool runs when multiple installations exist -- PATH resolution stops at the FIRST match found, searching directories in order; whichever directory comes first in PATH silently wins, regardless of which installation you actually intended.
  • Assuming a script with a correct shebang line will run once you try `./script.sh`, without checking the executable bit -- the OS checks permission to execute BEFORE it ever looks at the shebang or file content at all; chmod +x is a separate, required step.

Knowledge check

Knowledge check

1. What is the practical difference between a plain shell variable (NAME=value) and an exported environment variable (export NAME=value)?
2. If two directories in PATH both contain a program named `python3`, which one actually runs when you type `python3`?
3. A script has a perfectly correct `#!/bin/bash` shebang line but running `./script.sh` still fails with 'Permission denied.' Why?

Takeaway

Only exported variables are inherited by child processes, not every shell variable; PATH resolution is a first-match-wins search through an ordered directory list, so order matters when multiple installations exist; and the executable permission bit is checked before the shebang line, meaning a perfectly correct script still needs chmod +x to run directly.

Summary

export NAME=value makes a variable part of the environment inherited by child processes; a plain NAME=value stays local to the current shell. PATH is a colon-separated, ordered list of directories searched for a bare command name, stopping at the first match. A file needs its executable bit set (chmod +x) before it can run directly, checked before the shebang line is even read.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.