Lesson 5 of 13 · ⏱ 50 min · ✓ Free

Writing Bash Scripts

Until now you have typed commands one at a time. A Bash script packages those commands into a reusable, shareable, repeatable file. This is the difference between running an analysis once and being able to re-run it identically on any machine, any dataset, any time. Every real bioinformatics pipeline starts here.

01 What is a Bash script?

📄 A script is just a text file with commands inside

A Bash script is a plain text file containing the same commands you would type in the terminal — but saved so you can run all of them at once, in order, every time you need them.

Think about what you did in Lessons 1–4: you ran fastqc, then trimmomatic, then star, then featureCounts — each time typing the command manually. A script puts all of those into one file. You run the script once, and it executes every step in the right order automatically.

This is why scripts are the foundation of reproducible research. When you write a script, you can re-run the exact same analysis on new data, share it with a colleague, publish it alongside your paper, or run it on 100 samples overnight while you sleep. Without a script, your analysis only exists in your terminal history.

02 The shebang line — telling Linux which interpreter to use

🔧 Why every script must start with #!/bin/bash

The very first line of every Bash script must be the shebang (also called hashbang): #!/bin/bash. The #! is a special two-character sequence that Linux recognises as "the following path is the program that should run this file". /bin/bash is the location of the Bash interpreter.

Without this line, Linux does not know that your file contains Bash commands. It might try to run it as a different scripting language, or refuse to run it at all. Always make it the very first line — no spaces, no blank lines before it.

You will sometimes see #!/usr/bin/env bash instead. This is a more portable version — instead of hardcoding the Bash path, it searches your PATH for Bash. Either works on Ubuntu, but #!/bin/bash is most common in bioinformatics scripts.

Run from: ~/bash-linux-bioinformatics/module-2-scripting
bash
#!/bin/bash
# This MUST be the very first line — no blank lines before it
# It tells Linux: use /bin/bash to interpret this file

#!/usr/bin/env bash
# Alternative — searches PATH for bash — more portable

03 Creating and running a script

⚡ The three steps: create, make executable, run

Every Bash script goes through three steps before it can run. First you create the file with a text editor. Second you make it executable with chmod +x — by default new files are not executable even if they contain a script. Third you run it by calling it with bash script.sh or ./script.sh.

The difference between bash script.sh and ./script.sh: the first explicitly tells Linux to use Bash regardless of the shebang. The second uses the shebang line to decide — which is why the shebang matters. For bioinformatics scripts, both work, but bash script.sh is clearer when you are learning.

File naming convention: always use .sh as the extension and use underscores or hyphens between words — no spaces in file names ever.

Run from: ~/bash-linux-bioinformatics/module-2-scripting
bash
# Step 0 — create the module-2 folder first
mkdir -p ~/bash-linux-bioinformatics/module-2-scripting
cd ~/bash-linux-bioinformatics/module-2-scripting

# Step 1 — create the script with nano
nano my_first_script.sh

# Step 2 — make it executable
chmod +x my_first_script.sh

# Step 3a — run with bash explicitly
bash my_first_script.sh

# Step 3b — run with ./ (uses shebang line)
./my_first_script.sh

# Check permissions after chmod +x
ls -lh my_first_script.sh
-rwxr-xr-x 1 shajedur shajedur 142 Jun 18 10:00 my_first_script.sh
# The x in rwxr-xr-x means executable
💡

You only need to run chmod +x once per script. After that, the file stays executable permanently until you change it.

05 Script arguments — making scripts reusable

🎯 Why arguments are essential for bioinformatics scripts

An argument is a value you pass to a script when you run it, so the script can use different inputs each time without editing the code. Instead of hardcoding INPUT="/data/sample_A.fastq" inside the script, you pass the path when you run it: bash process.sh /data/sample_A.fastq.

Bash stores arguments automatically in special variables: $1 is the first argument, $2 is the second, $3 the third, and so on. $0 is the name of the script itself. $# is the total number of arguments passed. $@ gives you all arguments as a list.

