# How to Write Bash Functions for Bioinformatics

By the end of this article you will be able to write, test, debug, and reuse bash functions that wrap the tasks you repeat every day on the command line: quality control on FASTQ files, adapter trimming, read counting, alignment, and organizing results into tidy per-sample folders. You will also know how to validate inputs, log what ran, handle failures cleanly, and package your functions into a personal library you can load in any terminal.

Here is what you need on hand before you start:

- A Linux machine, a macOS terminal, or Windows Subsystem for Linux (WSL2). Bash version 4.0 or newer is recommended.
- A plain text editor such as nano, vim, or VS Code.
- A directory with a few paired-end FASTQ files for testing. Files ending in `.fastq.gz` work for every example below.
- Optional but useful tools for the worked examples: `fastqc`, `fastp`, and `seqkit`. If you use conda, you can install them with `conda install -c bioconda fastqc fastp seqkit`.
- The standard Linux commands bioinformatics work depends on: `ls`, `cd`, `mkdir`, `cat`, `zcat`, `wc`, `basename`, `find`, and `grep`.

Bash functions are not a niche skill. Published pipelines in the recent literature are still built and run this way. PePApipe, a complete analysis pipeline for African Swine Fever Virus genomes, is executed locally through bash scripts and organizes its thirteen integrated tools into structured output folders [1]. DoriTool, an integrative post-association annotation pipeline, follows a modular design pattern implemented with scripts written in Bash, Perl, and R [2]. Teaching protocols for next-generation sequencing analysis explicitly build student skills on Bash before moving to R, starting with quality check and trimming of sequencing reads [3]. Learning to write functions well is the step that turns scattered one-off commands into something you can rerun, share, and trust.

## Why Bash Functions Matter in Bioinformatics

Most bioinformatics analysis on a personal server or an HPC login node is still done in a terminal. The daily rhythm looks like this: check the reads, trim adapters, align to a reference, count features, repeat for every sample. Each step is one or two Linux commands, but you type them dozens of times with small variations in file names.

A bash function fixes that. A function is a named block of shell code that you define once and call by name, passing in the parts that change (the sample name, the input directory, the thread count) while the parts that never change (the tool flags, the output layout, the logging) stay fixed in one place.

Three concrete benefits matter in a lab setting:

1. **Consistency.** When the trimming command lives inside one function, every sample is trimmed with exactly the same parameters. You stop wondering whether sample 17 used the old adapter string.
2. **Fewer typos on irreplaceable data.** Retyping a long command forty times is how people accidentally overwrite the wrong FASTQ. A function with input validation refuses to run when a file is missing.
3. **Shareability.** A function file can be handed to a colleague, added to a project repository, or grown into a small pipeline. Several published tools began life as collections of scripts. CASCADE, a cross-platform analysis pipeline for multi-electrode array recordings, is distributed as a command-line tool on GitHub and installable through PyPI, with its source code archived on Zenodo [4]. That distribution path starts with clean, reusable scripts.

A note on vocabulary, because the terms confuse beginners. A **command** is a program or built-in you run, such as `fastqc`. A **function** is shell code you define in the current session or in a sourced file. A **script** is a text file bash executes from top to bottom. A **pipeline** chains several tools so the output of one feeds the next. Functions sit in the middle: they are the building blocks you assemble into scripts and pipelines.

## Linux for Bioinformatics: Preparing Your Environment

Before writing functions, confirm your environment. Open a terminal and run:

```bash
bash --version
echo $SHELL
echo $PATH
which fastqc fastp seqkit zcat
```

Expected output on a healthy system looks like this (versions will differ):

```
GNU bash, version 5.1.16(1)-release
/bin/bash
/home/you/bin:/usr/local/bin:/usr/bin:/bin
/usr/bin/fastqc
/usr/local/bin/fastp
/usr/bin/seqkit
/usr/bin/zcat
```

If `which` prints nothing for a tool, that tool is not on your `PATH` and any function calling it will fail with `command not found`. Fix that now, not inside your functions later.

Create a practice directory so you never experiment on real sequencing data:

```bash
mkdir -p ~/bash_practice/data ~/bash_practice/results
cd ~/bash_practice
```

If you have no small test FASTQ files, you can make a tiny synthetic one. FASTQ is a four-line text format: a header starting with `@`, the sequence, a `+` line, and quality scores of equal length. Ten thousand synthetic reads are plenty for testing logic:

```bash
for i in $(seq 1 10000)
do
  printf '@read_%d\nACGTACGTACGTACGTACGT\n+\nIIIIIIIIIIIIIIIIIIII\n' "$i"
done > data/test_R1.fastq
gzip data/test_R1.fastq
```

Everything below runs against `data/test_R1.fastq.gz` or, for paired-end examples, copies of it named `test_R2.fastq.gz`.

## Writing Bash Functions for Bioinformatics Step by Step

This section is a numbered walkthrough. Type each block, run it, compare the output. Do not paste the whole thing at once. The point of bash functions bioinformatics practice is that each idea lands in order.

