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.
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.
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.
#!/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
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.
# 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.
A script header is a block of comments at the top of the file that documents what the script does, who wrote it, when, and how to use it. Comments in Bash start with # — Bash ignores everything on a line after #.
In bioinformatics this is not optional. When you come back to a script six months later, or when a reviewer asks "exactly how did you align your reads?", or when a colleague needs to reproduce your analysis — the header is what tells them. A script without a header is a mystery. A script with a good header is documentation.
Two special options are often added right after the shebang to make scripts safer: set -e makes the script stop immediately if any command fails (instead of continuing and doing damage). set -u makes the script stop if you use an undefined variable (catches typos in variable names). Together they are called "strict mode" and every production bioinformatics script should use them.
#!/bin/bash # ============================================================ # Script: fastqc_pipeline.sh # Description: Run FastQC on all FASTQ files in a folder # Author: Shajedur Hossain # Date: 2026-06-18 # Usage: bash fastqc_pipeline.sh /path/to/fastq/ /path/to/output/ # ============================================================ # Strict mode — stop on error, stop on undefined variable set -e # exit immediately if any command fails set -u # treat undefined variables as errors # Your script code goes below here
set -e explained: Without it, if STAR alignment fails, your script continues and runs DESeq2 on empty files — silently producing wrong results. With set -e, the script stops the moment anything fails so you notice immediately.
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.
#!/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"
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.
#!/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.
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.
#!/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.
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.
#!/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
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.
#!/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"
| Syntax | What it does | Notes |
|---|---|---|
| #!/bin/bash | Shebang — must be first line of every script | Tells Linux to use Bash |
| chmod +x script.sh | Make script executable | Only needed once per file |
| bash script.sh | Run a script with Bash explicitly | Does not require chmod +x |
| ./script.sh | Run a script using its shebang | Requires chmod +x first |
| set -e | Stop script immediately on any error | Put after shebang |
| set -u | Stop on undefined variable | Catches typos in var names |
| $1 $2 $3 | Script arguments — first, second, third | $0 = script name |
| $# | Number of arguments passed | Use to validate argument count |
| $@ | All arguments as a list | Useful for passing args forward |
| exit 0 | Exit with success code | exit 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 logfile | Write to screen AND append to file | Pipe into it |
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.
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.
#!/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"
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.
#!/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
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.
#!/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"
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.
#!/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/