34.7 Exit Codes, $?, $!, and $$: Special Variables
Right, let’s talk about the little status reports your commands are constantly sending back to the shell. You’ve probably seen a command fail and thought, “Well, that didn’t work.” But your shell doesn’t just think that; it gets a definitive, numerical verdict on every single thing you run. This is the bedrock of scripting logic, and if you ignore it, your scripts will be as fragile as a house of cards in a breeze.
The Truth About Exit Codes
Every time you run a command or program, the moment it finishes, it hands your shell a number between 0 and 255. This is its exit code (or exit status). The rule is beautifully simple:
- 0 means Success. Green light. All good.
- Anything else means Failure. Red light. Something went wrong.
Think of it as a binary true/false for “did that work?”, but with 255 different flavors of “false.” Why 255? Because it’s stored in a single byte, and that’s the maximum value (2^8 - 1). A program can use these non-zero codes to tell you what specifically went wrong. For example, grep might exit with 0 if it finds a match, 1 if it finds no matches, and 2 if you passed it a bogus argument or the file doesn’t exist. You can check a command’s man page (e.g., man grep) to see what its specific codes mean.
But how do you, the humble scripter, get your hands on this number? Enter $?.
Your Crystal Ball: The $? Variable
$? is a special shell variable that always holds the exit code of the last executed command. It’s your window into what just happened. The catch? It’s ridiculously fleeting. It changes after every single command you run, so you have to check it immediately.
#!/bin/bash
ls /some/nonexistent/directory
echo "The exit code of 'ls' was: $?"
# Now let's run a successful command
echo "Hello"
echo "The exit code of 'echo' was: $?" # This will be 0, overwriting the previous failure
Running this will likely output:
ls: cannot access '/some/nonexistent/directory': No such file or directory
The exit code of 'ls' was: 2
Hello
The exit code of 'echo' was: 0
See? The echo "Hello" command worked, so it set $? to 0, completely obliterating the 2 from the failed ls command. This is the most common pitfall. You must capture it or use it right away.
The real power comes when you start using this in conditionals. The if statement in bash directly checks the exit code.
if mkdir /tmp/my_new_dir; then
echo "Directory created successfully. Proceeding..."
else
echo "Failed to create directory. Exit code was: $?. Aborting." >&2
exit 1
fi
Here, if runs the mkdir command and checks its exit code. If it’s 0, it runs the then block. If it’s anything else, it runs the else block. This is why you can use commands directly in if statements—it’s all about the exit code.
Background Processes and $!
When you run a command in the background with &, it disappears from your immediate view. But how do you check up on it later? With $!. This special variable holds the Process ID (PID) of the last command you sent to the background.
#!/bin/bash
# Start a long-running process in the background
sleep 60 &
# Immediately capture its PID
background_pid=$!
echo "The background sleep command is running with PID: $background_pid"
# ... later in your script, you could check if it's still running or kill it
# kill "$background_pid"
This is crucial for any kind of process management in your scripts. You can’t manage what you can’t identify, and $! gives you that identity the moment you spin up the background job.
Your Script’s ID: The $$ Variable
While $! is for the last background job, $$ is for the script itself. It holds the PID of the current shell process—the one running your script. This is incredibly useful for creating unique temporary filenames. If five people run your script at the same time, they all have different $$ values, so their temp files won’t collide.
#!/bin/bash
TEMP_FILE="/tmp/my_script_cache.$$" # e.g., /tmp/my_script_cache.12345
echo "This script is running under PID: $$"
echo "Using temp file: $TEMP_FILE"
# Do some work, save to the temp file
echo "Cached data" > "$TEMP_FILE"
It’s a cheap and easy way to ensure uniqueness without needing a more complex system. Just be aware it’s not truly random, so it’s not suitable for security-sensitive names, but for basic temp files, it’s perfect.
Putting It All Together: A Coherent Example
Let’s write a small script that uses all three of these special variables in a somewhat realistic scenario.
#!/bin/bash
echo "This script's PID is: $$"
# Start a backup operation in the background
tar -czf /tmp/backup.tar.gz /home/user/important_data &
BACKUP_PID=$! # Capture the PID of the tar command
echo "Backup started with PID: $BACKUP_PID"
# Wait for the backup to finish, and check its status
wait $BACKUP_PID
BACKUP_EXIT=$? # Capture the exit code of the specific PID we waited for
if [ $BACKUP_EXIT -eq 0 ]; then
echo "Backup succeeded! (Exit code: $BACKUP_EXIT)"
# Now we could scp the file somewhere...
else
echo "Backup failed catastrophically! (Exit code: $BACKUP_EXIT)" >&2
exit 1 # Exit our own script with a failure code
fi
This script starts a background job, uses $! to track it, uses wait to pause until it’s done, and then uses $? to check its outcome. This is the kind of robust control flow that separates a useful script from a dangerous one. You’re not just firing commands into the void; you’re listening to their answers. And in bash, everyone talks in numbers.