Writing Your First Bash Script

Writing Your First Bash Script

Every Linux user eventually hits the same wall: a task that is simple to do once by hand becomes tedious the tenth time. Bash scripting is how you cross that wall. A script is nothing more than a text file containing the same commands you would normally type at a prompt, run in sequence, saved so you never have to retype them.

This guide covers enough Bash to write real, useful scripts: variables, arguments, conditionals, loops, and functions. It assumes you are comfortable running commands interactively but have not written a script before.

The shebang line

Every Bash script starts with a shebang line, a special comment that tells the system which interpreter should run the file:

#!/bin/bash

This must be the very first line, with no blank line or whitespace before it. When you run the script directly (./script.sh), the kernel reads this line and hands the rest of the file to /bin/bash to execute. Without it, the system falls back to whatever shell is currently running, which may not support all the syntax your script uses.

Your first script

Create a file called hello.sh:

#!/bin/bash

echo "Hello, $USER"
echo "Today is $(date +%A)"

Make it executable and run it:

chmod +x hello.sh
./hello.sh

chmod +x adds execute permission to the file, which is required before ./hello.sh will work. If you skip this step, you will see a Permission denied error. As an alternative that does not require execute permission, you can always run bash hello.sh directly.

Variables

Bash variables are assigned with = and no spaces around it, and read with a $ prefix:

name="Ada"
count=3
echo "Hello, $name. You have $count new messages."

name= "Ada" (with a space) is a common beginner mistake and will produce a syntax error. Bash also supports command substitution, capturing the output of a command into a variable:

kernel_version=$(uname -r)
echo "Running kernel $kernel_version"

The $(...) syntax runs the enclosed command and substitutes its output as text. The older backtick syntax (`command`) does the same thing but is harder to nest and generally discouraged in new scripts.

Script arguments

Arguments passed to a script on the command line are available as positional parameters:

#!/bin/bash
echo "Script name: $0"
echo "First argument: $1"
echo "Second argument: $2"
echo "All arguments: $@"
echo "Number of arguments: $#"

Run it as ./script.sh apple banana and $1 is apple, $2 is banana, $# is 2. Always quote "$1" and "$@", unquoted expansions split on whitespace and will misbehave on any argument that contains a space.

A common pattern is checking that a required argument was actually provided:

#!/bin/bash
if [ -z "$1" ]; then
    echo "Usage: $0 <filename>"
    exit 1
fi

filename="$1"
echo "Processing $filename"

exit 1 stops the script and returns a non-zero exit code, signaling failure to whatever called the script (including your own error-checking logic elsewhere).

Conditionals

The if statement in Bash uses [ ] (or the more modern [[ ]]) for test expressions:

#!/bin/bash
if [ -f "/etc/hostname" ]; then
    echo "hostname file exists"
elif [ -d "/etc" ]; then
    echo "no hostname file, but /etc exists"
else
    echo "something unusual is going on"
fi

Common test operators:

[ -f "$path" ]      # true if $path is a regular file
[ -d "$path" ]      # true if $path is a directory
[ -e "$path" ]      # true if $path exists (any type)
[ -z "$str" ]       # true if $str is empty
[ -n "$str" ]       # true if $str is non-empty
[ "$a" = "$b" ]     # true if strings are equal
[ "$a" -eq "$b" ]   # true if numbers are equal
[ "$a" -gt "$b" ]   # true if $a is greater than $b

String comparison uses =, numeric comparison uses -eq, -gt, -lt, -ge, -le. Mixing them up (using = to compare numbers, for example) is a frequent source of confusing bugs.

Loops

A for loop iterating over a list:

#!/bin/bash
for file in *.log; do
    echo "Found log file: $file"
done

A for loop iterating over a numeric range:

for i in {1..5}; do
    echo "Iteration $i"
done

A while loop, useful when reading input line by line:

#!/bin/bash
while read -r line; do
    echo "Line: $line"
done < "input.txt"

The -r flag to read prevents backslashes in the input from being interpreted as escape characters, which is almost always what you want when processing plain text files.

Functions

Functions let you name and reuse a block of logic within a script:

#!/bin/bash

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

greet "Ada"
greet "Grace"

local restricts a variable’s scope to the function it is declared in, preventing it from leaking into or colliding with variables elsewhere in the script. Functions can return a status with return N (a number from 0 to 255, following the same convention as script exit codes), or produce output that the caller captures with command substitution:

get_disk_usage() {
    df -h / | awk 'NR==2 {print $5}'
}

usage=$(get_disk_usage)
echo "Root filesystem usage: $usage"

Exit codes

