🐚 bash Quick Reference

bash cheat sheet / survival guide

Video: bash (Command Line Tips video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
bash [options] [script [arguments...]]
bash --version

Bash (the Bourne Again SHell) is the default shell on most Linux systems. It is both the command line you type into
and a scripting language. This sheet covers the two halves: working fast at the prompt (shortcuts, history, job control)
and writing scripts (variables, tests, loops, functions, error handling). Bash is not the same as plain sh, and not the same as
fish or zsh. Scripts that start with #!/bin/bash can use everything here. See the Fish sheet for a friendlier interactive shell,
and the AI-Assisted Shell Scripting guide for a safe way to review scripts.

Which shell am I in?
echo $0                                  # the current shell (-bash means a login shell)
echo $BASH_VERSION                       # empty if you are not in bash
echo $SHELL                              # your default login shell
cat /etc/shells                          # shells installed on this machine
chsh -s /bin/bash                        # make bash your login shell
bash --version    (macOS ships an old bash 3.2. Install a newer one with: brew install bash)

Keyboard shortcuts at the prompt (readline)

Ctrl+A / Ctrl+E          Jump to the start / end of the line
Alt+B / Alt+F            Move back / forward one word (also Ctrl+Left / Ctrl+Right in many terminals)
Ctrl+U                   Delete from the cursor to the start of the line
Ctrl+K                   Delete from the cursor to the end of the line
Ctrl+W                   Delete the word before the cursor
Alt+D                    Delete the word after the cursor
Ctrl+Y                   Paste back what you just deleted (yank)
Ctrl+L                   Clear the screen (same as the clear command)
Ctrl+C                   Stop the running command
Ctrl+D                   Exit the shell (on an empty line), or delete the character under the cursor
Ctrl+Z                   Suspend the running command (resume with fg or bg)
Ctrl+R                   Search history backwards as you type. Press Ctrl+R again for the next match
Ctrl+G                   Cancel a history search
Alt+.                    Insert the last argument of the previous command. Press again to go further back
Ctrl+X Ctrl+E            Edit the current line in your $EDITOR (great for long commands)
Tab                      Complete a command, file or variable. Press Tab twice to list the choices
Up / Down                Walk through history
Ctrl+_  or  Ctrl+/       Undo the last edit to the line
Alt+U / Alt+L / Alt+C    Uppercase / lowercase / capitalize the word
Ctrl+T                   Swap the two characters before the cursor
Ctrl+S / Ctrl+Q          Pause / resume terminal output (if the screen seems frozen, press Ctrl+Q)

History

history                                  # list history with numbers
history 20                               # the last 20 entries
!!                                       # repeat the last command          sudo !!    repeat it with sudo
!$                                       # the last argument of the last command       mkdir /tmp/a && cd !$
!*                                       # all arguments of the last command
!n                                       # run history entry number n        !-2   the command before the last
!git                                     # the last command that started with "git"
!?text                                   # the last command containing "text"
^old^new                                 # repeat the last command, replacing old with new
!!:s/foo/bar/                            # same idea, with the substitution written out
history -d 123                           # delete one entry              history -c    clear this session's history
history -w                               # write history to ~/.bash_history now
Settings for ~/.bashrc:
HISTSIZE=10000  HISTFILESIZE=20000       # how much to keep
HISTCONTROL=ignoreboth:erasedups         # ignore duplicates and commands that start with a space (type a space first to keep a secret command out of history)
HISTTIMEFORMAT="%F %T  "                 # add timestamps
shopt -s histappend                      # append to the history file instead of overwriting it
PROMPT_COMMAND='history -a'              # save each command as you go (useful with several terminals)
HISTIGNORE='ls:cd:pwd:exit'              # do not record these

Startup files (which one runs when)

Login shell (SSH, console, su -)        /etc/profile, then the first found of ~/.bash_profile, ~/.bash_login, ~/.profile
Interactive non-login (a new terminal)  ~/.bashrc
Script (bash script.sh)                 None of the above (only what BASH_ENV points to)
On logout                               ~/.bash_logout
Common trick: make login shells read .bashrc too. Put this in ~/.bash_profile:
   [ -f ~/.bashrc ] && source ~/.bashrc
Put aliases, prompt and interactive settings in ~/.bashrc. Put environment variables you want everywhere in ~/.profile (or ~/.bash_profile).
Reload without logging out:  source ~/.bashrc      (or:  . ~/.bashrc)

Getting help

help cd                                  # help for a builtin
help                                     # list all builtins
man bash                                 # the full manual (search with /, quit with q)
type ls                                  # is it an alias, function, builtin or file?    type -a echo    show all matches
command -v git                           # where is it?  (better than which in scripts)
compgen -c | sort | head                 # list all commands you can run     compgen -A function    your functions
declare -p VAR                           # show how a variable is defined     declare -f myfunc    show a function's code
alias                                    # list aliases          unalias name    remove one

Variables

name="Alice"                             # NO spaces around the =
echo "$name"                             # use with a $ (quote it)
echo "${name}_backup"                    # braces mark where the name ends
export EDITOR=vim                        # pass to child programs (environment variable)
unset name                               # remove it
readonly PI=3.14                         # cannot be changed
declare -i count=5                       # integer      declare -r    readonly      declare -x    export      declare -l / -u    lowercase / uppercase on assignment
local x=1                                # inside a function only
env                                      # list environment variables         printenv HOME    show one
set                                      # list all variables and functions
VAR=value command                        # set a variable for one command only     LANG=C sort file
Common built-in variables:
$HOME  $USER  $PWD  $OLDPWD  $PATH  $SHELL  $HOSTNAME  $RANDOM  $SECONDS  $LINENO  $UID  $EDITOR  $PS1
PATH: add a folder with   export PATH="$HOME/bin:$PATH"     (put it in ~/.profile or ~/.bashrc)

Special parameters

$0            Name of the script                  $1 $2 ... $9 ${10}    Positional arguments
$#            Number of arguments                 $@    All arguments, each one separate (use "$@")
$*            All arguments as one word           $?    Exit status of the last command (0 = success)
$$            Process ID of this shell            $!    PID of the last background job
$_            Last argument of the previous command       $-    Current shell options
shift         Drop $1 and move the rest down      shift 2    drop two

Quoting

'single quotes'                          # everything literal. No variables, no escapes
"double quotes"                          # variables and $(...) still expand. Prevents word splitting and globbing
\$                                       # escape one character           echo "Cost: \$5"
$'line1\nline2\tTab'                     # ANSI-C quoting: \n, \t and friends work
"$var"  and  "${array[@]}"  and  "$(cmd)"     ALWAYS quote these, unless you know you want splitting
Unquoted variables split on spaces and expand wildcards. That causes most script bugs (and dangerous rm commands).

Parameter expansion (string tricks without external tools)

${var:-default}                          # value of var, or "default" if unset or empty
${var:=default}                          # same, and also assign the default
${var:?message}                          # exit with an error message if unset or empty
${var:+other}                            # "other" if var is set and not empty, else nothing
${#var}                                  # length of the string
${var:2}    ${var:2:5}                   # substring: from offset 2, or 5 characters from offset 2
${var#pattern}   ${var##pattern}         # remove the shortest / longest match from the START     ${path##*/}   file name
${var%pattern}   ${var%%pattern}         # remove the shortest / longest match from the END        ${file%.txt}  drop the extension
${var/old/new}   ${var//old/new}         # replace the first / every match
${var^}  ${var^^}  ${var,}  ${var,,}     # uppercase first letter / all  and lowercase first / all
${!prefix*}                              # names of variables starting with prefix
${!name}                                 # indirect: the value of the variable whose name is in $name
Examples:
f="/var/log/app.log"
echo "${f##*/}"                          # app.log       echo "${f%/*}"     /var/log       echo "${f%.log}"    /var/log/app
echo "${f/var/tmp}"                      # /tmp/log/app.log

Command substitution and arithmetic

today=$(date +%F)                        # capture a command's output (prefer $() over `backticks`)
files=$(ls | wc -l)
echo $((3 + 4 * 2))                      # arithmetic: + - * / % **
((count++))                              # increment (no $ needed inside (( )))
((count += 5))                           # also -=  *=  /=
if (( count > 10 )); then echo big; fi   # numeric comparison
result=$((a / b))                        # integers only. For decimals use:  bc -l  or  awk
echo "scale=2; 10/3" | bc                # 3.33

Expansions and wildcards (globbing)

*    Any characters          ?    One character          [abc]    One of a, b, c          [a-z]    A range          [!abc]    Not those
ls *.txt        ls file?.log        ls [A-C]*
{a,b,c}                                  # brace expansion:  echo file{1,2,3}.txt     mkdir -p project/{src,docs,tests}
{1..5}    {a..e}    {01..10}    {1..10..2}       # sequences:  for i in {1..5}
~     ~user                              # home directory of you / of user
cp file{,.bak}                           # cp file file.bak   (handy for quick backups)
shopt -s nullglob                        # a glob with no matches expands to nothing (default: stays as the text *.xyz)
shopt -s dotglob                         # let * match hidden files too
shopt -s globstar                        # ** matches directories recursively:  ls **/*.py
shopt -s extglob                         # ?(pat)  *(pat)  +(pat)  @(pat)  !(pat):  ls !(*.log)     rm !(keep|this).txt
shopt -s nocaseglob                      # case-insensitive globbing

Redirection and pipes

cmd > file                               # stdout to a file (overwrite)         cmd >> file    append
cmd 2> errors.log                        # stderr to a file                     cmd 2>> errors.log    append
cmd &> all.log                           # stdout and stderr to a file          cmd &>> all.log    append
cmd > out.log 2>&1                       # older, portable form of the same
cmd 2>&1 | less                          # send both through a pipe             cmd |& less    shorthand
cmd < input.txt                          # stdin from a file
cmd > /dev/null                          # throw output away        cmd > /dev/null 2>&1    throw away everything
cmd | tee file                           # show AND save            cmd | tee -a file    append
cmd1 | cmd2 | cmd3                       # a pipeline: each command's stdout feeds the next
diff <(sort a.txt) <(sort b.txt)         # process substitution: treat command output as a file
cmd > >(tee log.txt)                     # feed a command's output into another command as a file
cat <<'EOF'                              # here-document: multi-line input (quoted 'EOF' = no expansion)
line one
line two with $literal
EOF
cat <<-EOF                               # <<- strips leading tabs so you can indent the block (unquoted: variables expand)
	Hello $USER
EOF
grep foo <<< "$text"                     # here-string: feed a string to stdin
set -o noclobber                         # refuse to overwrite files with >   (override with >|)
exec > >(tee -a script.log) 2>&1         # inside a script: log everything the script prints
Order matters: cmd > file 2>&1 is right.   cmd 2>&1 > file is not.

Exit status and combining commands

echo $?                                  # 0 = success, anything else = failure (1-255)
cmd1 && cmd2                             # run cmd2 only if cmd1 succeeded
cmd1 || cmd2                             # run cmd2 only if cmd1 failed
cmd1 ; cmd2                              # run both, one after the other
cmd1 & cmd2 &                            # run in the background
mkdir build && cd build || exit 1        # a common pattern
if cmd; then ... fi                      # if tests the exit status of a command
! cmd                                    # negate the status
exit 0    exit 1    return 1             # end the script / leave a function with a status
Conventions: 0 success, 1 general error, 2 misuse, 126 not executable, 127 command not found, 130 stopped by Ctrl+C.

Tests: [ ], [[ ]] and (( ))
[[ ... ]] is the bash way (safer: no word splitting, supports && || and patterns). [ ... ] is the old POSIX test.
File tests
[[ -e f ]] exists    [[ -f f ]] regular file    [[ -d d ]] directory    [[ -L f ]] symlink    [[ -s f ]] not empty
[[ -r f ]] readable  [[ -w f ]] writable        [[ -x f ]] executable   [[ f1 -nt f2 ]] newer than    [[ f1 -ot f2 ]] older than
String tests
[[ -z "$s" ]] empty        [[ -n "$s" ]] not empty        [[ "$a" == "$b" ]] equal        [[ "$a" != "$b" ]] not equal
[[ "$s" == *.txt ]] pattern match (unquoted right side)        [[ "$s" =~ ^[0-9]+$ ]] regular expression (matches land in ${BASH_REMATCH[@]})
[[ "$a" < "$b" ]] sorts before
Number tests (use (( )) or these)
(( a == b ))  (( a != b ))  (( a < b ))  (( a <= b ))  (( a > b ))  (( a >= b ))        or in [[ ]]:  -eq -ne -lt -le -gt -ge
Combine
[[ -f f && -r f ]]   [[ -z $x || $x == y ]]   [[ ! -e f ]]
Quote variables inside [ ] always. Inside [[ ]] quoting is safer but not required.

Conditionals

if [[ -f "$file" ]]; then
    echo "found"
elif [[ -d "$file" ]]; then
    echo "directory"
else
    echo "missing"
fi

case "$answer" in
    y|Y|yes) echo "yes" ;;
    n|N|no)  echo "no" ;;
    q*)      exit 0 ;;
    *)       echo "unknown" ;;
