Bash provides a set of built-in special variables that give you information about the script itself, its arguments, its process, and the result of the last command. Knowing these fluently is what separates beginner scripts from production-grade ones.
Positional parameters hold the arguments passed to the script or function. $0 is always the script name, $1 through $9 are the first nine arguments, and ${10} onwards need braces.
#!/usr/bin/env bash
# positional.sh — Demonstrate positional parameters
echo "Script name : $0"
echo "Script name : $(basename "$0")" # Just the filename
echo "First arg : $1"
echo "Second arg : $2"
echo "Tenth arg : ${10}" # Braces required for 2+ digits
echo "Arg count : $#" # Number of arguments
echo "All args (@) : $@" # All args, individually quoted
echo "All args (*) : $*" # All args, as single string
# Loop over all arguments safely — use "$@" not "$*"
echo "Processing all arguments:"
for arg in "$@"; do
echo " → ${arg}"
done
"$@" in loops, never "$*". With "$@", arguments containing spaces are kept as separate quoted items. With "$*", all arguments collapse into one string separated by the first character of $IFS.$? holds the exit code of the most recently executed command. 0 means success. Any non-zero value means failure. This is how scripts communicate success or failure to each other and to cron, CI/CD, and monitoring systems.
# Check exit status after every important command
ls /etc/passwd
echo "Exit code: $?" # 0 — success
ls /nonexistent/path
echo "Exit code: $?" # 2 — failure (file not found)
# Use in conditionals
ping -c1 -W1 8.8.8.8 > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
echo "Network is reachable"
else
echo "Network is unreachable"
fi
# More idiomatic — check directly in if
if ping -c1 -W1 8.8.8.8 > /dev/null 2>&1; then
echo "Network OK"
fi
# Exit with a specific code
validate_input() {
[[ -z "${1}" ]] && return 1 # Return 1 for failure
return 0 # Return 0 for success
}
validate_input ""
echo "Validation result: $?" # 1 (failed)
# $$ — PID of the current script (current shell)
echo "My PID: $$"
# Used for unique temp files — prevents collisions in parallel runs
TMPFILE="/tmp/myapp_$$.tmp"
echo "data" > "${TMPFILE}"
# ... use file ...
rm -f "${TMPFILE}"
# $! — PID of the last background process
sleep 60 &
BG_PID=$!
echo "Background PID: ${BG_PID}"
wait "${BG_PID}" # Wait for it to finish
# $_ — last argument of the previous command
mkdir -p /tmp/myproject/logs
cd $_ # cd to /tmp/myproject/logs
# $PPID — Parent process ID
echo "Parent PID: $PPID"
# $BASHPID — PID in subshells (different from $$)
( echo "Subshell PID: $BASHPID, Parent $$: $$" )
$IFS controls how bash splits words when expanding unquoted variables and when reading with read. Its default value is space, tab, and newline. Changing it is powerful but requires care.
# Default IFS — splits on space, tab, newline
sentence="one two three"
for word in $sentence; do # Splits on spaces
echo " → ${word}"
done
# Change IFS to split on colon — useful for PATH parsing
OLD_IFS="${IFS}" # Save original
IFS=':'
for dir in $PATH; do
echo " PATH entry: ${dir}"
done
IFS="${OLD_IFS}" # Always restore!
# Use IFS with read to parse CSV lines
while IFS=',' read -r name age city; do
echo "Name=${name}, Age=${age}, City=${city}"
done < users.csv
# Best practice — use IFS=$'\n\t' at script top
# to prevent word splitting on spaces in filenames
IFS=$'\n\t'
# ── Navigation ─────────────────────────────────────────────
echo "Home dir : $HOME"
echo "Current dir : $PWD"
echo "Previous dir: $OLDPWD"
echo "PATH : $PATH"
# ── User info ──────────────────────────────────────────────
echo "Username : $USER"
echo "Effective UID: $EUID" # 0 = root
echo "Hostname : $HOSTNAME"
# ── Shell info ─────────────────────────────────────────────
echo "Shell : $SHELL"
echo "Bash version: $BASH_VERSION"
echo "Bash source : ${BASH_SOURCE[0]}"
echo "Line number : $LINENO" # Current line (useful in debug)
# ── Terminal ───────────────────────────────────────────────
echo "Term type : $TERM"
echo "Term width : $COLUMNS"
echo "Term height : $LINES"
# ── Runtime ────────────────────────────────────────────────
echo "Seconds up : $SECONDS" # Seconds since shell started
echo "Random num : $RANDOM" # Random int 0-32767
# Check if running as root
if [[ "${EUID}" -ne 0 ]]; then
echo "ERROR: This script must be run as root" >&2
exit 1
fi
#!/usr/bin/env bash
# Using BASH_SOURCE and LINENO in error messages
die() {
# Prints: [filename:linenum] ERROR: message
echo "[${BASH_SOURCE[1]}:${BASH_LINENO[0]}] ERROR: $*" >&2
exit 1
}
check_file() {
local file="${1}"
[[ -f "${file}" ]] || die "File not found: ${file}"
echo "File OK: ${file}"
}
check_file "/etc/passwd" # OK
check_file "/no/such/file" # Fails with location
# BASH_SOURCE array — useful in sourced libraries
# BASH_SOURCE[0] = current file
# BASH_SOURCE[1] = file that sourced this one
SCRIPT_DIR=$( cd "$(dirname "${BASH_SOURCE[0]}")" && pwd )
echo "This script lives in: ${SCRIPT_DIR}"
#!/usr/bin/env bash
# show_special_vars.sh — Print all special variables
echo "═══════════ POSITIONAL ═══════════"
echo "\$0 Script name : $0"
echo "\$1 First arg : ${1:-'(none)'}"
echo "\$# Arg count : $#"
echo "\$@ All args : $@"
echo "═══════════ PROCESS ══════════════"
echo "\$$ Current PID : $$"
echo "\$PPID Parent PID : $PPID"
echo "═══════════ STATUS ═══════════════"
true
echo "\$? After true : $?"
false
echo "\$? After false : $?"
echo "═══════════ ENVIRONMENT ══════════"
echo "\$USER : $USER"
echo "\$HOME : $HOME"
echo "\$PWD : $PWD"
echo "\$SHELL : $SHELL"
echo "\$RANDOM : $RANDOM"
echo "\$SECONDS : $SECONDS"
echo "\$LINENO : $LINENO"
$0 script name · $1-$9 arguments · $# arg count · "$@" all args safely · $? last exit code · $$ script PID · $! last background PID · $IFS word split char · $EUID effective user ID · $SECONDS elapsed time. Memorise these — they appear in virtually every production script.