4 min read
On this page

Shell Fundamentals

Why This Matters

Most engineers learn shell skills through osmosis — picking up a command here, a trick there, over years of stumbling through Stack Overflow answers. This is wildly inefficient. The core concepts fit in your head in a day, and they pay dividends for your entire career.

The shell is the most universal developer interface. Languages come and go, frameworks churn, but pipes, redirects, and environment variables work the same way they did 40 years ago. Every server you SSH into, every Docker container you debug, every CI pipeline you troubleshoot — they all speak shell.

If you can't navigate a terminal fluently, you're working with one hand tied behind your back.

Pipes

Pipes are the shell's killer feature. They let you chain simple commands into powerful compositions.

The pipe operator | takes the standard output (stdout) of the command on the left and feeds it as standard input (stdin) to the command on the right.

# Count how many Go files are in the project
find . -name "*.go" | wc -l

# Find the 10 largest files in the current directory tree
du -ah . | sort -rh | head -10

# Show unique HTTP status codes from an access log, sorted by frequency
awk '{print $9}' access.log | sort | uniq -c | sort -rn

# Find all TODO comments and show just the filenames
grep -r "TODO" src/ | cut -d: -f1 | sort -u

The philosophy behind pipes is the Unix philosophy: each tool does one thing well, and pipes let you compose them. grep searches, sort sorts, uniq deduplicates, wc counts. Learn these small tools and you can build almost any text-processing pipeline on the fly.

Common pipe commands worth knowing

grep    - Search for patterns
sort    - Sort lines
uniq    - Remove adjacent duplicates (use with sort)
wc      - Count lines, words, characters
head    - Show first N lines
tail    - Show last N lines (tail -f for live following)
cut     - Extract columns from text
awk     - Pattern scanning and processing
sed     - Stream editing (find and replace)
tr      - Translate or delete characters
xargs   - Build command lines from stdin
tee     - Write to both stdout and a file

Redirects

Redirects control where output goes and where input comes from.

Every process has three standard streams:

stdin  (0) - Standard input
stdout (1) - Standard output
stderr (2) - Standard error

Output redirection

# Write stdout to a file (overwrites)
echo "hello" > output.txt

# Append stdout to a file
echo "world" >> output.txt

# Redirect stderr to a file
make build 2> errors.log

# Redirect both stdout and stderr to the same file
make build > build.log 2>&1

# Modern bash shorthand for the above
make build &> build.log

# Discard all output
make build > /dev/null 2>&1

Input redirection

# Feed a file as stdin
sort < unsorted.txt

# Here document (multi-line input)
cat << EOF
This is line one.
This is line two.
EOF

# Here string (single-line input)
grep "error" <<< "this is an error message"

Useful patterns

# Save output to a file AND see it on screen
make build 2>&1 | tee build.log

# Run a command only if the previous one succeeded
make build && make test

# Run a command only if the previous one failed
make build || echo "Build failed!"

Environment Variables

Environment variables are key-value pairs that configure how programs behave. They're inherited by child processes, which is what makes them useful for configuration.

# Set a variable for the current session
export DATABASE_URL="postgres://localhost:5432/mydb"

# Set a variable for a single command only
DATABASE_URL="postgres://test:5432/testdb" python manage.py test

# View a variable
echo $DATABASE_URL

# View all environment variables
env

# Unset a variable
unset DATABASE_URL

Variables vs environment variables

There's a subtle but important distinction:

# Shell variable (only visible in current shell)
MY_VAR="hello"

# Environment variable (visible to child processes)
export MY_VAR="hello"

If you set a variable without export, scripts and programs you launch won't see it. This is a common source of confusion when configuring applications.

Important environment variables

PATH        - Where the shell looks for executables
HOME        - Your home directory
USER        - Your username
SHELL       - Your default shell
EDITOR      - Your preferred text editor
TERM        - Your terminal type
LANG        - Your locale settings

PATH

PATH is the most important environment variable you'll deal with. When you type a command like python or node, the shell searches the directories listed in PATH, in order, until it finds a matching executable.

# View your PATH (colon-separated list of directories)
echo $PATH

# Typical output:
# /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

# Add a directory to PATH (prepend — searched first)
export PATH="$HOME/.local/bin:$PATH"

# Add a directory to PATH (append — searched last)
export PATH="$PATH:$HOME/go/bin"