esac
[[ -f f ]] && echo "yes" || echo "no"          # one-line form (beware: the || also runs if the echo fails)

Loops

for i in 1 2 3; do echo "$i"; done
for i in {1..10}; do echo "$i"; done
for ((i = 0; i < 10; i++)); do echo "$i"; done              # C-style
for f in *.txt; do echo "$f"; done                          # over files (quote "$f" inside the body)
for arg in "$@"; do echo "$arg"; done                       # over script arguments
while [[ $count -lt 5 ]]; do ((count++)); done
until ping -c1 -W1 host >/dev/null 2>&1; do sleep 2; done   # repeat until the command succeeds
while IFS= read -r line; do echo "$line"; done < file.txt   # read a file line by line (the safe way)
while IFS=, read -r name age city; do echo "$name is $age"; done < data.csv      # split fields
command | while read -r line; do ...; done                  # careful: the loop runs in a subshell (see below)
break    continue    break 2                                # leave the loop / skip to the next round / leave two loops
select choice in start stop quit; do echo "$choice"; break; done      # a simple numbered menu
Do NOT loop with:  for f in $(ls)   It breaks on spaces. Use a glob:  for f in *   or   find ... -print0 | while IFS= read -r -d '' f

Functions

greet() {
    local name="${1:-World}"                # local variables stay inside the function
    echo "Hello, $name"
    return 0                                # only numbers 0-255. Output the "result" with echo instead
}
greet Alice                                 # call it like a command
message=$(greet Bob)                        # capture its output
$1, $2, $@, $# work inside a function (the function's own arguments)
declare -f greet                            # show the code         unset -f greet    remove it
Export a function to child bash processes:  export -f greet
Functions can go in ~/.bashrc, for example:
mkcd() { mkdir -p -- "$1" && cd -- "$1"; }
extract() { case "$1" in *.tar.gz|*.tgz) tar xzf "$1" ;; *.tar.bz2) tar xjf "$1" ;; *.zip) unzip "$1" ;; *) echo "unknown type" ;; esac; }