### Step 1: Learn the Anatomy of a Function

Bash accepts two equivalent syntaxes:

```bash
# Form one: the function keyword
function greet {
    echo "Hello from greet"
}

# Form two: POSIX style, used most often in bioinformatics code
greet2() {
    echo "Hello from greet2"
}
```

The parentheses after the name are decoration in bash. They hold nothing. What matters is the name, the curly braces, and the body between them. To define a function, paste the block into your terminal or into a file you then `source`. To call it, type its name:

```bash
greet
greet2
```

Expected output:

```
Hello from greet
Hello from greet2
```

Two rules of bash anatomy surprise newcomers. First, definitions are not executions. Pasting the block prints nothing. The function only runs when called. Second, definitions are session-scoped. Close the terminal and the function is gone, unless you saved it in a file and sourced that file. Step 8 handles persistence.

A style note that will save you debugging time: write `do`, `then`, and `fi` on their own lines rather than packing commands onto one line with semicolons. Multi-line code reads the same way six months later as it does today, and it avoids a whole class of syntax errors covered later.

### Step 2: Write Your First Useful Function

Skip `hello world`. The first function every bioinformatician needs counts reads in a FASTQ file. The logic uses a fact of the [FASTQ format](/knowledge/bioinformatics/fastq-file-format-phred-scores-qc): every record is exactly four lines, so the number of reads equals the line count divided by four.

```bash
count_reads() {
    local fastq="$1"
    local lines
    lines=$(zcat "$fastq" | wc -l)
    printf '%s\t%s\n' "$fastq" "$(( lines / 4 ))"
}
```

Call it:

```bash
count_reads data/test_R1.fastq.gz
```

Expected output:

```
data/test_R1.fastq.gz	10000
```

Walk through what happens. `local fastq="$1"` stores the first argument in a variable named `fastq`. The `local` keyword keeps that variable from leaking into your shell session, which matters for reasons explained in Step 6. `zcat` decompresses the gzip file to standard output without writing a decompressed copy to disk. `wc -l` counts lines. `$( ... )` is command substitution: it captures the output of the pipeline into the variable `lines`. Arithmetic expansion `$(( lines / 4 ))` performs integer division. `printf` prints the file name and count separated by a tab, which keeps the output machine-readable if you later collect counts across many samples into a table.

One caveat about this function: it streams the entire decompressed file, so on a 30 GB FASTQ it takes real time. For production work you would use `seqkit stats`, which reads gzip directly and reports read counts, base counts, and N50 in one pass. The naive version above is worth keeping in your head because it explains the format and because it works on any machine with zero bioinformatics software installed.

### Step 3: Accept Arguments the Right Way

Inside a function, bash gives you special variables called **positional parameters**:

- `$1`, `$2`, `$3` hold the first, second, and third arguments.
- `$#` holds the number of arguments.
- `$@` holds all arguments, and when quoted as `"$@"` preserves each one as a separate word, even file names containing spaces.
- `$*` also holds all arguments but joins them into one string. Almost never use it.
- `$?` holds the exit status of the most recent command, where 0 means success and anything else means failure.
- `${var:-default}` expands to `default` when `var` is unset or empty, which is how you give optional arguments fallback values.
- `${var:?message}` aborts with `message` when `var` is unset, which is how you make required arguments loud.

Here is a function that uses several of these at once:

```bash
run_fastqc() {
    local outdir="${1:-qc_raw}"
    shift
    local fastqs=("$@")
    local n=$#

    if [ "$n" -eq 0 ]
    then
        echo "Usage: run_fastqc [outdir] fastq1 [fastq2 ...]" >&2
        return 1
    fi

    mkdir -p "$outdir"
    fastqc --threads 4 --outdir "$outdir" "${fastqs[@]}"
    echo "FastQC done for $n file(s), results in $outdir"
}
```

Note the `shift`. It discards the first argument (the output directory, which got a default value of `qc_raw`) so that everything remaining in `$@` is a FASTQ file. The line `local fastqs=("$@")` stores the remaining arguments as an array, and `"${fastqs[@]}"` expands each element as its own word. This is the standard, safe way to pass an arbitrary number of file names through a function.

Call it with a default output directory:

```bash
run_fastqc data/test_R1.fastq.gz
```

Expected output ends with something like:

```
Analysis complete for test_R1.fastq
FastQC done for 1 file(s), results in qc_raw
```

If you call it with no arguments, you get the usage message on standard error and a nonzero exit status, exactly as designed:

```
Usage: run_fastqc [outdir] fastq1 [fastq2 ...]
```

### Step 4: Validate Inputs Before Running Anything

![Diagram of a QC function checking arguments, then R1, R2 and thread count before calling fastp or returning an error.](/article-images/illustrations/bash-functions-bioinformatics-guard-clause-path.webp)

The cheapest failure is one that happens in the first tenth of a second. Guard clauses check preconditions and bail out before the expensive tool launches. This pattern is the single highest-value habit in this article.

