Writing Your First Bash Script
A Bash script is a plain text file containing a sequence of shell commands, saved so you can run the whole sequence with a single command instead of typing each line by hand. Anything you can type at the Bash prompt, you can put in a script — and once it works, you can run it identically a hundred times, hand it to a teammate, or schedule it to run automatically at 3 a.m. This lesson writes, permissions, and runs your very first Bash script, and explains exactly what the shell and the kernel are doing behind the scenes at every step.
Overview: What a Bash Script Actually Is
There is nothing magical that turns a text file into a "script" — it is just a file containing lines of text, interpreted the same way Bash would interpret them if you typed them one by one at an interactive prompt. What makes it runnable as a program is a combination of two things: the shebang line at the top of the file, and the file’s execute permission bit.
The shebang line
The very first line of a script conventionally looks like this:
#!/usr/bin/env bash
This is called a shebang (from "hash-bang", the characters #!). It matters at the kernel level: when you ask the kernel to execute a file directly (via the execve() system call), the kernel peeks at the first two bytes. If they are #!, the kernel treats the rest of that line as the path to an interpreter, plus one optional argument, and re-executes that program, handing it your script’s path as an argument. #!/usr/bin/env bash asks the kernel to run env, which then searches $PATH for a program called bash and hands control to it — this is more portable than hardcoding #!/bin/bash, since on some systems Bash lives somewhere other than /bin. If a script has no shebang and you try to execute it directly, most shells fall back to interpreting it with /bin/sh, which does not understand Bash-only syntax like [[ ]] or arrays — always write the shebang explicitly.
Two ways to run a script, and why permissions matter
There are two distinct ways to run a script, and they behave differently:
bash script.sh— you are explicitly launching thebashinterpreter and telling it to read and executescript.shas input. Bash just opens the file and reads it, the same way it would read any text file, so the execute permission bit is not required. The shebang line is also irrelevant here (Bash just treats it as a comment), because you already chose the interpreter yourself../script.sh— you are asking the kernel to execute the file directly, exactly like running any other program. This requires the file’s execute bit to be set (chmod +x script.sh), and it is the shebang line that tells the kernel which interpreter to hand the file to.
Why the ./ prefix? For security, most Linux distributions do not include the current directory in $PATH (the list of directories the shell searches for commands). If it did, an attacker could drop a malicious file named ls into a directory, wait for you to cd into it and run ls, and silently run their code instead of the real /bin/ls. Typing ./script.sh (or a full path like /home/ubuntu/scripts/script.sh) makes your intent to run a local, specific file unambiguous.
Execution and exit status
Once running, Bash reads the script roughly line by line, performing the same expansions it would interactively — variable substitution, command substitution with $(...), globbing — and executing each command exactly as if you had typed it, just without printing a prompt. Every command that finishes returns a numeric exit status between 0 and 255, where 0 conventionally means success and anything non-zero means some kind of failure; this is a convention followed by well-behaved programs, not something the kernel enforces. A script itself exits with the status of the last command it ran, unless you call exit N explicitly. The calling shell can inspect the most recently finished command’s status in the special variable $? — but only until the next command runs, since that overwrites it.
Syntax
A minimal script has this shape:
#!/usr/bin/env bash
# comment describing what this script does
command_1
command_2
command_3
And it can be run in any of these ways:
| Command | Requires chmod +x? |
Notes |
|---|---|---|
./script.sh |
Yes | Kernel reads the shebang and launches the interpreter for you. |
bash script.sh |
No | You explicitly choose the interpreter; shebang is ignored. |
/full/path/to/script.sh |
Yes | Same as ./script.sh, usable from any directory. |
Examples
Example 1: A simple greeting script
#!/usr/bin/env bash
echo "Hello, $(whoami)!"
echo "Today is $(date +%A), and it's $(date +%T)."
echo "This script is running from: $(pwd)"
chmod +x hello.sh
./hello.sh
Output:
Hello, ubuntu!
Today is Tuesday, and it's 14:32:07.
This script is running from: /home/ubuntu/scripts
The three $(...) expressions are command substitutions: Bash runs whoami, date, and pwd in subshells, captures their standard output, strips a trailing newline, and substitutes the result into the string before echo ever sees it.
Example 2: Using arguments and default values
Scripts can accept command-line arguments, available inside the script as $1, $2, and so on, with $0 holding the script’s own invocation name and $# the argument count.
#!/usr/bin/env bash
name="$1"
greeting="${2:-Hello}"
if [[ -z "$name" ]]; then
echo "Usage: $0 <name> [greeting]" >&2
exit 1
fi
echo "$greeting, $name! Welcome to Bash scripting."
chmod +x greet.sh
./greet.sh Maria
./greet.sh Alex "Hey there"
./greet.sh
Output:
Hello, Maria! Welcome to Bash scripting.
Hey there, Alex! Welcome to Bash scripting.
Usage: ./greet.sh <name> [greeting]
${2:-Hello} is parameter expansion with a default: if $2 was never set, it falls back to the literal string Hello without changing the actual variable. The [[ -z "$name" ]] test checks whether $name is an empty string; when it is, the usage message is sent to standard error (>&2, the conventional destination for error and diagnostic output) and the script exits with status 1 to signal failure to whoever called it.
Example 3: A more realistic script with logging
#!/usr/bin/env bash
set -euo pipefail
log_file="$HOME/disk-check.log"
threshold=80
usage_percent="$(df --output=pcent / | tail -n 1 | tr -dc '0-9')"
if (( usage_percent >= threshold )); then
echo "$(date '+%Y-%m-%d %H:%M:%S') WARNING: root filesystem is ${usage_percent}% full" >> "$log_file"
echo "Warning: disk usage is at ${usage_percent}%."
else
echo "Disk usage is fine: ${usage_percent}% used."
fi
chmod +x disk-check.sh
./disk-check.sh
Output:
Disk usage is fine: 42% used.
This script pipes df‘s output through tail and tr to extract just the numeric usage percentage, then uses arithmetic evaluation (( )) to compare it against a threshold. If the threshold is crossed, it appends (>>) a timestamped line to a log file rather than overwriting it. set -euo pipefail makes the script exit immediately if any command fails, treats unset variables as errors, and makes a pipeline fail if any stage of it fails — a habit worth building even in your first scripts.
How It Works, Step by Step
Here is exactly what happens when you type ./greet.sh Maria and press Enter, from an interactive Bash session:
- Your interactive Bash process forks a child process.
- The child calls
execve("./greet.sh", ["./greet.sh", "Maria"], environ), asking the kernel to replace itself with this program. - The kernel opens
greet.sh, confirms the execute bit is set, and reads the first line, finding#!/usr/bin/env bash. - Because of the shebang, the kernel actually executes
/usr/bin/envinstead, passing itbash, the script path, and the original arguments. envsearches$PATH, finds the realbashbinary, andexecves into it.- This new Bash process opens
greet.shas a regular file, parses it, and executes its commands in order, with$1bound toMaria. - When the script reaches its last line (or an explicit
exit), the Bash process terminates with a numeric exit status. - The kernel reports that status back to the parent shell, which stores it in
$?and prints the next prompt.
Nothing about this is specific to scripts you write yourself — it is exactly how every compiled program and every script-based tool on your system starts up.
Common Mistakes
1. Forgetting to make the script executable
$ ./backup.sh
bash: ./backup.sh: Permission denied
The execute bit is off. Fix it once, and the permission sticks:
chmod +x backup.sh
./backup.sh
2. Running a Bash script with sh
$ sh greet.sh Maria
greet.sh: 6: [[: not found
On many distributions /bin/sh is a different, more limited shell (like dash) that does not understand Bash-only syntax such as [[ ]] or arrays. Run it with the interpreter the script was written for, either via the shebang or explicitly:
./greet.sh Maria
3. Leaving variable expansions unquoted
filename="my report.txt"
cat $filename
Output:
cat: my: No such file or directory
cat: report.txt: No such file or directory
Without quotes, Bash word-splits $filename on whitespace before cat ever sees it, turning one filename into two arguments. Always quote variable expansions that might contain spaces (or globs):
filename="my report.txt"
cat "$filename"
4. Forgetting the ./ and getting "command not found"
Typing greet.sh instead of ./greet.sh fails with bash: greet.sh: command not found, because the current directory is deliberately excluded from $PATH. Either prefix the script with ./, use its full path, or move finished, trusted scripts into a directory that is on your $PATH, such as ~/bin.
Best Practices
- Always start scripts with
#!/usr/bin/env bashso the intended interpreter is unambiguous. chmod +xa script immediately after creating it, before you forget.- Give scripts a
.shextension for human readability, even though Bash itself does not require it. - Quote every variable expansion (
"$var","$1","$(cmd)") unless you specifically want word-splitting. - Run
bash -n script.shto check for syntax errors without executing anything. - Install and run
shellcheck script.shfor deeper linting beyond syntax — it catches quoting bugs, unused variables, and dozens of other common mistakes. - Print a short usage message and exit with a non-zero status when required arguments are missing.
- Keep early scripts short and linear; add functions, loops, and error handling once the basics are comfortable.
Practice Exercises
- Write
whoami-report.shthat prints your username and current directory using command substitution, similar to Example 1. Make it executable and confirmecho $?reports0after it runs. - Write
disk-log.shbased on Example 3 that appends the current disk usage percentage to~/disk-check.logevery time it runs. Run it three times, then usetail -n 3 ~/disk-check.logto confirm three new lines were appended, not that the file was overwritten. - Write
args-demo.shthat prints$#(the argument count) followed by each argument on its own line. Run it with zero, one, and three arguments, and add a usage check that exits with status1when no arguments are given.
Summary
- A Bash script is a plain text file of commands; the shebang line (
#!/usr/bin/env bash) tells the kernel which interpreter should run it. ./script.shrequires the execute permission bit (chmod +x);bash script.shdoes not, since you are choosing the interpreter directly.- The leading
./or a full path is required because the current directory is intentionally left out of$PATH. - A script exits with the status of its last command, or an explicit
exit N; the caller reads it from$?. - Positional parameters (
$1,$2, …),$#, and$0give a script access to its command-line arguments and its own name. - Always quote variable expansions, check required arguments, and use
shellcheckto catch mistakes before they bite you.
