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 the bash interpreter and telling it to read and execute script.sh as 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/env instead, passing it bash, the script path, and the original arguments.
  • env searches $PATH, finds the real bash binary, and execves into it.
  • This new Bash process opens greet.sh as a regular file, parses it, and executes its commands in order, with $1 bound to Maria.
  • 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 bash so the intended interpreter is unambiguous.
  • chmod +x a script immediately after creating it, before you forget.
  • Give scripts a .sh extension 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.sh to check for syntax errors without executing anything.
  • Install and run shellcheck script.sh for 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.sh that prints your username and current directory using command substitution, similar to Example 1. Make it executable and confirm echo $? reports 0 after it runs.
  • Write disk-log.sh based on Example 3 that appends the current disk usage percentage to ~/disk-check.log every time it runs. Run it three times, then use tail -n 3 ~/disk-check.log to confirm three new lines were appended, not that the file was overwritten.
  • Write args-demo.sh that 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 status 1 when 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.sh requires the execute permission bit (chmod +x); bash script.sh does 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 $0 give a script access to its command-line arguments and its own name.
  • Always quote variable expansions, check required arguments, and use shellcheck to catch mistakes before they bite you.