This is what makes a script truly reusable. A script that processes sample_A.fastq with a hardcoded path is only useful once. A script that accepts a path as $1 can process any FASTQ file you will ever have.

Always validate that the required arguments were actually provided before using them. If your script expects two arguments and someone runs it with one, it should print a helpful error message and exit — not crash halfway through with a confusing error.

Run from: ~/bash-linux-bioinformatics/module-2-scripting
bash — using arguments in a script
#!/bin/bash
# Usage: bash process_sample.sh SAMPLE_NAME INPUT_FILE OUTPUT_DIR

# Arguments are available as $1, $2, $3...
SAMPLE="$1"       # first argument
INPUT="$2"        # second argument
OUTDIR="$3"       # third argument

# $# = number of arguments passed
# $0 = the script name itself
# $@ = all arguments as a list

echo "Script name:   $0"
echo "Arguments:     $#"
echo "Sample:        $SAMPLE"
echo "Input file:    $INPUT"
echo "Output folder: $OUTDIR"

🛡 Validating arguments — check before you run

Always check that the right number of arguments was passed before the script does anything. The pattern below prints a usage message and exits with code 1 (failure) if the wrong number of arguments is given. The exit 1 signals to the calling program that something went wrong.

Run from: ~/bash-linux-bioinformatics/module-2-scripting
bash — argument validation pattern
#!/bin/bash
# Validate that exactly 2 arguments were provided
if [ "$#" -ne 2 ]; then
    echo "Error: wrong number of arguments"
    echo "Usage: bash $0 INPUT_FASTQ OUTPUT_DIR"
    echo "Example: bash $0 data/sample1.fastq results/"
    exit 1
fi

INPUT="$1"
OUTDIR="$2"
echo "Processing: $INPUT → $OUTDIR"
💡

Run your script with no arguments first to test the validation: bash process_sample.sh — it should print the usage message and exit cleanly, not crash with a cryptic error.

06 Error handling — stopping when something goes wrong

🚨 Why error handling prevents silent disasters

In bioinformatics, a failed step rarely looks like a crash. STAR might produce an empty BAM file because the genome index path was wrong. DESeq2 might run on zero reads. Without error handling, your pipeline produces output files — but they contain garbage — and you only discover the problem weeks later when you try to interpret the results.

Good error handling catches problems immediately, tells you exactly which step failed, and stops the pipeline before downstream steps run on broken data.

Beyond set -e, you can also manually check the exit status of critical commands and print a meaningful message before exiting. This is especially important for steps that might fail silently — downloads, database connections, tool installations.

Run from: ~/bash-linux-bioinformatics/module-2-scripting
bash — error handling patterns
#!/bin/bash
set -e   # stop on any error
set -u   # stop on undefined variable

# Pattern 1 — check exit status manually
ls species.txt
if [ "$?" -ne 0 ]; then
    echo "Error: species.txt not found. Exiting."
    exit 1
fi

# Pattern 2 — inline error message with ||
# || means "if the left command fails, run the right command"
ls species.txt || { echo "Error: file not found"; exit 1; }

# Pattern 3 — check that a required file exists before using it
INPUT_FILE="sample.fastq"
if [ ! -f "$INPUT_FILE" ]; then
    echo "Error: input file '$INPUT_FILE' does not exist"
    exit 1
fi
echo "Input file found: $INPUT_FILE"

# -f checks if a file exists and is a regular file
# -d checks if a directory exists
# ! negates the check

set -e stops the script on the first failed command. This is almost always what you want. But be aware that some commands intentionally return non-zero exit codes — for example, grep returns 1 when it finds no matches. In those cases, add || true after the command to tell Bash the failure is expected and acceptable.

07 Logging — keeping a record of what happened

📝 Why logging is essential in bioinformatics pipelines

A log file is a permanent record of exactly what your script did, when, and what output it produced. When something goes wrong three hours into a pipeline run — or three months later when a reviewer asks a question — the log tells you everything.

The simplest logging approach uses a timestamp function and tee. tee reads from stdin and writes to both the screen and a file simultaneously — so you see the output in real time AND it is saved to the log.

