Three things get called “the terminal” and they are different. The terminal emulator is the window. The shell is a program running inside it that reads what you type and decides what to do. The commands are separate programs the shell finds and runs on your behalf.

The distinction sounds academic until something goes wrong. An alias that works in your terminal but not in a script, a PATH that is right when you log in and wrong over SSH, a colleague’s dotfile that does nothing on your machine — all of these are shell behaviour, and none of them make sense without knowing which file runs when.

What happens to a line you type

When you press Enter, the shell does several things in a fixed order before anything runs:

  1. Expands what you wrote — wildcards become filenames, $HOME becomes a path, $(...) is run and replaced by its output.
  2. Splits the result into words.
  3. Decides what the first word is — an alias, a function, a builtin, or a program on disk.
  4. Sets up redirection — pipes and > are the shell’s work, not the command’s.
  5. Runs it and waits.

Step one explains the single most common source of surprise. The command never sees your wildcard. Type ls *.txt and the shell replaces *.txt with the matching filenames first; ls receives a list and has no idea a wildcard was involved.

That is why find . -name "*.txt" needs quotes — without them the shell expands the pattern before find can use it — and why echo is the best debugging tool you have:

# See exactly what the command will receive
echo rm -rf /var/log/*.gz

# Which kind of thing is this?
type ls
type cd
type my_function

type answers step three. It tells you whether a name is an alias, a shell builtin, a function or a file — which explains why man cd fails (cd is a builtin, not a program) and why your alias does not apply inside a script.

Which startup file runs when

This is the part that wastes hours, and it comes down to two independent questions the shell asks about itself: is this a login shell? and is this interactive?

How you got a shellLogin?Interactive?Reads
SSH into a serverYesYes~/.bash_profile (or ~/.profile)
Opening a terminal windowNoYes~/.bashrc
Running a scriptNoNoNeither
ssh host 'command'NoNoNeither
A cron jobNoNoNeither

Three consequences follow, and between them they explain most “but it works when I type it” bugs.

A cron job gets almost none of your environment. No aliases, no functions, and a minimal PATH that often excludes /usr/local/bin. This is why a script that works perfectly by hand fails silently at 3am — see cron, where the fix is to use absolute paths in scheduled jobs.

ssh host 'command' is not the same as logging in and typing it. The first is non-interactive and reads no startup files at all.

Aliases do not exist in scripts, by design. If you want something available everywhere, make it a small script in ~/.local/bin rather than an alias — see the Linux filesystem.

The conventional fix for the login-versus-interactive split is to put everything in ~/.bashrc and have ~/.bash_profile source it:

# ~/.bash_profile
[ -f ~/.bashrc ] && . ~/.bashrc

Most distributions ship this already. If your prompt and aliases work in a local terminal but vanish over SSH, that line is missing.

Environment variables and PATH

Variables belong to the shell. export is what passes one down to programs the shell starts — without it, the variable exists for you and is invisible to everything you run.

NAME="value"          # this shell only
export NAME="value"   # this shell and everything it starts

# What is in the environment?
printenv
echo "$PATH"

# Which of these will actually run?
which python3
type -a python3

PATH is a colon-separated list of directories searched in order, first match wins. type -a shows every match, which is how you diagnose “I installed a newer version and it still runs the old one”.

# Add a directory, keeping the rest
export PATH="$HOME/.local/bin:$PATH"

Keep the :$PATH on the end. Writing export PATH="$HOME/.local/bin" replaces the entire search path, and the next command you type will not be found. It is a memorable five minutes.

A related rule: a child process cannot change its parent’s environment. A script that sets a variable cannot affect the shell that ran it. That is why tools like nvm tell you to source them rather than execute them, and why cd has to be a builtin — a separate program could not change your shell’s directory.

bash, zsh and fish

bashzshfish
Installed by defaultAlmost alwaysSometimesNo
POSIX compatibleYesYesNo
Runs existing scriptsYesYesOften not
Tab completionBasicExcellentExcellent
Suggestions as you typeNoWith a pluginBuilt in
Good defaultsNoAfter configurationYes
Setup effortNoneSomeNone

bash is the default nearly everywhere and the one to know. Scripts you write in it run on any Linux machine, and its behaviour is what every tutorial assumes. It is not a pleasant interactive shell out of the box, which is why the other two exist.

zsh is mostly bash-compatible with far better completion — it completes command options, git branches and remote paths over SSH. Most bash knowledge transfers directly. The usual complaint is that it needs configuring to be good, which frameworks like oh-my-zsh solve at the cost of slowing your prompt down.

fish is the nicest to use and deliberately not POSIX compatible. Autosuggestions from your history, syntax highlighting as you type, and sensible behaviour with no configuration at all. The cost is real: export VAR=value does not work, && is and, and pasted snippets from the internet frequently fail.

The compromise most fish users settle on: use fish interactively, write scripts with #!/bin/bash. A script’s shebang decides which shell runs it regardless of what you use, so the two do not conflict.

# Try one without committing
fish
exit

# Change your login shell for good
chsh -s /usr/bin/fish

# What are my options?
cat /etc/shells

Do not change root’s shell, and think twice before changing it on a server. Root’s shell is part of your recovery path, and a shell that fails to load leaves you unable to log in. Change it on your own account, on your own machine.

Making bash better without switching

Most of what people leave bash for can be fixed in ten lines of ~/.bashrc:

# A history worth searching
HISTSIZE=50000
HISTFILESIZE=100000
HISTCONTROL=ignoreboth:erasedups
shopt -s histappend

# Correct small typos in cd, and cd by typing a directory name
shopt -s cdspell autocd

# ** matches across directories
shopt -s globstar

# Do not overwrite a file with >
set -o noclobber

Add fzf on top and bash’s weakest feature — searching what you did last week — becomes its strongest. That combination closes most of the gap to zsh for a fraction of the setup.

Things that catch people out

SymptomCause
Works by hand, fails in cronCron reads no startup files; use absolute paths
Alias does not work in a scriptAliases are interactive only
Prompt fine locally, plain over SSH~/.bash_profile does not source ~/.bashrc
“command not found” after editing PATHYou replaced it instead of appending
Wrong version of a program runsPATH order — check type -a
Variable set by a script disappearsYou executed it; you needed to source it
Changes to .bashrc do nothingOpen a new shell, or source ~/.bashrc

If you want the full picture — all eleven stages between pressing Enter and getting an exit status, including why quoting is settled before any expansion happens, what word splitting does to an unquoted variable, and every exemption set -e quietly makes — that is a separate, much longer article: The Shell, in Depth: From Enter to Exit Status.

Quick reference

You wantCommand
Which shell am I in?echo $0
Alias, builtin or program?type -a name
Preview what a command will getecho in front of it
Reload your configsource ~/.bashrc
Add to PATH safelyexport PATH="$HOME/.local/bin:$PATH"
See the environmentprintenv
Available shellscat /etc/shells
Change your login shellchsh -s /usr/bin/zsh

Related reading