```bash
qc_trim() {
    local r1="$1"
    local r2="$2"
    local outdir="$3"
    local threads="${4:-8}"

    if [ -z "$r1" ] || [ -z "$r2" ] || [ -z "$outdir" ]
    then
        echo "Usage: qc_trim R1.fastq.gz R2.fastq.gz outdir [threads]" >&2
        return 1
    fi

    if [ ! -f "$r1" ]
    then
        echo "R1 file not found: $r1" >&2
        return 1
    fi

    if [ ! -f "$r2" ]
    then
        echo "R2 file not found: $r2" >&2
        return 1
    fi

    if [ "$threads" -lt 1 ] 2>/dev/null
    then
        echo "threads must be a positive integer" >&2
        return 1
    fi

    echo "Checks passed. Would run fastp here with $threads threads."
}
```

Each test checks one precondition and returns immediately with a clear message if it fails. `-z` tests for an empty string, `-f` tests that a regular file exists, and the redirect `2>/dev/null` on the numeric comparison suppresses the error bash would print if someone passed a non-numeric value, letting your own message take over.

Trigger each guard on purpose so you know what failure looks like:

```bash
qc_trim data/test_R1.fastq.gz
qc_trim data/missing_R1.fastq.gz data/test_R2.fastq.gz results/s1
qc_trim data/test_R1.fastq.gz data/test_R2.fastq.gz results/s1 abc
```

Expected output:

```
Usage: qc_trim R1.fastq.gz R2.fastq.gz outdir [threads]
R1 file not found: data/missing_R1.fastq.gz
threads must be a positive integer
```

In real published pipelines this same discipline appears at scale. PePApipe classifies generated files into structured folders and produces intermediate files usable as inputs for further analyses, which only works when every upstream step validated its inputs and wrote to its expected location [1]. Your guard clauses are the small-scale version of that contract.

### Step 5: Control Exit Status and Return Values

![Comparison table of return and exit in bash functions showing what each ends, what the caller sees, and when to use each.](/article-images/illustrations/bash-functions-bioinformatics-return-vs-exit.webp)

Bash functions communicate success in one currency: the exit status, an integer from 0 to 255 where 0 means success. Two keywords control it, and confusing them is a classic bug.

- `return N` ends the function with status N. Use it for success and failure signals.
- `exit N` ends the entire shell, not just the function. Calling `exit` inside a function you source into your interactive session closes your terminal.

Watch the difference in practice:

```bash
check_file() {
    if [ -f "$1" ]
    then
        return 0
    else
        return 1
    fi
}

check_file data/test_R1.fastq.gz
echo "status was $?"

check_file data/nope.fastq.gz
echo "status was $?"
```

Expected output:

```
status was 0
status was 1
```

If a function reaches its end without a `return`, its status is the status of the last command it ran. That implicit behavior is convenient but can bite you. If the last command in a function is `echo`, the function succeeds even when the tool it wrapped failed. End important functions with an explicit `return` tied to the real outcome.

Exit status is not the only channel. Functions also produce **standard output**, and you can capture it with command substitution:

```bash
sample_base() {
    local fastq="$1"
    basename "$fastq" .fastq.gz
}

base=$(sample_base data/test_R1.fastq.gz)
echo "base name is $base"
```

Expected output:

```
base name is test_R1
```

The rule of thumb: use the exit status for "did it work", use standard output for "here is a value". A function that reports a number should print the number and nothing else to stdout. Diagnostics, warnings, and usage text go to standard error with `>&2`, so that capturing stdout never captures your logging.

You can also distinguish which command inside a pipeline failed. Without help, `$?` reports only the last command in a pipeline. The `PIPESTATUS` array holds every member's status:

```bash
zcat data/test_R1.fastq.gz | wc -l
echo "zcat status ${PIPESTATUS}, wc status ${PIPESTATUS[1]}"
```

Expected output:

```
40000
zcat status 0, wc status 0
```

### Step 6: Keep Variables Local

Without the `local` keyword, every variable you set inside a function overwrites a variable of the same name in the rest of your shell or calling script. This bug is silent, delayed, and devastating. Demonstrate it to yourself once so you never forget it:

```bash
sample="MASTER_COPY"

bad_function() {
    sample="OVERWRITTEN"
    echo "inside: $sample"
}

good_function() {
    local sample="LOCAL_ONLY"
    echo "inside: $sample"
}

bad_function
echo "after bad: $sample"

good_function
echo "after good: $sample"
```

Expected output:

```
inside: OVERWRITTEN
after bad: OVERWRITTEN
inside: LOCAL_ONLY
after good: MASTER_COPY
```

The variable `sample` survived the second call and died in the first. In a real project the overwritten variable might be a path used later by a long-running script, and the failure shows up three steps away from the cause.

Make it a hard rule: every variable assigned inside a function gets `local`. Declare it on first assignment, and if you need to assign in two steps, declare the name first:

```bash
myfunc() {
    local result
    result=$(some_command)
    echo "$result"
}
```

### Step 7: Log Progress and Organize Output Folders

Functions that print a timestamped line for every major step turn a silent twenty-minute run into something you can monitor and audit. A tiny logging function covers the need:

```bash
log() {
    printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}

log "Starting QC for sample S1"
```

Expected output:

```
[2026-11-11 14:32:07] Starting QC for sample S1
```

The second habit is a disciplined output layout. Create one directory per sample, with subdirectories for each analysis stage, and have your functions create it with `mkdir -p` (which creates parents as needed and does nothing if the path already exists):

```bash
make_sample_dirs() {
    local sample="$1"
    local root="${2:-results}"
    mkdir -p "$root/$sample"/{qc,trimmed,aligned,reports}
    log "Created $root/$sample directory tree"
}

make_sample_dirs S1
find results -type d
```

Expected output:

```
[2026-11-11 14:32:09] Created results/S1 directory tree
results
results/S1
results/S1/qc
results/S1/trimmed
results/S1/aligned
results/S1/reports
```

This is not merely tidiness. Structured, predictable output folders are a documented design feature of working pipelines. PePApipe classifies its generated files into structured folders and deliberately produces intermediate files that can serve as inputs for further or parallel analyses [1]. DoriTool's modular design, which integrates variant, gene, pathway, and network level annotation from third-party tools, depends on each module writing results where the next module expects them [2]. Decide on your folder scheme once, encode it in functions, and every sample lands in the same shape.

### Step 8: Build a Reusable Function Library

Functions defined in a terminal vanish when it closes. To persist them, put your functions in a plain text file and `source` it, which executes the file in the current shell so the definitions become available. A common layout is one file, `biofunctions.sh`, kept in a `bin` directory in your home:

```bash
mkdir -p ~/bin
nano ~/bin/biofunctions.sh
```

Paste your functions in, save, then load them:

```bash
source ~/bin/biofunctions.sh
type qc_trim
```

Expected output:

```
qc_trim is a function
qc_trim ()
{
    local r1="$1"
    ...
}
```

`type` is your friend for verifying that a name resolves to the function you think it is, especially when a program with the same name exists on your `PATH`. To load the library automatically in every new terminal, add the source line to `~/.bashrc`:

```bash
echo 'source ~/bin/biofunctions.sh' >> ~/.bashrc
source ~/.bashrc
```

For projects with collaborators, go one step further and keep `biofunctions.sh` under version control in the project repository, then source it with a path relative to the script that needs it. Distributing well-documented command-line tools through public repositories is now routine in the field, as CASCADE does with its GitHub repository, PyPI package, and Zenodo-archived source [4]. Your function library is the same pattern at personal scale, and documenting each function with a short comment block (name, arguments, example call) costs five minutes and saves hours of reverse engineering.

## A Worked Example: Paired-End QC and Trimming

Now assemble everything into the function you would actually run on sequencing data: validation, adapter and quality trimming with `fastp`, timestamped logging, and a structured output folder. `fastp` performs adapter trimming, quality filtering, and report generation in a single pass, which is why it is a common component of teaching protocols and pipelines built around standard read processing steps such as quality check and trimming [3].

```bash
qc_trim() {
    local r1="$1"
    local r2="$2"
    local outdir="$3"
    local threads="${4:-8}"

    # Guard clauses
    if [ ! -f "$r1" ]
    then
        log "ERROR: R1 not found: $r1" >&2
        return 1
    fi
    if [ ! -f "$r2" ]
    then
        log "ERROR: R2 not found: $r2" >&2
        return 1
    fi

    # Structured output
    mkdir -p "$outdir"

    local base1
    local base2
    base1=$(basename "$r1" .fastq.gz)
    base2=$(basename "$r2" .fastq.gz)

    log "Trimming $base1 and $base2 with $threads threads"

    fastp \
        -i "$r1" \
        -I "$r2" \
        -o "$outdir/${base1}_trimmed.fastq.gz" \
        -O "$outdir/${base2}_trimmed.fastq.gz" \
        --detect_adapter_for_pe \
        --thread "$threads" \
        --json "$outdir/fastp.json" \
        --html "$outdir/fastp.html"

    local status=$?
    if [ "$status" -ne 0 ]
    then
        log "ERROR: fastp failed with status $status for $base1" >&2
        return "$status"
    fi

    log "Done. Output in $outdir"
    return 0
}
```

Line by line, the parameters that matter are:

- `-i` and `-I` (capital I) name the read 1 and read 2 input files. Lowercase and uppercase are different options here, a frequent source of confusion.
- `-o` and `-O` name the trimmed outputs. Writing them into the sample's own directory keeps parallel samples from colliding.
- `-detect_adapter_for_pe` asks fastp to auto-detect the adapter sequence for paired-end data by overlap analysis, rather than requiring you to hardcode an adapter string.
- `-thread` sets worker threads. Match it to the cores you actually have.
- `-json` and `-html` write machine-readable and human-readable QC reports into the sample folder, where they become part of the permanent record.