Every command, and every script, returns a numeric exit code when it finishes: 0 for success, and any non-zero value for failure, by convention. You can set your script’s exit code explicitly with exit N, and check the exit code of the most recently run command with $?:

grep "error" logfile.txt
if [ $? -eq 0 ]; then
    echo "Found errors in the log"
else
    echo "No errors found"
fi

A more idiomatic way to write the same check is to test the command directly in the if statement, without the intermediate $?:

if grep -q "error" logfile.txt; then
    echo "Found errors in the log"
fi

A safer default header

Many experienced script authors start every script with a standard safety header:

#!/bin/bash
set -euo pipefail

set -e exits immediately on any command failure. set -u treats references to unset variables as an error rather than silently substituting an empty string, catching typos in variable names. set -o pipefail makes a pipeline (cmd1 | cmd2) fail if any command in it fails, not just the last one. Together, these three catch a large fraction of common scripting bugs early, at the cost of occasionally requiring you to explicitly handle commands that are expected to fail.

A complete example

Putting it together, a script that backs up a directory and reports whether it succeeded:

#!/bin/bash
set -euo pipefail

if [ -z "${1:-}" ]; then
    echo "Usage: $0 <directory-to-backup>"
    exit 1
fi

src_dir="$1"
backup_name="backup-$(date +%Y%m%d-%H%M%S).tar.gz"

if [ ! -d "$src_dir" ]; then
    echo "Error: $src_dir is not a directory"
    exit 1
fi

tar -czf "$backup_name" "$src_dir"

if [ -f "$backup_name" ]; then
    echo "Backup created: $backup_name"
else
    echo "Backup failed"
    exit 1
fi

This script checks for a required argument, validates that it is a real directory, creates a timestamped compressed archive with tar, and confirms the result, all patterns covered above combined into one working tool.

Frequently Asked Questions

What is the difference between running a script with ./script.sh and bash script.sh?

Running ./script.sh executes the file directly using the interpreter named on its shebang line (the #!/bin/bash at the top), and requires the file to have execute permission (chmod +x script.sh). Running bash script.sh explicitly invokes Bash and passes the file to it as an argument, ignoring whatever shebang line is present and not requiring execute permission. Both usually produce the same result for a script written for Bash, but ./script.sh is the more portable habit since it respects the interpreter the script author intended, which matters if a script is written for a different shell like sh or zsh.

Why do I get a permission denied error when running my script?

Scripts need execute permission before they can be run directly with ./script.sh. Add it with chmod +x script.sh. If you still get an error after that, check that the shebang line at the top of the file is correct (#!/bin/bash, with no typos or leading whitespace) and that the file was saved with Unix line endings rather than Windows-style CRLF line endings, which can break the shebang line if the script was edited on Windows and transferred without conversion.

What does set -e do and should I always use it?

set -e causes the script to exit immediately if any command returns a non-zero (failure) exit status, instead of continuing to the next line. This is useful for catching errors early rather than letting a script plow ahead after something has already gone wrong. It is not always the right choice: some commands are expected to fail in normal operation (like grep finding no matches), and set -e will kill the whole script in those cases unless you explicitly handle it with something like command || true. Many experienced script authors combine it with set -u (error on undefined variables) and set -o pipefail (catch failures inside a pipeline) as a standard safety header: set -euo pipefail.

How do I pass arguments into a Bash script?

Arguments passed on the command line are available inside the script as $1, $2, $3, and so on, with $0 holding the script name itself. $# holds the total number of arguments, and $@ expands to all arguments as separate words (the form you almost always want when looping over them or passing them through to another command). Always quote expansions like “$1” and ”$@” with double quotes; unquoted expansions get split on whitespace, which breaks on any argument containing a space.

How do I check if a variable is empty or unset in Bash?

Use a conditional test: if [ -z “$myvar” ]; then checks whether the variable is empty or unset, and if [ -n “$myvar” ]; then checks the opposite, that it has a non-empty value. Always quote the variable inside the brackets (“$myvar” not $myvar) so the test does not break or behave unexpectedly when the variable is genuinely empty. Bash also supports default-value expansion, such as ${myvar:-default}, which substitutes default if myvar is unset or empty without needing a separate if statement.

What is the difference between single and double quotes in Bash?

Double quotes allow variable expansion and command substitution inside the string, so “Hello, $USER” prints Hello, followed by the actual username. Single quotes treat everything inside literally, with no expansion at all, so echo ‘Hello, $USER’ prints the text Hello, $USER exactly as written, dollar sign included. As a general rule, use double quotes whenever you want variables to expand, and single quotes when you want the literal text, including cases where the string itself contains characters like $ or backticks that you do not want Bash to interpret.