Arrays

fruits=(apple banana "red grape")            # indexed array
echo "${fruits[0]}"                          # first item (indexes start at 0)
echo "${fruits[@]}"                          # all items (quote it: "${fruits[@]}" keeps each item whole)
echo "${#fruits[@]}"                         # how many items
echo "${!fruits[@]}"                         # the indexes
fruits+=(mango)                              # append
fruits[1]="cherry"                           # change one
unset 'fruits[0]'                            # remove one
echo "${fruits[@]:1:2}"                      # a slice: 2 items from index 1
for f in "${fruits[@]}"; do echo "$f"; done
mapfile -t lines < file.txt                  # read a file into an array (one line per item)
read -ra parts <<< "a b c"                   # split a string into an array
declare -A color                             # associative array (needs bash 4+)
color[apple]=red; color[banana]=yellow
for k in "${!color[@]}"; do echo "$k is ${color[$k]}"; done
[[ -v color[apple] ]] && echo "key exists"

Strings and text

str="Hello, World"
echo "${#str}"                               # length
echo "${str:7:5}"                            # World
echo "${str/World/Bash}"                     # replace
echo "${str,,}   ${str^^}"                   # lowercase / uppercase
[[ $str == Hello* ]] && echo "starts with Hello"
[[ $str =~ ([A-Z][a-z]+),\ (.*) ]] && echo "${BASH_REMATCH[1]}"      # regex capture
printf "%s is %d years old\n" "Alice" 30     # formatted output (more reliable than echo -e)
printf "%-10s|%5.2f|\n" "name" 3.14159       # left-align 10, decimals    %x hex   %o octal   %e scientific   %q shell-quoted
printf '%s\n' "${array[@]}"                  # one array item per line
echo -n "no newline"                         # -n = no trailing newline. For escapes use printf or  echo -e  (not portable)
Trim spaces:  trimmed="${var#"${var%%[![:space:]]*}"}"       or use   xargs   or   sed
Join an array:  IFS=,; echo "${arr[*]}"; unset IFS         Repeat a character:  printf '%.0s-' {1..20}

