Exit codes & testing
$?, [[ ]], and && / || logic.
A command that finishes without telling you whether it worked is like a delivery driver who drops a box and walks off without a word. Did it reach the right door? You have no idea. Bash (the shell that reads and runs the commands you type on most Linux servers) refuses to leave you guessing. The moment a command finishes, it hands back a small number that says how things went. Learn to read that number and you can write scripts that notice failure and stop, instead of charging ahead and making a mess. That is the whole difference between a backup script that warns you the disk was full and one that quietly saves nothing for six months.
Every command hands back a number
That number is called the exit code, and it runs from 0 to 255. The rule feels backwards until it sticks: 0 means success, and any non-zero number means something went wrong. It works like a golf score, where zero problems is the win and a higher number is a louder, more specific complaint. The ceiling is 255. If a script tries to leave with code 256, the number wraps back around to 0, which reads as success, so keep any code you invent yourself between 1 and 255. After anything runs, a special variable named $? (say it 'dollar question-mark') holds the exit code of that last command, and you read it with echo.
The first ls found the file, so it reported 0. The second could not, so it reported 2. Notice that I threw the error message away with 2> /dev/null, and the exit code still told the truth. That is the point worth holding onto. The text a command prints on your screen is written for humans to read. The exit code is written for your script to act on. Even when a command runs in total silence, its exit code is still talking.
Different failures, different numbers
A non-zero code is not a shrug that means 'broke somehow'. Well-built tools give different failures different numbers and write them down. grep (a text-search tool whose name comes from an old editor command meaning 'search everywhere for a pattern and print it') is a clean example. It returns 0 when it finds your pattern, 1 when the pattern is absent, and 2 when something actually broke, like the file not being there. The -q flag means quiet: print no matching lines, only set the exit code.
The gap between 1 and 2 matters more than it looks. A pattern that is absent (code 1) might be perfectly normal, an empty log with nothing to worry about. A file that has vanished (code 2) is a real problem you probably want to halt for. Flatten every non-zero code into one generic 'error' and you lose that difference, which is exactly the kind of blind spot an attacker who quietly deletes a log file is counting on. Notice too that the error text on the third line came from grep's own complaint, printed to your screen, while the number 2 is the part your script reads.
The grep on the first line exited 1 because 'ghost' is not in the file, yet the last line prints 0. That 0 belongs to the echo in the middle, which happily succeeded and painted over the result you cared about. This is why you capture the code immediately, before anything else has a chance to run.
Asking yes/no questions with [[ ]]
Scripts constantly need to ask a fact: Is this variable empty? Is this number too high? Does this file exist? Bash answers with a test, written inside double square brackets, [[ ... ]]. A test works like a bouncer checking one thing at the door, and it reports back the same way everything else does, as an exit code: 0 if the test passed (true), 1 if it failed (false). You rarely read that code by hand. You hand the test straight to if, which reads it for you.
String tests compare text. == asks are these equal, != asks are these different, -z asks is this empty (z for zero length), and -n asks is there anything here at all (n for non-empty). Checking for empty earns its keep: a blank variable is often the fingerprint of something upstream that already failed, and acting on it without looking is how a script ends up deleting the wrong path.
For DevSecOps work (development, security, and operations handled by one team instead of three), that empty check is a guard rail. If $token came back blank because an API (application programming interface, the way one program asks another for something) call failed, you catch it here before you ever use it as a password, a header, or a filename.
Numeric tests compare whole numbers, and they spell things out with letters: -eq (equal), -ne (not equal), -lt (less than), -gt (greater than). The letters exist because < and > already mean 'redirect a file' to the shell, so Bash needs different words for the comparisons. Reach for these when you count things: failed logins, open ports, days left before a certificate expires.
3 does equal 3 (0), 3 is under 5 (0), and 3 is not over 10, so that last test fails with 1. A real use writes itself: raise an alert when failed logins cross a line, with something like [[ "$fails" -gt 10 ]] && lock_account.
File tests ask about files on disk. -f is this a regular file, -d is this a directory, -e does it exist at all, and the permission trio -r, -w, -x ask can I read it, write it, or run it. Those permission tests answer what your script is actually allowed to do, which is a sharper and safer question than whether the file merely exists.
That last 1 is correct and reassuring. A configuration file is not supposed to be executable, and -x tells you so. A config file that suddenly passes -x is a small red flag worth noticing.
Why [[ ]] beats the old [ ]
You will meet older scripts that use single brackets, [ ... ]. They do a similar job, so why prefer the double kind? Because single brackets are a trap for any value an attacker or a sloppy input might reach. [ is not really part of the language. It is an ordinary command with a funny name, so the shell splits its arguments apart on spaces and expands any * into filenames before [ ever sees them. Double brackets, [[ ]], are built into Bash itself, so they skip that risky rewriting.
#!/usr/bin/env bashreply="yes please"if [ $reply = yes ]; thenecho "access granted"fi
Because $reply held two words and the single brackets did nothing to protect it, [ saw four arguments where it wanted three and gave up. In a login or deploy check, a loud crash is the lucky outcome. The quieter danger is a value shaped so that its split-apart pieces line up into a test that passes when it should not, handing out access it should refuse. Double brackets treat $reply as one whole value no matter what is packed inside it, so the same input fails the comparison cleanly.
#!/usr/bin/env bashreply="yes please"if [[ $reply == yes ]]; thenecho "access granted"elseecho "access denied"fi
Chaining checks with && and ||
Two little symbols turn exit codes into decisions, and they read almost like English. && means 'and then, only if that worked'. || means 'or else, if that failed'. So make_backup && upload_backup uploads only when the backup succeeded, and ping -c1 host || page_oncall wakes a human only when the ping failed. Bash picks which side to run by reading the exit code on the left: a 0 lets && proceed, and any non-zero fires ||.
This is the everyday shape of grep -q pattern file && do_something. The -q keeps grep silent so the only thing that carries meaning is its exit code, and && acts on it. String a few together and a whole safety check fits on one line: [[ -f backup.tar ]] && upload runs the upload only when the backup file is really there.