The backslashes at line ends are line continuations. They let one long command span readable lines. Note that the continuation must be the very last character on the line, with no trailing spaces.

Run it:

```bash
cp data/test_R1.fastq.gz data/test_R2.fastq.gz
qc_trim data/test_R1.fastq.gz data/test_R2.fastq.gz results/S1 4
```

Expected output:

```
[2026-11-11 14:40:12] Trimming test_R1 and test_R2 with 4 threads
Read1 before filtering: 10000 reads
Read2 before filtering: 10000 reads
...
Read1 after filtering: 10000 reads
[2026-11-11 14:40:12] Done. Output in results/S1
```

(The read counts shown by fastp will match your synthetic file. With real data, counts after filtering are typically somewhat lower, because reads failing quality or adapter criteria are removed.)

### Running a Batch of Samples

One function becomes a pipeline when you loop it. This driver function iterates over every read 1 file matching a pattern, derives the read 2 name, calls `qc_trim`, and records any failure without aborting the rest of the batch:

```bash
batch_qc() {
    local indir="$1"
    local outroot="$2"

    for r1 in "$indir"/*_R1.fastq.gz
    do
        local fname
        fname=$(basename "$r1")
        local base="${fname%_R1.fastq.gz}"
        local r2="$indir/${base}_R2.fastq.gz"

        if [ ! -f "$r2" ]
        then
            log "WARN: no R2 for $base, skipping" >&2
            continue
        fi

        if ! qc_trim "$r1" "$r2" "$outroot/$base"
        then
            log "FAILED: $base" >&2
            echo "$base" >> "$outroot/failed_samples.txt"
        fi
    done
    log "Batch complete"
}
```

Two shell idioms here earn their keep. `${fname%_R1.fastq.gz}` strips the suffix `_R1.fastq.gz` from the end of the file name, leaving the sample base name. The construct `if ! qc_trim ...` runs the function and inverts its status, so the `if` body executes only on failure. Writing the failed sample name to `failed_samples.txt` gives you a one-file summary at the end, which matters when a batch of 96 samples contains two problems you would otherwise hunt for by scrolling a terminal buffer.

### Checking That the Result Is Right

Never trust a tool run you have not spot-checked. After trimming a sample, verify four things:

```bash
# 1. Exit status of the last run was 0
echo $?

# 2. All four expected outputs exist and are nonzero
ls -lh results/S1
```

Expected output:

```
-rw-r--r-- 1 you you  12K Nov 11 14:40 fastp.html
-rw-r--r-- 1 you you 3.1K Nov 11 14:40 fastp.json
-rw-r--r-- 1 you you 1.5M Nov 11 14:40 test_R1_trimmed.fastq.gz
-rw-r--r-- 1 you you 1.5M Nov 11 14:40 test_R2_trimmed.fastq.gz
```

A zero-byte trimmed FASTQ means the run died partway. Use `find` to catch that failure mode across a whole batch:

```bash
find results -name "*trimmed.fastq.gz" -size 0
```

No output means no empty files. Then confirm read counts on input and output agree with expectations:

```bash
count_reads results/S1/test_R1_trimmed.fastq.gz
```

Finally, glance at the JSON report for the numbers that decide whether the trim was sane:

```bash
grep -o '"total_reads":[0-9]*' results/S1/fastp.json | head -2
grep -o '"q30_rate":[0-9.]*' results/S1/fastp.json | head -2
```

For real data, compare the Q30 rate before and after. An after-trimming Q30 rate that dropped would indicate something went wrong, since trimming should remove poor-quality bases and raise per-base quality. And when both read 1 and read 2 of a pair went through the same filter, their post-trim read counts must match each other exactly. If they diverge, the pairing is broken and every downstream alignment step will silently degrade.

## Key Shell Parameters Reference

The special parameters below appear constantly in bash functions bioinformatics work. Keep this table within reach until they are reflex.

| Parameter | Meaning | Typical use in a bioinformatics function |
|--|--|--|
| `$1`, `$2`, `$3` | First, second, third argument | `local r1="$1"` for the read 1 input |
| `$#` | Number of arguments | Guard clause: refuse to run with zero arguments |
| `"$@"` | All arguments, individually quoted | Passing many FASTQ files to a tool |
| `$?` | Exit status of the last command | `if [ "$?" -ne 0 ]` after a tool call |
| `${var:-default}` | Default value if unset or empty | `local threads="${4:-8}"` |
| `${var:?msg}` | Abort with message if unset | Required output paths |
| `${var%pattern}` | Strip pattern from the end | `base="${fname%_R1.fastq.gz}"` |
| `${var#pattern}` | Strip pattern from the start | Removing a leading directory prefix |
| `${PIPESTATUS[@]}` | Status of each pipeline member | Detecting a failed `zcat` feeding `wc` |
| `$!` | PID of the most recent background job | Monitoring a launched tool |

