bigonotes
LinuxShell and Editor

Bash Scripting Basics

Write and run a Bash script: shebang, variables, quoting, arithmetic, conditions, loops, functions, arguments, exit codes and strict mode.

6 min readLast updated

A shell script is a text file of shell commands that run in order, as if you typed them one by one. Scripts automate repeated work: setting up a server, deploying an app, rotating logs, running checks in CI. They also document a procedure, because the steps are written down and repeatable. This note covers the Bash you need to read and write everyday scripts.

Your first script#

A script starts with a shebang line that names the interpreter (more on it below). Comments start with #.

#!/bin/bash
# sysinfo.sh: print basic system information
uname -a
uptime

Then make it executable and run it.

  1. Save the file, for example as sysinfo.sh.
  2. Make it executable: chmod +x sysinfo.sh.
  3. Run it with a path: ./sysinfo.sh.

The ./ matters. The shell searches only the directories in $PATH for bare command names, and the current directory usually isn't one of them.

The shebang#

Back to that first line. #! followed by a path tells the kernel which program runs the file. #!/bin/bash uses Bash. #!/usr/bin/env bash finds bash on the PATH, which helps on systems where Bash lives elsewhere, such as macOS with Homebrew.

Without a shebang, the file usually runs in sh or in your current shell, depending on how you start it. Bash features may then fail. On Debian and Ubuntu, /bin/sh is dash, not Bash.

Variables#

A script that only runs fixed commands gets limited fast. Variables let you store values and reuse them. Assign with name=value and read with $name, with no spaces around =.

name="Ada"
count=5
echo "Hello, $name. You have $count messages."
echo "Backup: ${name}_backup.tar"   # braces mark where the name ends
today=$(date +%F)                   # command substitution

$(command) runs a command and inserts its output. Variable names are case sensitive, so $Name and $name differ. By convention, environment variables are uppercase (PATH, HOME) and script variables are lowercase.

Quoting#

The echo lines above used double quotes, and that choice matters. Quotes control which special characters the shell interprets.

Form Effect echo of it prints
'$HOME' Single quotes: everything literal $HOME
"$HOME" Double quotes: variables and $( ) expand /home/alice
\$HOME Backslash escapes one character $HOME

Characters such as $, *, ?, &, |, >, < and spaces have special meaning. Quote or escape them to use them as plain text.

Quote every variable: "$file", not $file. Without quotes, a value with spaces splits into several words, and a * in it expands to filenames.

Arithmetic#

Math on variables needs its own syntax. Bash does integer arithmetic inside $(( )), and variables inside need no $.

x=10
y=3
echo $(( x + y ))   # 13
echo $(( x / y ))   # 3: integer division
echo $(( x % y ))   # 1
(( count++ ))       # increment in place

For decimals, use another tool such as bc or Python.

Conditions#

