Skip to content

Bash

Bash is a program that reads lines of text and turns them into running processes. Learning it well means learning two things at once: a command language you type interactively, and a full programming language you write scripts in.

A shell is a command interpreter. It sits between you and the operating system kernel and does four jobs, in this order, for every line you type:

  1. Read a line of text.
  2. Expand it — replace variables, run command substitutions, expand globs like *.txt into filenames, split the result into words.
  3. Execute — find the program, fork() a child process, exec() the program in it, and wait.
  4. Report — collect the child’s exit status into $? and print the prompt again.

The critical insight is step 2. The shell does a lot of text rewriting before any program runs. grep foo *.log never reaches grep as three words — the shell expands *.log first, so grep might actually receive twelve arguments. Nearly every confusing bash bug is a misunderstanding of what happened during expansion.

The programs you run (ls, grep, curl) are separate executables on disk. The shell only contributes the glue: expansion, redirection, pipes, control flow, and a handful of builtins (cd, export, read) that must run inside the shell itself because they change the shell’s own state.

sh is a specification (POSIX), not a single program. Bash and zsh are implementations that add their own extensions on top.

What it is Where you meet it
sh The POSIX shell standard. Minimal, portable, no arrays worth using, no [[ ]]. #!/bin/sh scripts, Docker RUN, system() calls. On Debian/Ubuntu /bin/sh is dash, a small fast POSIX shell — not bash.
bash GNU Bourne-Again SHell. The de-facto Linux default and the subject of this section. Adds [[ ]], arrays, $(( )), process substitution, local. Default login shell on most Linux distros; /bin/bash.
zsh A different superset with better interactive features (completion, globbing, prompts). Mostly bash-compatible for simple scripts, but not a drop-in: array indices start at 1, unquoted variables are not word-split. Default interactive shell on macOS since Catalina.

macOS ships /bin/bash version 3.2 (frozen in 2007 for licensing reasons). Anything from bash 4 onwards — associative arrays, ${var,,}, mapfile, &>> — is missing there. Install a modern bash with Homebrew and use the env-style shebang so scripts pick it up.

The same binary runs in two very different modes.

  • Interactive: attached to a terminal, prints a prompt, has job control, expands aliases, reads history. Started when you open a terminal, or with bash -i.
  • Non-interactive: reads commands from a file or -c, no prompt, aliases are off by default, job control is off. This is script mode.

Test which you are in — $- holds the current option flags, and interactive shells include i:

Terminal window
case "$-" in
*i*) echo "interactive" ;;
*) echo "script" ;;
esac

Bash also distinguishes login shells (started at sign-in, or with -l) from non-login ones. This determines which startup files run, which is why “my alias works in the terminal but not over SSH” happens.

Shell kind Files read (in order)
Interactive login /etc/profile, then the first of ~/.bash_profile, ~/.bash_login, ~/.profile
Interactive non-login /etc/bash.bashrc (Debian-family), then ~/.bashrc
Non-interactive (scripts) Nothing — unless BASH_ENV is set to a filename

Because scripts read no startup files, a script cannot rely on your aliases, functions, or PATH tweaks. Everything a script needs must be defined in the script.

hello.sh
#!/usr/bin/env bash
set -euo pipefail
name="${1:-world}"
printf 'Hello, %s!\n' "$name"

The first line is the shebang (#!). It is not a comment to the shell — it is read by the kernel. When you execute a file, the kernel looks at the first two bytes; if they are #!, it runs the interpreter named on that line and passes the script’s path as an argument. So ./hello.sh bob effectively becomes /usr/bin/env bash ./hello.sh bob.

Two common forms:

#!/bin/bash # exact path; fails where bash lives elsewhere (e.g. NixOS, some BSDs)
#!/usr/bin/env bash # looks bash up in PATH; picks a newer bash on macOS/Homebrew

#!/usr/bin/env bash is the recommended default. Its one trade-off: it obeys PATH, so it can pick up an unexpected bash in a strange environment.

Other shebang facts worth knowing: Linux truncates the shebang line at 127 characters, and a file with no shebang that is executed directly is run by bash as a bash script (this is a bash courtesy, not a kernel rule — do not rely on it).

File permissions decide whether ./hello.sh is allowed at all.

Terminal window
chmod +x hello.sh # add execute for user, group, other (masked by umask)
chmod 755 hello.sh # explicit: rwx for owner, r-x for everyone else
ls -l hello.sh
# -rwxr-xr-x 1 you you 96 Jan 5 10:11 hello.sh

chmod u+x hello.sh restricts the execute bit to the owner only, which is the safer default for scripts holding anything sensitive.

Terminal window
bash hello.sh bob # 1. run with an explicit interpreter
./hello.sh bob # 2. execute the file (needs +x and a shebang)
source hello.sh bob # 3. read it into the *current* shell
. hello.sh bob # same as source; `.` is the POSIX spelling
Method New process? Needs +x? Shebang used? Sees/changes your shell’s variables
bash script.sh Yes (child bash) No No — ignored, it is just a comment No
./script.sh Yes (child bash) Yes Yes No
source script.sh No No No Yes

The first two run the script in a child process. The child gets a copy of your exported environment; anything it changes — the current directory, variables, PATH — dies with it. That is why a script cannot change the directory of the shell that called it.

source is the opposite: bash reads the file’s lines as if you had typed them. Use it for things that must affect the current shell — activating a virtualenv, loading ~/.bashrc, defining functions.

Terminal window
# demo.sh contains: cd /tmp; export FOO=1
./demo.sh ; pwd; echo "${FOO:-unset}" # => your original dir, "unset"
source demo.sh ; pwd; echo "${FOO:-unset}" # => /tmp, "1"

Note the exit status: a sourced file’s status is that of the last command it ran, and exit inside a sourced file exits your shell. Use return in sourced files.

Bash has more built-in documentation than most tools, and knowing which source answers which question saves a lot of guessing.

Terminal window
man bash # the complete reference (~5000 lines) — search inside it with /
help # list all shell builtins
help test # help for a *builtin*; man pages do not cover these
help -m read # same, formatted like a man page
type -a echo # how bash would resolve this name — every match, in order
command -v ls # the single thing that would run (script-friendly, POSIX)
ls --help # most GNU tools' own summary
apropos json # search man page descriptions by keyword
info bash # the full GNU manual, more readable than man bash

type is the tool that answers “why did that command do something unexpected?” — it distinguishes aliases, functions, builtins, keywords, and files:

Terminal window
type -t if # => keyword
type -t cd # => builtin
type -a ls # => ls is aliased to `ls --color=auto'
# ls is /usr/bin/ls
  • The shell expands text before running anything; understanding expansion explains most surprises.
  • /bin/sh is not bash. Choose your shebang deliberately: #!/usr/bin/env bash for bash features.
  • Scripts run in a child process and read no startup files — they inherit only exported variables.
  • source runs code in your current shell; ./script.sh does not.
  • help, type -a, and man bash answer nearly every “what is this thing” question locally.