Input

read name                                    # read a line from the keyboard into $name
read -p "Continue? [y/N] " ans               # with a prompt
read -r line                                 # -r: do not treat backslashes specially (use it almost always)
read -s -p "Password: " pw; echo             # silent (for passwords)
read -t 5 -n 1 key                           # 5-second timeout, one character
read -a words <<< "one two three"            # into an array
IFS=: read -r user pass uid rest < /etc/passwd     # split on a different separator
[[ $ans =~ ^[Yy]$ ]] || exit 1

Script skeleton

#!/usr/bin/env bash
set -Eeuo pipefail                           # stop on errors, unset variables and pipe failures
IFS=$'\n\t'
trap 'echo "Error on line $LINENO" >&2' ERR
trap 'rm -rf -- "$tmpdir"' EXIT              # cleanup when the script ends, however it ends
tmpdir=$(mktemp -d)

usage() { echo "Usage: ${0##*/} [-v] [-o file] input"; exit 2; }
verbose=0; out=""
while getopts ":vo:h" opt; do                # getopts: parse short options (o: takes a value)
    case "$opt" in
        v) verbose=1 ;;
        o) out="$OPTARG" ;;
        h) usage ;;
        :) echo "Option -$OPTARG needs a value" >&2; usage ;;
        \?) echo "Unknown option -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND - 1))                        # what is left in $@ are the non-option arguments