With variables in hand, you can make decisions. if runs a command and checks its exit status (0 means success; more on that later). [ ... ] (or Bash's [[ ... ]]) is the usual test command.

if [[ $x -gt $y ]]; then
  echo "$x is greater than $y"
elif [[ $x -eq $y ]]; then
  echo "equal"
else
  echo "$x is smaller than $y"
fi

These are the tests you'll use most inside the brackets:

Test True when
-eq -ne -lt -le -gt -ge Number comparisons
"$a" == "$b" / != Strings are equal / differ
-z "$s" / -n "$s" String is empty / not empty
-f path / -d path A regular file / a directory exists
-e path Anything exists at the path

Spaces inside the brackets are required: [[ $x -gt 1 ]], not [[$x -gt 1]]. [[ ]] is safer than [ ] because it doesn't split unquoted variables.

Loops#

Loops let you repeat work, such as processing every log file in a directory. for loops over a list. while repeats while a command succeeds.

for i in 1 2 3; do
  echo "Number: $i"
done

for f in *.log; do
  gzip "$f"
done

n=3
while (( n > 0 )); do
  echo "$n"
  (( n-- ))
done

while read -r line; do    # read a file line by line
  echo "> $line"
done < input.txt

Functions#

As a script grows, you'll want to name and reuse blocks of it. A function groups commands under a name. Inside it, $1, $2 and so on are the function's own arguments. Use local so variables don't leak into the rest of the script.

greet() {
  local who="$1"
  echo "Hello, $who"
}

greet "Ada"

Arguments and special parameters#

Scripts take arguments the same way functions do. A script reads its command-line arguments through special variables.

Parameter Meaning
$0 The script's name
$1 .. $9, ${10} Positional arguments
$# Number of arguments
"$@" All arguments, each as a separate word
"$*" All arguments joined into one string
$? Exit status of the last command
$$ PID of the current shell
#!/bin/bash
# args.sh
echo "Script: $0, $# arguments"
echo "First: $1, second: $2"

Running ./args.sh Hello World prints First: Hello, second: World.

@vs@ vs *#

The table lists two ways to get all arguments, and they're easy to mix up. Unquoted, $@ and $* behave the same: both split on spaces. The difference appears inside double quotes.

for a in "$@"; do echo "[$a]"; done
# ./show.sh "my file" two  ->  [my file] [two]

for a in "$*"; do echo "[$a]"; done
# ./show.sh "my file" two  ->  [my file two]

Use "$@" to pass arguments on or loop over them. It keeps arguments that contain spaces intact.

Exit status#

The $? parameter from that table, and the if checks earlier, both rely on exit codes. Every command returns an exit status: 0 means success, and 1 to 255 means failure. $? holds the status of the last command. A script returns the status of its last command, or the value you give exit.

mkdir /tmp/testdir
if [[ $? -eq 0 ]]; then
  echo "created"
fi

# Shorter: test the command directly
if mkdir /tmp/testdir; then
  echo "created"
fi

Some non-zero codes have a standard meaning, so the number alone often tells you what went wrong:

Code Usual meaning
0 Success
1 General error
2 Misuse of a shell builtin (bad options)
126 Found, but not executable
127 Command not found
128 + n Killed by signal n: 130 is Ctrl+C (SIGINT), 137 is SIGKILL

Chaining with && and ||#

Exit codes also let you chain commands without a full if. && runs the next command only if the previous one succeeded. || runs it only if the previous one failed.

mkdir -p build && cd build
cd /missing || exit 1

a && b || c looks like if/else, but it isn't. c also runs when a succeeds and b fails. Use a real if when that matters.

Strict mode#

Everything so far assumes you check for errors yourself. By default, Bash keeps going after a failed command and treats unset variables as empty, which hides bugs. Most production scripts start with strict settings.

#!/usr/bin/env bash
set -euo pipefail
Option Effect
-e Exit as soon as a command fails
-u Treat an unset variable as an error
-o pipefail A pipeline fails if any command in it fails, not only the last

set -e has exceptions. It doesn't trigger inside if conditions or on the left side of && and ||. Treat it as a safety net, and still check errors that matter. Run ShellCheck on your scripts; it catches most quoting and logic mistakes.

Key takeaways#

  • Start with a shebang, chmod +x, and run with ./script.sh.
  • name=value with no spaces; read with "$name". Quote every variable.
  • Single quotes are literal; double quotes expand variables.
  • $(( )) does integer math, $( ) captures command output.
  • Use "$@" for arguments, $? for the last exit status; 0 means success.
  • Begin scripts with set -euo pipefail, and know that a && b || c is not if/else.
Revision notes · 2 min

Core idea#

A shell script is a text file of commands that run in order. It automates and documents repeated work.

Key points#

  • First line is the shebang, such as #!/bin/bash or #!/usr/bin/env bash.
  • Make it executable with chmod +x, then run ./script.sh.
  • Assign with name=value, no spaces. Read with "$name".
  • Single quotes are literal. Double quotes expand variables and $( ).
  • $(( )) does integer arithmetic. $( ) captures command output.
  • [[ ]] tests: -eq -lt -gt for numbers, == for strings, -f -d -e for paths.
  • Use local inside functions.

Special parameters#

Parameter Meaning
$0 Script name
$1, $2 Positional arguments
$# Argument count
"$@" Each argument as a separate word
"$*" All arguments as one string
$? Last exit status
$$ PID of the current shell

Exit codes#

Code Meaning
0 Success
1 General error
126 / 127 Not executable / not found
128 + n Killed by signal n (130 Ctrl+C, 137 SIGKILL)

Strict mode#

Option Effect
-e Exit on the first failure
-u Unset variable is an error
-o pipefail A pipeline fails if any part fails

Pitfalls#

  • Unquoted variables split on spaces and expand *. Quote every variable.
  • Spaces are required inside brackets: [[ $x -gt 1 ]].
  • a && b || c is not if/else. c runs if b fails.
  • set -e does not trigger inside if conditions or before && and ||.
  • On Debian and Ubuntu, /bin/sh is dash. Bash features need a bash shebang.

Say it in an interview#

  • "I start scripts with set -euo pipefail, quote variables and pass "$@"."

Found a mistake? Email ask@bigonotes.com

Further reading#