A more complete approach redirects the entire script's stdout and stderr into a log file from the start, while still showing output on screen. This captures everything — including error messages from tools that write to stderr.

Run from: ~/bash-linux-bioinformatics/module-2-scripting
bash — logging patterns
#!/bin/bash

# Pattern 1 — timestamp function
# Call log "message" anywhere in the script to print with timestamp
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

log "Pipeline started"
[2026-06-18 10:32:15] Pipeline started

log "Running FastQC..."
[2026-06-18 10:32:16] Running FastQC...

# Pattern 2 — tee: show on screen AND save to log file
log "Step 1 complete" | tee -a pipeline.log
# -a = append (do not overwrite existing log)

# Pattern 3 — redirect ALL output to log from the start
LOG_FILE="pipeline_$(date +%Y%m%d_%H%M%S).log"
exec >  >(tee -a "$LOG_FILE")
exec 2> >(tee -a "$LOG_FILE" >&2)
# Everything printed after these two lines goes to screen AND to log file

08 A complete bioinformatics script

Here is everything from this lesson combined into one production-ready script. Study the structure — this is the template you will use for every pipeline script in this course.

Run from: ~/bash-linux-bioinformatics/module-2-scripting
bash — complete script template
#!/bin/bash
# ============================================================
# Script:      count_reads.sh
# Description: Count reads in all FASTQ files in a folder
#              and save results to a summary file
# Author:      Shajedur Hossain
# Date:        2026-06-18
# Usage:       bash count_reads.sh INPUT_FOLDER OUTPUT_FOLDER
# Example:     bash count_reads.sh ~/data/raw/ ~/results/
# ============================================================

set -e
set -u

# ── Logging function ────────────────────────────────────────
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

# ── Validate arguments ──────────────────────────────────────
if [ "$#" -ne 2 ]; then
    echo "Error: wrong number of arguments"
    echo "Usage:   bash $0 INPUT_FOLDER OUTPUT_FOLDER"
    echo "Example: bash $0 ~/data/raw/ ~/results/"
    exit 1
fi

INPUT_DIR="$1"
OUTPUT_DIR="$2"

# ── Check inputs exist ──────────────────────────────────────
if [ ! -d "$INPUT_DIR" ]; then
    echo "Error: input folder '$INPUT_DIR' not found"
    exit 1
fi

# ── Create output folder if it does not exist ───────────────
mkdir -p "$OUTPUT_DIR"

# ── Main logic ──────────────────────────────────────────────
SUMMARY="${OUTPUT_DIR}/read_counts.txt"
echo "sample	reads" > "$SUMMARY"

log "Starting read count pipeline"
log "Input:  $INPUT_DIR"
log "Output: $OUTPUT_DIR"

FILE_COUNT=0

for FASTQ in "${INPUT_DIR}"*.fastq; do
    if [ ! -f "$FASTQ" ]; then
        log "Warning: no .fastq files found in $INPUT_DIR"
        break
    fi
    SAMPLE=$(basename "$FASTQ" .fastq)
    LINES=$(wc -l < "$FASTQ")
    READS=$(( LINES / 4 ))
    echo "${SAMPLE}	${READS}" >> "$SUMMARY"
    log "  $SAMPLE: $READS reads"
    FILE_COUNT=$(( FILE_COUNT + 1 ))
done

log "Done. Processed $FILE_COUNT file(s). Summary: $SUMMARY"

09 Quick reference

SyntaxWhat it doesNotes
#!/bin/bashShebang — must be first line of every scriptTells Linux to use Bash
chmod +x script.shMake script executableOnly needed once per file
bash script.shRun a script with Bash explicitlyDoes not require chmod +x
./script.shRun a script using its shebangRequires chmod +x first
set -eStop script immediately on any errorPut after shebang
set -uStop on undefined variableCatches typos in var names
$1 $2 $3Script arguments — first, second, third$0 = script name
$#Number of arguments passedUse to validate argument count
$@All arguments as a listUseful for passing args forward
exit 0Exit with success codeexit 1 = failure
cmd || { echo "msg"; exit 1; }Run recovery code if command fails|| = "or if fails"
[ -f file ]Check if file exists-d for directory, ! to negate
tee -a logfileWrite to screen AND append to filePipe into it

