skip to content

What does the `#!/bin/sh` line at the top of a script actually cause to happen when the file is executed, and how does choosing `#!/bin/sh`, `#!/bin/bash` or `#!/usr/bin/env bash` change which interpreter ends up running it?

level: juniorimportance: should knowfreq 60%

answer

  1. first two bytes decide everything
  2. it names a program, not a language
  3. the path is not searched — except with env
  4. explicit sh script.sh ignores the line

basics

~20 s

The kernel reads the first line, and if it starts with #! it runs the named interpreter with the script as an argument. /bin/sh means whatever POSIX-ish shell that path holds, /bin/bash demands bash at that exact path, and /usr/bin/env bash finds the first bash on PATH.

solid answer

~50 s

When you execute a script directly, the kernel looks at its first two bytes. If they are `#!`, it treats the rest of the line as the interpreter to run, and effectively executes `"/bin/sh" "./script"`. So the shebang selects a *program by absolute path*, not a language. `#!/bin/sh` is a promise that the script is POSIX, because you are accepting whatever sh is on that host — dash on Debian, bash on RHEL, BusyBox ash on Alpine. `#!/bin/bash` says bash is a hard dependency and must exist at that exact path; that is true on Linux but not on FreeBSD or NixOS, where bash lives elsewhere. `#!/usr/bin/env bash` searches `PATH` instead, which is how you pick up a newer Homebrew bash on macOS — at the cost of running whatever bash the caller's PATH happens to offer. Two gotchas: the shebang is ignored entirely if you run `sh script.sh`, and Linux passes at most one argument after the interpreter path.

code

bash · 8 lines
bash
cat > /tmp/demo.sh <<'EOF'
#!/bin/bash
echo "$0 ran under: ${BASH_VERSION:-not bash}"
EOF
chmod +x /tmp/demo.sh

/tmp/demo.sh      # shebang honoured -> bash prints a version
sh /tmp/demo.sh   # shebang ignored -> runs under whatever sh is

go deeper

for a junior

Be able to say the first line names the interpreter the kernel runs, and to pick between sh and bash deliberately rather than copying whatever the last script had.

for a middle

Explain the mechanics — the ENOEXEC fallback, the single-argument limit, PATH lookup via env — and articulate what promise #!/bin/sh makes about the script's contents.

for a senior

Show that you treat the shebang as a dependency declaration and know where it fails to hold: sudo and cron PATHs, FreeBSD and Nix layouts, minimal images without bash, and callers that invoke the interpreter explicitly.

for a principal

Own it as a standard: one shebang convention per class of script, enforced in review or CI, so that interpreter selection is never left to whatever happened to be on the author's machine.

## What the kernel does A shell script is not a binary format the kernel understands, so `execve()` on one would normally fail. The escape hatch is the interpreter line. The kernel reads the start of the file, and if the first two bytes are `#!` it takes the rest of that line as an interpreter path plus (optionally) one argument, and executes *that* program with the script's path appended. Running `./deploy.sh` with `#!/bin/sh` on top becomes, in effect: ```sh /bin/sh ./deploy.sh ``` The `#!` line is a comment to the shell itself, which is why the trick works at all: the interpreter reads the file from the top and simply skips it. If a script has **no** shebang, `execve()` fails with `ENOEXEC`. A POSIX shell that was trying to run it catches that error and re-runs the file with a shell — which is why a shebang-less script often "works" from your terminal and then fails the moment something other than a shell (a language runtime, a supervisor, a container runtime) tries to exec it. ## The shebang is a path, not a language This is the part candidates skip. `#!/bin/sh` does not request "POSIX shell semantics"; it requests the file at `/bin/sh`, which is dash on Debian and Ubuntu, bash on RHEL and Fedora, and a BusyBox applet on Alpine. Writing `#!/bin/sh` is therefore a *promise you are making*: this script uses only the POSIX shell command language, so any of those is acceptable. If the script uses `[[ ]]`, arrays or `source`, the shebang is a lie that happens to be tolerated wherever sh is bash. ## The three common choices **`#!/bin/sh`** — maximum reach, minimum language. Correct for container entrypoints, init and packaging scripts, and anything that may run on an image where bash was never installed. It obliges you to actually check portability rather than assume it. **`#!/bin/bash`** — an explicit dependency on bash at that exact path. Accurate and hermetic on Linux distributions, where bash really is `/bin/bash`. It fails on systems where bash is not a base-system program: on FreeBSD a package-installed bash is `/usr/local/bin/bash`, and on NixOS binaries live in the store rather than `/bin`. On Alpine you must `apk add bash` first. **`#!/usr/bin/env bash`** — `env` is at a stable path on essentially every Unix, and it looks up `bash` in `PATH` and execs it. This is the idiom for scripts that must work across Linux, macOS and BSD, and it is the only way to pick up a Homebrew-installed bash 5 on a Mac whose `/bin/bash` is still 3.2. The tradeoff is that you now depend on the caller's `PATH`: under `sudo`, cron, or a hardened service the resolved interpreter may not be the one you tested with. It is also unhelpful for security-sensitive scripts, since it hands interpreter selection to the environment. ## Two mechanics worth knowing **Arguments.** Linux allows the interpreter plus at most one argument, and the whole remainder is passed as a single string. `#!/bin/sh -e` works. `#!/usr/bin/env bash -e` typically does not, because `env` receives the literal argument `bash -e` and looks for a program with a space in its name. (GNU coreutils added `env -S` to split that string, but it is not universally available.) Linux also truncates the shebang line at 127 characters — long enough to be forgotten and short enough to bite in deeply nested store paths. **Explicit invocation wins.** `sh deploy.sh` or `bash deploy.sh` ignores the shebang completely; it is only a comment to a shell that was already chosen. So a script marked `#!/bin/bash` will happily be fed to dash by a Makefile line, a CI step, or a colleague, and fail there. Likewise, a script's execute bit and shebang matter only when something execs the file — sourcing it with `.` runs it in the *current* shell regardless of what the first line says. ## Practical rule Pick `#!/bin/sh` and write POSIX, or pick a bash shebang and mean it. State the choice, keep the script honest to it, and let a linter enforce the pairing — the failure mode of getting this wrong is never local, it is on the one machine that has a different `/bin/sh`.

  • What happens if a script has no shebang at all?
    Execing it fails with `ENOEXEC` because the kernel has no idea how to run it. A POSIX shell that hit that error falls back to running the file with a shell, which is why it seems to work from a terminal — but a container runtime, a language's process API or a supervisor calling exec directly will just report an exec format error.
  • Why is `#!/usr/bin/env bash -e` usually broken?
    Linux passes everything after the interpreter path as one argument, so `env` receives the single string `bash -e` and searches for a command with that literal name. Put the options inside the script instead (`set -e` on the next line), or use `env -S` where GNU coreutils provides it.
  • When would you prefer `#!/bin/bash` over `#!/usr/bin/env bash`?
    When you want the interpreter pinned rather than negotiated: system and packaging scripts, anything running under sudo or cron where PATH is not yours, and Linux-only tooling where `/bin/bash` is guaranteed. `env` is the better choice when the script must cross macOS and BSD or must pick up a newer bash than the base system's.

saying these in an interview costs you the question

  • Believes #!/bin/sh forces POSIX-only behaviour on the shell
  • Thinks the interpreter path is searched in PATH
  • Says the shebang still applies when you run sh script.sh
  • Assumes /bin/bash exists on every Unix system
  • Puts multiple options after env in the shebang line

context