# Find which executable the shell will use
which python
# /usr/local/bin/python

# See ALL matching executables in PATH
which -a python

Debugging PATH issues

When a command "isn't found" or the wrong version runs, it's almost always a PATH issue.

# "command not found" — the binary isn't in any PATH directory
# Fix: find where it's installed and add that directory to PATH

# Wrong version running — another version appears earlier in PATH
# Fix: reorder PATH so the correct directory comes first

# Changes don't persist — you edited PATH in the shell, not in your rc file
# Fix: add the export line to ~/.bashrc or ~/.zshrc

RC Files

RC files (run commands) are scripts that execute when your shell starts. They're where you configure your shell environment.

Which file to edit

This depends on your shell and how you start it:

Bash:
  ~/.bashrc        - Interactive non-login shells
  ~/.bash_profile  - Login shells (often sources .bashrc)

Zsh:
  ~/.zshrc         - Interactive shells (most common to edit)
  ~/.zshenv        - All shells (including non-interactive)
  ~/.zprofile      - Login shells

Fish:
  ~/.config/fish/config.fish - All interactive shells

For most purposes, edit ~/.zshrc (macOS default) or ~/.bashrc (Linux default). If you're not sure which shell you're using, run echo $SHELL.

What goes in your RC file

# PATH additions
export PATH="$HOME/.local/bin:$PATH"
export PATH="$HOME/go/bin:$PATH"

# Environment variables
export EDITOR="vim"
export LANG="en_US.UTF-8"

# Aliases (covered in detail in the next section)
alias ll="ls -la"
alias gs="git status"

# Tool initialization
eval "$(starship init zsh)"
source "$HOME/.cargo/env"

# Custom functions
mkcd() {
    mkdir -p "$1" && cd "$1"
}

Reloading changes

After editing your RC file, either open a new terminal or source the file:

source ~/.zshrc
# or
source ~/.bashrc

Practical Patterns

Command substitution

Use the output of a command as part of another command:

# Create a directory with today's date
mkdir "backup-$(date +%Y-%m-%d)"

# Kill a process by name
kill $(pgrep -f "my-server")

# Set a variable from command output
CURRENT_BRANCH=$(git branch --show-current)

Brace expansion

Generate multiple strings without repetition:

# Create multiple directories
mkdir -p project/{src,test,docs,config}

# Create numbered files
touch file_{01..10}.txt

# Rename with backup
cp config.yaml{,.backup}
# Expands to: cp config.yaml config.yaml.backup

Process management & history

Run a command in the background with &, list jobs with jobs, bring one forward with fg %1. Suspend a running process with Ctrl+Z, then bg to continue it in the background.

Search your command history with Ctrl+R — start typing and it finds the most recent match. Run !git to re-execute the last command starting with "git". Use ^old^new to re-run the last command with a substitution.

Common Pitfalls

Editing the wrong RC file. On macOS with Zsh, edit ~/.zshrc. On Linux with Bash, edit ~/.bashrc. Editing ~/.bash_profile when you should be editing ~/.bashrc (or vice versa) is a classic source of "it works in one terminal but not another."

Forgetting to export environment variables. If a program can't see a variable you set, you probably forgot export. Without it, the variable exists only in the current shell, not in child processes.

Overwriting PATH instead of appending to it. Never do export PATH="/my/new/path" — this replaces your entire PATH and breaks everything. Always do export PATH="/my/new/path:$PATH".

Not quoting variables. $MY_VAR without quotes can break on spaces or special characters. Use "$MY_VAR" in almost all cases.

Ignoring exit codes. Every command returns an exit code (0 for success, non-zero for failure). Use $? to check it, and use && to chain commands that depend on each other.

Key Takeaways

Pipes let you compose simple tools into powerful pipelines. Learn the core utilities (grep, sort, uniq, awk, sed, xargs) and you can process almost any text data without writing a script.

Redirects give you control over where data flows. Understanding stdout, stderr, and the difference between > and >> prevents data loss and simplifies debugging.

PATH determines which programs your shell can find. When something isn't working, PATH is the first thing to check.

Your RC file is your shell's configuration. Invest time in setting it up properly — every terminal you open for the rest of your career will benefit.

These are not advanced topics. They're fundamentals that most engineers learn piecemeal over years. Spend a focused afternoon with them and you'll be ahead of engineers with twice your experience.