## Common Errors and Fixes

Every error below is one you will hit. The messages are quoted exactly as bash or the operating system prints them, so you can search them when they appear.

| Exact message | Cause | Fix |
|--|--|--|
| `bash: fastp: command not found` | Tool not installed or not on `PATH` | Run `which fastp`. If empty, install the tool (for example with conda) or call it by full path |
| `qc_trim: command not found` | Function not defined in this shell | Re-source your library: `source ~/bin/biofunctions.sh`, then confirm with `type qc_trim` |
| `zcat: data/x.fastq.gz: No such file or directory` | Wrong path, typo, or wrong working directory | Run `ls data/` and use absolute paths or `realpath` in your validation block |
| `bash: results/S1/fastp.json: Permission denied` | No write permission on the output location | Check with `ls -ld results/S1`, fix with `chmod u+w` or write to a directory you own |
| `line 9: threads: unbound variable` | Script runs under `set -u` and the variable was never set | Provide a default: `local threads="${4:-8}"` |
| `syntax error near unexpected token 'then'` | `then` glued to `if` without a separator | Put `then` on its own line, or after a newline rather than a bare junction |
| `bash: $1: bad substitution` | POSIX-only shell (like `dash` or `sh`) read bash syntax | Run with `bash script.sh`, add a shebang `#!/usr/bin/env bash`, and avoid sourcing bash files from `sh` |
| `bash: line 4: $outdir: ambiguous redirect` | A redirect target variable was empty or contained multiple words | Quote and validate: `[ -n "$outdir" ]` before `> "$outdir/log.txt"` |
| `[: too many arguments` | Unquoted variable with spaces inside `[ ... ]` | Quote the variable or use `[[ ... ]]` |

Two of these deserve extra explanation because they are the most common bash functions bioinformatics mistakes in practice.

The **unbound variable** error appears the moment you adopt `set -u` (treat unset variables as errors), which you should adopt, because it converts silent empty-string bugs into loud failures. The fix is not to remove `set -u` but to make every optional argument explicit with `${var:-default}` and every required argument explicit with `${var:?message}`.

The **syntax error near unexpected token** error almost always traces back to writing shell keywords on one line without separators, such as `if [ -f "$x" ] then`. Bash needs a command separator (a newline or a semicolon) between the test and `then`. Since the clean fix is a newline, just adopt the multi-line style shown throughout this article and the error disappears from your life.

## Bash Functions Bioinformatics Tips

These tips compress the habits that separate fragile scripts from dependable ones.

**Start every script and sourced library with strict mode.**

```bash
set -euo pipefail
```

Three flags, three behaviors. `-e` makes the shell exit when any command fails, so a failed alignment cannot be silently followed by a variant call on missing files. `-u` treats unset variables as errors, catching typos in variable names. `-o pipefail` makes a pipeline report failure if any member fails, not just the last one. With plain `set -e`, the pipeline `zcat missing.gz | wc -l` would appear to succeed because `wc` succeeded. With `pipefail`, it fails correctly.

**Run ShellCheck on every function file.** ShellCheck is a static analyzer for shell scripts, available in every Linux package manager as `shellcheck`. It catches quoting errors, unused variables, and unhandled failures before they cost you a compute night. Make it part of saving the file:

```bash
shellcheck ~/bin/biofunctions.sh
```

Clean output with no findings is the goal. Treat every warning it reports as a real bug until proven otherwise, because in shell, most of them are.

**One function, one job.** A function that trims, aligns, calls variants, and emails you a report cannot be tested, reused, or debugged. Keep each function small enough that you can state what it does in one sentence, and compose them in a driver script. The modular design pattern that DoriTool follows, integrating third-party tools as separable modules rather than one monolith, is the same principle applied at pipeline scale [2].

**Send diagnostics to stderr.** Anywhere your function explains itself, redirect with `>&2`. This keeps stdout clean for data, so command substitution and pipes keep working even when the function is chatty.

**Make functions idempotent.** Running the same function twice on the same inputs should produce the same result, not a crash or a corrupted mix. `mkdir -p` instead of `mkdir`, explicit output file names instead of timestamped ones you never reuse, and overwrite-by-default (or a documented `-force` style guard) all help. Idempotent functions let you resume after a crash without cleaning up by hand.

**Use `mktemp` and `trap` for temporary files.** Intermediate files should go somewhere private and be cleaned up even when the script dies mid-run:

```bash
make_index() {
    local genome="$1"
    local tmpdir
    tmpdir=$(mktemp -d)
    trap 'rm -rf "$tmpdir"' EXIT
    bwa index -p "$tmpdir/ref" "$genome"
    mv "$tmpdir/ref"* "$(dirname "$genome")/bwa_index/"
    trap - EXIT
}
```

The `trap ... EXIT` runs the cleanup when the shell exits for any reason, including an error triggered by `set -e`. The final `trap - EXIT` cancels the cleanup when the function succeeded and the moved files must survive.