10 Exercises

Work through all four exercises in your Ubuntu terminal. Create each script in ~/bash-linux-bioinformatics/module-2-scripting/. Write every script yourself — do not copy-paste.

Exercise 1Your first complete script

Create a script called hello_bio.sh in ~/bash-linux-bioinformatics/module-2-scripting/. It should have a proper shebang, set -e, set -u, a header comment block, and print three lines: your name, today's date using command substitution, and the number of files in your module-1-foundations folder.

💬 Hint: use $(date +%Y-%m-%d) for the date and $(ls ~/bash-linux-bioinformatics/module-1-foundations/ | wc -l) for the file count.

Show answer
#!/bin/bash
# ============================================================
# Script:      hello_bio.sh
# Description: My first complete Bash script
# Author:      Shajedur Hossain
# Date:        2026-06-18
# Usage:       bash hello_bio.sh
# ============================================================
set -e
set -u

NAME="Shajedur Hossain"
TODAY=$(date +%Y-%m-%d)
FILE_COUNT=$(ls ~/bash-linux-bioinformatics/module-1-foundations/ | wc -l)

echo "Name:       $NAME"
echo "Date:       $TODAY"
echo "M1 files:   $FILE_COUNT"
Exercise 2Script with arguments and validation

Create greet_sample.sh. It should accept exactly one argument — a sample name — and print "Processing sample: [name]". If no argument is provided, it should print a usage message and exit with code 1. Test it both with and without an argument.

💬 Hint: check $# equals 1 using if [ "$#" -ne 1 ]. Use $1 for the sample name.

Show answer
#!/bin/bash
set -e
set -u

if [ "$#" -ne 1 ]; then
    echo "Error: expected 1 argument"
    echo "Usage: bash $0 SAMPLE_NAME"
    echo "Example: bash $0 SRR123456"
    exit 1
fi

SAMPLE="$1"
echo "Processing sample: $SAMPLE"

# Test with: bash greet_sample.sh SRR123456
# Test without: bash greet_sample.sh
Exercise 3Script with logging

Create logged_script.sh. Add a log() function that prints messages with a timestamp. Use it to log: script started, a loop over three species names (maize, rice, sorghum), and script finished. Run the script and observe the timestamps.

💬 Hint: define log() { echo "[$(date '+%H:%M:%S')] $1"; } then call log "message" throughout.

Show answer
#!/bin/bash
set -e
set -u

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

log "Script started"

for SPECIES in maize rice sorghum; do
    log "Processing: $SPECIES"
done

log "Script finished"
Exercise 4 · ChallengeA real FASTQ summary script

Create fastq_summary.sh that accepts one argument — a folder path. It should validate the argument, check the folder exists, then loop over all .fastq files in that folder and print the sample name and read count for each. Include a log() function, set -e, and set -u. Test it with ~/bash-linux-bioinformatics/module-1-foundations/.

💬 Hint: combine the argument validation from Exercise 2, the log function from Exercise 3, and the FASTQ loop from Lesson 4.

Show answer
#!/bin/bash
# Usage: bash fastq_summary.sh FOLDER_PATH
set -e
set -u

log() { echo "[$(date '+%H:%M:%S')] $1"; }

if [ "$#" -ne 1 ]; then
    echo "Usage: bash $0 FOLDER_PATH"
    exit 1
fi

FOLDER="$1"

if [ ! -d "$FOLDER" ]; then
    echo "Error: folder '$FOLDER' not found"
    exit 1
fi

log "Scanning: $FOLDER"

for FILE in "${FOLDER}"*.fastq; do
    if [ ! -f "$FILE" ]; then
        log "No .fastq files found"
        break
    fi
    NAME=$(basename "$FILE" .fastq)
    READS=$(( $(wc -l < "$FILE") / 4 ))
    log "$NAME: $READS reads"
done

log "Done"

# Run with:
# bash fastq_summary.sh ~/bash-linux-bioinformatics/module-1-foundations/