[[ $# -ge 1 ]] || usage
input="$1"
[[ -r "$input" ]] || { echo "Cannot read $input" >&2; exit 1; }
main() { ... }
main "$@"
Make it runnable:  chmod +x script.sh   then   ./script.sh         Shebang: #!/usr/bin/env bash (finds bash on the PATH) or #!/bin/bash
Long options need a manual while/case loop (getopts only does single letters). Use "--" to mark the end of options.

set and shopt: safety and behavior switches
set -e                                       # exit immediately if a command fails (has exceptions: not inside if/while/&&/||)
set -u                                       # error on unset variables (catches typos)
set -o pipefail                              # a pipeline fails if ANY command in it fails
set -x                                       # print each command before running it (debugging)     set +x    turn off
set -f                                       # disable globbing            set -n    read but do not run (syntax check)
set -euo pipefail                            # the common "strict mode"
shopt                                        # list shell options           shopt -s name    enable      shopt -u name    disable
shopt -s checkwinsize cdspell autocd cmdhist       # useful interactive ones (autocd: type a directory name to cd into it)
Do not rely on set -e alone: it does not catch every failure. Check the important commands yourself.

Traps and signals

trap 'cleanup' EXIT                          # run on any exit
trap 'echo Interrupted; exit 130' INT        # Ctrl+C
trap 'echo Terminated; exit 143' TERM        # kill
trap '' INT                                  # ignore a signal          trap - INT    restore the default
trap -l                                      # list signals
kill PID   kill -9 PID   kill -HUP PID       # 15 (TERM) asks nicely, 9 (KILL) cannot be caught, 1 (HUP) reload/hangup
pkill -f pattern   pgrep -f pattern          # find or kill by name

Job control and background work

long_command &                               # run in the background
jobs                                         # list background jobs         jobs -l    with PIDs
fg %1   bg %1                                # bring job 1 to the foreground / resume it in the background
Ctrl+Z, then bg                              # suspend a running command and let it continue in the background
disown %1                                    # detach a job so it survives closing the terminal
nohup command > out.log 2>&1 &               # keep running after logout
wait                                         # wait for all background jobs         wait $pid    wait for one
command1 & command2 & wait                   # run two things at once, then continue
timeout 30 command                           # kill it after 30 seconds
sleep 5                                      # pause
watch -n 2 'df -h'                           # repeat a command every 2 seconds
For jobs that must outlive your session, use tmux, screen, or a systemd service.

Subshells and scope

( cd /tmp && ls )                            # parentheses run in a SUBSHELL: cd and variables do not leak out
{ cd /tmp && ls; }                           # braces run in the CURRENT shell (note the spaces and the final ;)
count=0; printf 'a\nb\n' | while read -r l; do ((count++)); done; echo "$count"     # prints 0! the pipe put the loop in a subshell
Fix: use process substitution or a here-string:   while read -r l; do ((count++)); done < <(printf 'a\nb\n')     # now $count is 2
Variables set in a child process never reach the parent. export only passes them DOWN.
source script.sh  (or . script.sh)           # run a script in the current shell (so its variables and cd stay)

Directories and navigation

cd -                                         # back to the previous directory       cd    or   cd ~    home
pushd /var/log; popd; dirs -v                # a directory stack: pushd = go there and remember, popd = go back
CDPATH=.:~:~/projects                        # cd finds directories under these (use with care)
pwd -P                                       # the real path (resolving symlinks)
ls -lah   ls -ltr   ls -d */                 # long/human/hidden   oldest first, newest last   directories only
mkdir -p a/b/c                               # create parents         rmdir dir    remove an EMPTY directory
cd "$(dirname "$0")"                         # go to the script's own folder     script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

Aliases and the prompt

alias ll='ls -lah'                           # in ~/.bashrc:  alias ll='ls -lah --color=auto'     alias gs='git status -sb'
alias ..='cd ..'    alias ...='cd ../..'     alias grep='grep --color=auto'
alias update='sudo apt update && sudo apt upgrade'
\ls                                          # run the real command, skipping an alias      unalias ll    remove an alias
Aliases cannot take arguments. Use a function for that.
The prompt is the PS1 variable:
PS1='\u@\h:\w\$ '                            # user@host:directory$         Escapes: \u user  \h host  \H full host  \w directory  \W last part  \$ $ or # (root)  \t time  \d date  \n newline  \j jobs
PS1='\[\e[32m\]\u@\h\[\e[0m\]:\[\e[34m\]\w\[\e[0m\]\$ '        # colors: 32 green, 34 blue, 31 red, 33 yellow, 0 reset. Wrap color codes in \[ \]
PROMPT_COMMAND='echo -ne "\033]0;${USER}@${HOSTNAME}: ${PWD}\007"'      # set the terminal title
For a themed prompt with git status and more, see the Oh My Posh sheet, or use starship.

Tab completion

sudo apt install bash-completion             # completion for git, ssh, apt, systemctl, docker and hundreds more (often installed already)
source /usr/share/bash-completion/bash_completion
complete -F _longopt mycommand               # simple completion for a command
complete -W "start stop status" myservice    # complete from a fixed word list
bind 'set completion-ignore-case on'         # case-insensitive completion (or put it in ~/.inputrc)
bind 'set show-all-if-ambiguous on'          # show choices on the first Tab. Put settings like these in ~/.inputrc without the word bind

Debugging scripts

bash -n script.sh                            # syntax check only. Runs nothing
bash -x script.sh                            # trace every command (each line starts with +)
set -x   ...   set +x                        # trace only part of a script
PS4='+ ${BASH_SOURCE}:${LINENO}: '           # a more informative trace prefix
shellcheck script.sh                         # the best linter: catches quoting bugs and bad patterns  (sudo apt install shellcheck)
shfmt -d script.sh                           # formatting differences (shfmt -w to fix them)
echo "DEBUG: var=$var" >&2                   # print to stderr so it does not pollute the output
declare -p var                               # exactly what is in a variable (shows quotes and arrays)
trap 'echo "line $LINENO: $BASH_COMMAND"' ERR      # which command failed
Test in a temp folder with fake data first:  T=$(mktemp -d)

Common gotchas

name = "x"                    "command not found": no spaces around = in an assignment.  Right: name="x"
[ $a = $b ]                   Breaks when a value is empty or has spaces. Use [[ "$a" == "$b" ]]
if [$a -eq 1]                 Needs spaces inside the brackets:  [ "$a" -eq 1 ]   (or [[ ]])
for f in $(ls)                Breaks on spaces in names. Use for f in *
rm -rf $dir/*                 If $dir is empty this becomes rm -rf /*   Use "${dir:?}" and quote:  rm -rf -- "${dir:?}"/*
cd somewhere; rm *            If cd fails, rm runs in the WRONG place. Use  cd somewhere || exit 1
cat file | while read ...     The loop runs in a subshell, so variables set inside are lost. Use  done < file
echo $var (unquoted)          Loses spaces and expands wildcards. Use echo "$var"
$(cmd) result with newlines   Trailing newlines are stripped. Fine for most uses
[ "$x" = "a" ] && ... || ...  The || part also runs if the && command fails. Use a real if
Using > to update a file      cmd file > file   truncates it BEFORE reading. Write to a temp file, then mv, or use sponge / sed -i
Windows line endings          "bad interpreter: /bin/bash^M". Fix with  dos2unix script.sh   or   sed -i 's/\r$//' script.sh
Script not executable         "Permission denied": chmod +x script.sh, or run it with  bash script.sh
"./script.sh: not found"      Wrong shebang path, or CRLF line endings. Check  head -1 script.sh | cat -A
Ran with sh, not bash         sh script.sh does not have arrays, [[ ]] or $'..'. Use bash script.sh, or run ./script.sh with a bash shebang
Unset vs empty                ${var:-x} treats both as missing. ${var-x} only treats UNSET as missing
Integer math only             $((10/3)) is 3. Use bc or awk for decimals
Filenames starting with -     rm -- "$f"  (the -- ends option parsing)
Globs with no match           A glob that matches nothing stays as the literal text. Use nullglob, or test with [[ -e "$f" ]]

Practical one-liners

# Repeat the last command with sudo
sudo !!

# Make a directory and go into it
mkdir -p ~/projects/new && cd $_

# Quick backup of a file
cp file.conf{,.bak}

# Find the 10 biggest things in the current folder
du -sh * .[!.]* 2>/dev/null | sort -rh | head -10

# Count lines in every .py file, largest first
wc -l **/*.py | sort -rn | head        (needs shopt -s globstar)

# Run a command for every file
for f in *.jpg; do convert "$f" "${f%.jpg}.png"; done

# Rename many files (replace spaces with underscores)
for f in *\ *; do mv -- "$f" "${f// /_}"; done

# Process a file line by line, safely
while IFS= read -r line; do echo "Got: $line"; done < input.txt

# Ask for confirmation
read -r -p "Delete everything? [y/N] " a; [[ $a == [yY] ]] || exit 1

# Wait until a port is open
until nc -z localhost 8080; do sleep 1; done

# Run something every 5 seconds and log it
while true; do date >> log.txt; sleep 5; done

# Download and check status
if curl -fsS -o out.json https://example.com/api; then echo OK; else echo "failed ($?)"; fi

# Retry a flaky command 5 times
for i in {1..5}; do cmd && break || sleep $((i * 2)); done

# Show the top 5 most used commands in your history
history | awk '{ $1=""; print $2 }' | sort | uniq -c | sort -rn | head -5

# Sum a column of numbers
awk '{ s += $1 } END { print s }' numbers.txt

# Parallel work with xargs (4 at a time)
find . -name '*.log' -print0 | xargs -0 -n1 -P4 gzip

# Print each line of a file with its line number
nl -ba file.txt        or        awk '{ print NR": "$0 }' file.txt

# Get your public IP and time it
time curl -s ifconfig.me

# Show all listening ports (with the process, if you use sudo)
sudo ss -tulpn

# Lock a script so it cannot run twice at once
exec 9>/tmp/myjob.lock; flock -n 9 || { echo "already running"; exit 1; }

Bash vs other shells

sh (POSIX)    The minimal standard. No arrays, no [[ ]], no <( ). Use #!/bin/sh only if you need that portability (Debian's /bin/sh is dash)
bash          The default on most Linux systems. Most tutorials and scripts assume it
zsh           The default on macOS. Similar to bash, with different globbing and array indexing (starts at 1)
fish          Friendly and modern, but NOT compatible with bash scripts. See the Fish sheet
Write scripts in bash (or sh) so they run everywhere. Use fish or zsh interactively if you like them better.

Tips

    Quote every variable: "$var", "${array[@]}", "$(command)". Unquoted variables are the source of most script bugs.

    Start scripts with #!/usr/bin/env bash and set -Eeuo pipefail, and add a trap to clean up temporary files.

    Use [[ ]] and (( )) in bash scripts. They are safer and easier than [ ].

    Run shellcheck on every script. It finds real bugs the moment you save.

    Use local variables in functions, and pass results back with echo and $(...), not global variables.

    Avoid parsing ls. Use globs, find with -print0, and while IFS= read -r.

    Test destructive commands with echo first, or use a temp folder. Use cd dir || exit so a failed cd does not run the next command in the wrong place.

    Learn the shortcuts: Ctrl+R, Ctrl+A, Ctrl+E, Ctrl+W, Alt+. and !! save more time than any tool.

    Keep aliases, functions and the prompt in ~/.bashrc, and keep that file in git so every machine feels the same.

    For anything long or complicated, consider Python. Bash is at its best gluing commands together, not doing heavy logic.