**Know your core budget.** Before launching a function that takes a thread count, check what the machine can give you:

```bash
nproc
free -h
```

Giving `fastp` 32 threads on a 16-core machine does not speed it up and starves everyone else on a shared server. A good pattern is to derive threads from the environment: `local threads="${4:-$(( $(nproc) / 2 ))}"`, which defaults to half the cores and leaves headroom.

**Test on synthetic data first.** The ten-thousand-read synthetic file from the setup section runs in under a second. Every new function should survive a synthetic run before it touches a sequencer's output, because a sequencing lane cannot be re-run by fixing a typo.

**Record versions.** A function library that works today may break next year when a tool changes its flags. Keep a `versions.txt` in your project and refresh it after installs:

```bash
fastp --version 2>&1 | tee -a versions.txt
fastqc --version 2>&1 | tee -a versions.txt
```

## Common Mistakes and Limitations

This section catalogs the specific failures that recur in real bioinformatics work, including ones that produce no error message at all.

**Forgetting `local`.** Covered in Step 6, and worth repeating because it is the most damaging silent bug in shell scripting. A global variable clobbered inside a function can corrupt a step that runs hours later. Audit any function written without `local` declarations before trusting it.

**Unquoted variables.** `$fastq` unquoted splits on spaces. A file named `2026 11 11 run.fastq.gz` becomes three arguments and a cascade of `No such file or directory` errors. Quote every expansion: `"$fastq"`, `"$@"`, `"${fastqs[@]}"`. There is essentially no cost to quoting and a large cost to omitting it.

**Using `exit` inside a sourced function.** If a function meant for interactive use calls `exit`, it closes the terminal or, worse, ends a long driver script that sourced it. Functions use `return`. Scripts use `exit`. Keep the boundary sharp.

**Assuming the login shell matches the job shell.** On HPC systems your interactive shell, the shell that runs `sbatch` scripts, and the shell inside a container can all differ. A function tested interactively under bash 5 may fail under `sh` inside a job. Add `#!/usr/bin/env bash` shebangs, invoke scripts with `bash` explicitly, and set your strict-mode line inside every script rather than relying on the environment.

**Hardcoding absolute paths.** `/home/anna/projects/asf/data` dies the day the project moves to `/scratch` or a collaborator's machine. Take directories as arguments with sensible defaults relative to the current directory, and derive paths from them.

**Confusing `return` values with output.** A function that both prints a count and returns an error status can be captured wrongly. If the caller writes `count=$(count_reads ...)`, the exit status of the function is available in `$?` immediately after, but only if the function prints nothing except the count to stdout. This is why the stderr discipline in the tips section exists.

**Silent failure swallowing.** Appending `|| true` to a command to "keep the pipeline moving" converts a hard failure into an invisible one that surfaces days later as an empty output file. If you must tolerate a failure, log it and record it in a failures file, as the `batch_qc` example does, so the problem is discoverable.

**Ignoring pipefail.** Under default settings, a decompression failure at the head of a pipeline does not fail the pipeline. The `set -o pipefail` line and the `PIPESTATUS` array exist for this reason.

**Treating bash as the end point.** Bash functions scale beautifully from one sample to a few dozen. Beyond that, they cannot express dependencies, restart failed steps, or parallelize across samples with resource awareness, and the honest move is migration. PePApipe illustrates the transition: its bash-scripted local execution is complemented by a Slurm protocol for batch processing of multiple samples [1], and workflow systems such as Snakemake and Nextflow exist precisely to manage dependencies, retries, and resumability that plain shell cannot. Writing good functions is not wasted effort even after you migrate, because the tool invocations, validation logic, and output layout you developed in functions transfer directly into workflow tasks.

**Reproducibility depends on more than functions.** A function library documents *how* you ran things, but full reproducibility also requires recording tool versions, reference genome versions and their checksums, and the raw inputs. The teaching literature makes the same point from the other direction: skills acquired as one-shot tutorials without the underlying concepts do not transfer, which is why understanding each command inside your functions matters more than memorizing them [3].

## Frequently Asked Questions

### Do I need to be a programmer to write bash functions for bioinformatics?

No. A bash function needs roughly ten lines of learned syntax: a name, parentheses, braces, `local` variables, `$1` style arguments, and a `return`. If you can already run `fastqc` and `fastp` by hand, you are one afternoon of practice away from wrapping those commands in validated, logged functions. University teaching protocols now introduce Bash alongside R precisely because the required subset is small [3].

### Where should I save my bash functions?

Put them in one plain text file such as `~/bin/biofunctions.sh` for personal, cross-project functions, and in a `scripts/` directory inside each project repository for project-specific ones. Load personal functions with `source ~/bin/biofunctions.sh`, and add that line to `~/.bashrc` so every new terminal has them.

### What is the difference between return and exit in a bash function?

`return N` ends the function and hands status N back to the caller, where 0 means success. `exit N` terminates the entire shell process, which inside a sourced function means closing your terminal or killing the calling script. Use `return` in every function and reserve `exit` for top-level scripts.

### How do I make a bash function available in every new terminal session?

Add the line `source ~/bin/biofunctions.sh` to your `~/.bashrc` file, then either restart the terminal or run `source ~/.bashrc`. Confirm with `type function_name`, which should report that the name is a function.

### Can bash functions handle paired-end FASTQ files in batches?

Yes. Loop over every read 1 file matching a glob such as `"$indir"/*_R1.fastq.gz`, derive the sample base name with `${fname%_R1.fastq.gz}`, construct the read 2 path, and call your single-sample function inside the loop. Record failed samples in a failures file rather than aborting the batch, as shown in the `batch_qc` example.

### How many threads should I give each function?

Match the thread count to cores you actually have and leave headroom on shared machines. Check available cores with `nproc` and memory with `free -h` before launching. A practical default is half the machine's cores per job, passed as an optional argument so you can tune it per function call without editing code.

### When should I stop using bash and switch to a workflow manager like Snakemake or Nextflow?

Switch when your analysis has more than a few dozen samples, complex dependencies between steps, or a need to rerun only failed tasks. Plain bash cannot restart a single failed sample in a batch without custom bookkeeping, while workflow managers handle dependency tracking, parallelization, and resumption natively. The tool commands and validation logic you wrote in functions transfer directly into workflow tasks, so the effort is not lost.

### How do I test a bash function before running it on real sequencing data?

Create a small synthetic FASTQ file of a few thousand four-line records, run the function on it in a scratch directory, and verify three things: the exit status is 0 when inputs are valid, each guard clause fires with the right message when inputs are invalid, and the output files exist and are nonzero. Also run ShellCheck on the file and test the function from a different working directory to catch path assumptions.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Do I need to be a programmer to write bash functions for bioinformatics?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. A bash function needs roughly ten lines of learned syntax: a name, parentheses, braces, local variables, $1 style arguments, and a return. If you can already run tools like fastqc and fastp by hand, you are one afternoon of practice away from wrapping those commands in validated, logged functions."
      }
    },
    {
      "@type": "Question",
      "name": "Where should I save my bash functions?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Put personal, cross-project functions in one plain text file such as ~/bin/biofunctions.sh, and project-specific ones in a scripts directory inside each project repository. Load them with source ~/bin/biofunctions.sh."
      }
    },
    {
      "@type": "Question",
      "name": "What is the difference between return and exit in a bash function?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "return N ends the function and hands status N back to the caller, where 0 means success. exit N terminates the entire shell process. Use return in every function and reserve exit for top-level scripts."
      }
    },
    {
      "@type": "Question",
      "name": "How do I make a bash function available in every new terminal session?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Add the line source ~/bin/biofunctions.sh to your ~/.bashrc file, then restart the terminal or run source ~/.bashrc. Confirm with type function_name, which should report that the name is a function."
      }
    },
    {
      "@type": "Question",
      "name": "Can bash functions handle paired-end FASTQ files in batches?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes. Loop over every read 1 file matching a glob such as *_R1.fastq.gz, derive the sample base name by stripping the suffix, construct the read 2 path, and call your single-sample function inside the loop while recording failures in a file rather than aborting."
      }
    },
    {
      "@type": "Question",
      "name": "How many threads should I give each function?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Match the thread count to cores you actually have and leave headroom on shared machines. Check available cores with nproc and memory with free -h. A practical default is half the machine's cores per job, passed as an optional argument."
      }
    },
    {
      "@type": "Question",
      "name": "When should I stop using bash and switch to a workflow manager like Snakemake or Nextflow?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Switch when your analysis has more than a few dozen samples, complex dependencies between steps, or a need to rerun only failed tasks. Workflow managers handle dependency tracking, parallelization, and resumption natively, and your function logic transfers directly into workflow tasks."
      }
    },
    {
      "@type": "Question",
      "name": "How do I test a bash function before running it on real sequencing data?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Create a small synthetic FASTQ file of a few thousand four-line records, run the function in a scratch directory, and verify that the exit status is 0 with valid inputs, that each guard clause fires with the right message on invalid inputs, and that output files exist and are nonzero. Run ShellCheck on the file as well."
      }
    }
  ]
}
</script>

## Sources

1. ['PePApipe': A complete bioinformatics analysis pipeline for African Swine Fever Virus genome.](https://pubmed.ncbi.nlm.nih.gov/42721146/)
2. [DoriTool: A Bioinformatics Integrative Tool for Post-Association Functional Annotation.](https://pubmed.ncbi.nlm.nih.gov/28700989/)
3. [A Protocol for Teaching Basic Next Generation Sequencing (NGS) Analysis Skills to Undergraduate Students Using Bash and R.](https://pubmed.ncbi.nlm.nih.gov/33961229/)
4. [CASCADE: criticality avalanche spike cross-platform analysis detection engine, a multi-manufacturer MEA bash analysis pipeline.](https://pubmed.ncbi.nlm.nih.gov/42525390/)