# How to Pass Arguments to Your Bioinformatics Bash Script

By the end of this article you will be able to write a bash script that accepts sample names, FASTQ files, a reference genome, thread counts, and output directories as arguments, validates them, runs a small QC and alignment pipeline, and logs everything it did. You will also know how to debug a script that ignores your arguments, and how to avoid the argument handling mistakes that silently corrupt results.

To follow along you need a Linux or macOS machine (a cluster login node, an HPC head node, or WSL on Windows all work), bash version 4 or newer, a plain text editor, and, for the worked pipeline at the end, cutadapt, bwa, and samtools on your PATH. Everything else uses commands built into bash itself. This article is part of a broader series on linux for bioinformatics, but no prior scripting experience beyond running commands at the prompt is assumed.

## Why Arguments Matter in Bioinformatics Workflows

Most bioinformatics work happens at the command line, and most command line work is repetitive. You align sample after sample against the same reference, trim adapter after adapter with the same parameters, and run QC on every library that comes off the sequencer. A script that has the file names and parameters hardcoded inside it has to be edited for every run, and every edit is a chance to introduce an error.

Arguments solve this by separating the logic of the pipeline from the inputs to that pipeline. One script, many samples. The script becomes a reusable tool, and the exact command you ran is recorded in your shell history and your logs, which is the raw material of reproducibility.

The limits of ad hoc shell scripting are well documented in the literature. The authors of the admixr package describe how a traditional ADMIXTOOLS workflow required the user to generate a set of text configuration files for each analysis, often with a combination of shell scripting and manual text editing, and then parse non tabular output files by hand before further analysis. They built their package to automate all of those low level configuration and parsing steps so that analyses become a single command inside a reproducible framework [1]. Handwritten shell glue is exactly the layer their paper identifies as fragile. Learning to pass arguments cleanly is the first step toward removing that fragility from your own pipelines, whether you stay in bash or later move the same logic into a workflow manager.

## Bash Script Arguments for Bioinformatics, Step by Step

This walkthrough builds from the simplest mechanism (positional parameters) to structures you will actually use for real pipelines (flags, defaults, validation, and pass through arguments). Every step shows the command to run and the output you should expect.

### Step 1. Understand Positional Parameters

When you run a script, bash assigns each word after the script name to a numbered variable. `$1` is the first argument, `$2` the second, and so on through `$9`. The tenth argument is written `${10}`, with braces, because `$10` would be read as `$1` followed by the character 0.

Create a file called `greet_sample.sh`:

```bash
#!/usr/bin/env bash
echo "Processing sample: $1"
echo "Script name: $0"
echo "Number of arguments: $#"
```

Run it:

```bash
bash greet_sample.sh SRR1234567
```

Expected output:

```
Processing sample: SRR1234567
Script name: greet_sample.sh
Number of arguments: 1
```

Three variables appear here that you will use constantly:

- `$1`, `$2`, and up to `$9` hold the individual arguments.
- `$0` holds the name the script was invoked with, useful in usage messages.
- `$#` holds the number of arguments, which you use to check that the caller supplied enough of them.

For a quick one off, positional parameters are enough. A script that trims one pair of FASTQ files can simply treat `$1` and `$2` as the forward and reverse reads. The trouble starts when someone runs the script with the arguments in the wrong order, which brings us to validation.

### Step 2. Check the Argument Count Before Doing Anything

A script that runs halfway and then fails on a missing argument wastes compute. Check `$#` first, print a usage message, and exit with a nonzero status if the count is wrong. By convention, an exit status of 0 means success and any value from 1 to 255 means failure, so a validation failure should use `exit 1`.

```bash
#!/usr/bin/env bash
usage() {
    echo "Usage: $0 <reads_R1.fastq.gz> <reads_R2.fastq.gz>"
    echo "  reads_R1.fastq.gz  Forward reads, [FASTQ format](/knowledge/bioinformatics/fastq-file-format-phred-scores-qc), gzip allowed"
    echo "  reads_R2.fastq.gz  Reverse reads, FASTQ format, gzip allowed"
}

if [ "$#" -ne 2 ]
then
    echo "ERROR: expected exactly 2 arguments, got $#" >&2
    usage
    exit 1
fi

echo "R1: $1"
echo "R2: $2"
```

The `>&2` redirect sends the error message to standard error rather than standard output. This matters on clusters and in pipelines, because standard error is what gets captured in job logs and what monitoring tools watch for. Run the script with the wrong number of arguments and you get a clear, actionable message:

```bash
bash check_args.sh only_one_file.fastq.gz
```

```
ERROR: expected exactly 2 arguments, got 1
Usage: check_args.sh <reads_R1.fastq.gz> <reads_R2.fastq.gz>
  reads_R1.fastq.gz  Forward reads, FASTQ format, gzip allowed
  reads_R2.fastq.gz  Reverse reads, FASTQ format, gzip allowed
```

Confirm the exit status with `echo $?`, which should print `1`.

### Step 3. Set Defaults and Mark Required Arguments

![Worked example showing threads set to 4 when no argument is given and to 16 when the caller passes 16.](/article-images/illustrations/bash-script-arguments-bioinformatics-default-threads.webp)

Thread counts, output directories, and adapter sequences usually have sensible defaults, so making the user type them every run is unnecessary. Bash parameter expansion handles this in one line. The syntax `${VAR:-default}` expands to the value of `VAR` if it is set and non empty, and to the default otherwise.

```bash
#!/usr/bin/env bash
threads=${1:-4}
outdir=${2:-results}
echo "Using $threads threads"
echo "Writing output to $outdir"
```

```bash
bash defaults.sh
```

```
Using 4 threads
Writing output to results
```

```bash
bash defaults.sh 16 analysis_out
```

```
Using 16 threads
Writing output to analysis_out
```

For arguments that must be present, use `${VAR:?message}`. This expansion prints the message and exits the script if the variable is unset or empty, which is a compact way to enforce required inputs:

```bash
reads=${1:?"You must supply a FASTQ file as the first argument"}
```

Running the script with no arguments now produces:

```
defaults_required.sh: line 3: reads: You must supply a FASTQ file as the first argument
```

### Step 4. Loop Over Multiple Files With "$@"

![Table of quoted at, quoted star, and unquoted at showing word boundaries and what happens to a file name with spaces.](/article-images/illustrations/bash-script-arguments-bioinformatics-at-vs-star.webp)

Bioinformatics rarely involves one file at a time. You might pass ten FASTQ files to a QC script, or a whole directory of genomes to a masking step. The variable `$@` expands to every argument, and when quoted as `"$@"` each argument stays a single word even if it contains spaces. The quoted form is the one you should use essentially every time.

```bash
#!/usr/bin/env bash
outdir=qc_reports
mkdir -p "$outdir"
for fastq in "$@"
do
    echo "Running fastqc on $fastq"
    fastqc "$fastq" -o "$outdir"
done
echo "Processed $# files"
```

```bash
bash run_qc.sh sampleA_R1.fastq.gz sampleB_R1.fastq.gz sampleC_R1.fastq.gz
```

```
Running fastqc on sampleA_R1.fastq.gz
Running fastqc on sampleB_R1.fastq.gz
Running fastqc on sampleC_R1.fastq.gz
Processed 3 files
```

Because the loop uses `"$@"`, a file named `patient 04 R1.fastq.gz`, spaces and all, is passed to fastqc as one file name rather than four broken fragments.

### Step 5. Accept Short Flags With getopts

Positional arguments force the caller to remember the order. Flags such as `-i input.fastq.gz -t 8` are self documenting and can appear in any order. The builtin `getopts` parses short options for you. The option string lists each flag, and a colon after a flag means it takes a value.

```bash
#!/usr/bin/env bash
input=""
outdir="results"
threads=4

while getopts "i:o:t:h" option
do
    if [ "$option" = "i" ]
    then
        input="$OPTARG"
    elif [ "$option" = "o" ]
    then
        outdir="$OPTARG"
    elif [ "$option" = "t" ]
    then
        threads="$OPTARG"
    elif [ "$option" = "h" ]
    then
        echo "Usage: $0 -i input.fastq.gz -o outdir -t threads"
        exit 0
    fi
done

shift $((OPTIND - 1))

echo "input   = $input"
echo "outdir  = $outdir"
echo "threads = $threads"
echo "leftover positional args = $@"
```

Two variables come with getopts:

- `$OPTARG` holds the value attached to the current option.
- `$OPTIND` holds the index of the next argument to process. `shift $((OPTIND - 1))` removes everything getopts already consumed, so any remaining arguments (for example, a second FASTQ file) land in `"$@"`.

Run it:

```bash
bash flags.sh -t 16 -i sampleA_R1.fastq.gz extra1.fastq.gz
```

```
input   = sampleA_R1.fastq.gz
outdir  = results
threads = 16
leftover positional args = extra1.fastq.gz
```

Note that the flags work in any order and that the leftover positional argument is preserved. This combination of flags plus trailing positionals is a common pattern in linux commands for bioinformatics, and now your scripts speak it too.

### Step 6. Accept Long Options Like, input

Long options such as `-threads 8` are the modern convention and are what users expect from tools they already run. getopts cannot handle them, so you parse them yourself with a loop, a test, and `shift`. Each option that takes a value consumes two arguments, so you shift by 2. A flag with no value consumes one, so you shift by 1.

```bash
#!/usr/bin/env bash
input=""
threads=8
dry_run=false

while [ "$#" -gt 0 ]
do
    if [ "$1" = "--input" ]
    then
        input="$2"
        shift 2
    elif [ "$1" = "--threads" ]
    then
        threads="$2"
        shift 2
    elif [ "$1" = "--dry-run" ]
    then
        dry_run=true
        shift
    elif [ "$1" = "-h" ] || [ "$1" = "--help" ]
    then
        echo "Usage: $0 --input FILE --threads N [--dry-run]"
        exit 0
    else
        echo "ERROR: unknown option: $1" >&2
        echo "Run $0 --help for usage" >&2
        exit 1
    fi
done

echo "input   = $input"
echo "threads = $threads"
echo "dry_run = $dry_run"
```

```bash
bash longopts.sh --threads 4 --input test.fastq.gz
```

```
input   = test.fastq.gz
threads = 4
dry_run = false
```

The unknown option branch is doing real work here. Without it, a typo such as `-thread 8` would be silently ignored and the script would run with the default 8 threads, which is a much worse failure mode than a clear error.

### Step 7. Validate the Arguments, Not Just the Count

Counting arguments is necessary but not sufficient. Three checks catch most real world failures before they waste an hour of cluster time:

1. Does each input file actually exist?
2. Is every external tool the script needs installed and on the PATH?
3. Can the script write to the output location?

```bash
#!/usr/bin/env bash
input=$1
outdir=$2

if [ ! -f "$input" ]
then
    echo "ERROR: input file not found: $input" >&2
    exit 1
fi

if [ ! -r "$input" ]
then
    echo "ERROR: input file exists but is not readable: $input" >&2
    exit 1
fi

if ! command -v cutadapt > /dev/null 2>&1
then
    echo "ERROR: cutadapt not found on PATH" >&2
    echo "Activate the environment that has it, for example:" >&2
    echo "  conda activate cutadapt-env" >&2
    exit 1
fi

mkdir -p "$outdir" || exit 1
if [ ! -w "$outdir" ]
then
    echo "ERROR: cannot write to directory: $outdir" >&2
    exit 1
fi

echo "All checks passed"
```

The `command -v` check deserves emphasis. On a shared server, the single most common reason a script fails at minute 40 of a run is that the tool was available in one shell and not in the environment the script actually ran in. Checking up front turns that into a five second failure with a clear message. For output directories, note that `mkdir -p` succeeds silently if the directory already exists and fails if it cannot be created, for example because a parent path is wrong or a quota is full.

### Step 8. Pass Extra Arguments Through to a Tool

Often your script wraps a tool and should accept whatever arguments the caller wants to forward. After your own flags are parsed and shifted away, `"$@"` contains exactly the leftover arguments, so forwarding is a one liner:

```bash
#!/usr/bin/env bash
# wrapper.sh runs seqkit stats and forwards any extra seqkit flags
input=$1
shift
seqkit stats "$@" "$input"
```

```bash
bash wrapper.sh sampleA.fastq.gz -a -T
```

```
file              format  type  num_seqs  sum_len  ...
sampleA.fastq.gz  FASTQ   DNA   250,000   37,750,000  ...
```

The forwarded arguments land in `"$@"` after the shift, and because they are quoted they survive intact even when they contain spaces.

### Step 9. Use set -euo pipefail and Configuration Files

Three bash settings make argument handling safer in any script longer than a few lines:

- `set -e` stops the script immediately if any command fails, instead of continuing with missing intermediate files.
- `set -u` treats an unset variable as an error, which catches typos in variable names and unassigned arguments instead of silently expanding them to empty strings.
- `set -o pipefail` makes a pipeline fail if any command in it fails, not just the last one. Without it, a failing aligner piped into a succeeding sort command would look like success.

A robust preamble for a bioinformatics script looks like this:

```bash
#!/usr/bin/env bash
set -euo pipefail
```

For pipelines with many parameters, an alternative to long option lists is a configuration file that the script sources. The caller passes one argument, the path to the config, and the config sets variables:

```bash
# sampleA.conf
sample=sampleA
r1=data/sampleA_R1.fastq.gz
r2=data/sampleA_R2.fastq.gz
threads=16
```

```bash
#!/usr/bin/env bash
set -euo pipefail
config=$1
source "$config"
echo "Will process $sample from $r1 and $r2 with $threads threads"
```

```bash
bash run_from_conf.sh sampleA.conf
```

```
Will process sampleA from data/sampleA_R1.fastq.gz and data/sampleA_R2.fastq.gz with 16 threads
```

This pattern scales better than a long flag list when a pipeline has ten or more parameters, and the config files themselves become a record of exactly what each run used.

### Step 10. Put It Together in a Worked Pipeline

Here is a complete script for trimming and aligning one paired end sample. It uses short flags, validates its inputs, prints its own settings into the log, and produces sorted, indexed BAM files.

```bash
#!/usr/bin/env bash
# qc_align.sh, trim and align one paired end sample
set -euo pipefail

usage() {
    echo "Usage: $0 -r ref.fa -s SAMPLE -1 reads_R1.fastq.gz -2 reads_R2.fastq.gz -o OUTDIR -t THREADS"
}

threads=8
outdir=results
ref=""
sample=""
r1=""
r2=""

while getopts "r:s:1:2:o:t:h" option
do
    if [ "$option" = "r" ]
    then
        ref="$OPTARG"
    elif [ "$option" = "s" ]
    then
        sample="$OPTARG"
    elif [ "$option" = "1" ]
    then
        r1="$OPTARG"
    elif [ "$option" = "2" ]
    then
        r2="$OPTARG"
    elif [ "$option" = "o" ]
    then
        outdir="$OPTARG"
    elif [ "$option" = "t" ]
    then
        threads="$OPTARG"
    elif [ "$option" = "h" ]
    then
        usage
        exit 0
    fi
done

if [ -z "$ref" ] || [ -z "$sample" ] || [ -z "$r1" ] || [ -z "$r2" ]
then
    echo "ERROR: -r, -s, -1, and -2 are all required" >&2
    usage
    exit 1
fi

for f in "$ref" "$r1" "$r2"
do
    if [ ! -f "$f" ]
    then
        echo "ERROR: file not found: $f" >&2
        exit 1
    fi
done

for tool in cutadapt bwa samtools
do
    if ! command -v "$tool" > /dev/null 2>&1
    then
        echo "ERROR: $tool not found on PATH" >&2
        exit 1
    fi
done

mkdir -p "$outdir"

echo "=== Run settings ==="
echo "sample   : $sample"
echo "reference: $ref"
echo "R1       : $r1"
echo "R2       : $r2"
echo "outdir   : $outdir"
echo "threads  : $threads"
echo "===================="

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Trimming adapters"
cutadapt -a AGATCGGAAGAGC -A AGATCGGAAGAGC -m 25 -j "$threads" \
    -o "$outdir/${sample}_trim_R1.fastq.gz" \
    -p "$outdir/${sample}_trim_R2.fastq.gz" \
    "$r1" "$r2" > "$outdir/${sample}_cutadapt.log"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Aligning with bwa mem"
bwa mem -t "$threads" "$ref" \
    "$outdir/${sample}_trim_R1.fastq.gz" \
    "$outdir/${sample}_trim_R2.fastq.gz" \
    | samtools sort -@ "$threads" -o "$outdir/${sample}.bam"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Indexing and collecting stats"
samtools index "$outdir/${sample}.bam"
samtools flagstat "$outdir/${sample}.bam" > "$outdir/${sample}.flagstat.txt"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done, results in $outdir"
```

Run it:

```bash
bash qc_align.sh -r ref/GRCg6a.fa -s batch1_sampleA \
    -1 data/sampleA_R1.fastq.gz -2 data/sampleA_R2.fastq.gz \
    -o results/batch1 -t 16
```

Expected log output:

```
=== Run settings ===
sample   : batch1_sampleA
reference: ref/GRCg6a.fa
R1       : data/sampleA_R1.fastq.gz
R2       : data/sampleA_R2.fastq.gz
outdir   : results/batch1
threads  : 16
====================
[2026-11-11 09:14:02] Trimming adapters
[2026-11-11 09:21:37] Aligning with bwa mem
[2026-11-11 09:38:55] Indexing and collecting stats
[2026-11-11 09:39:01] Done, results in results/batch1
```

The settings block at the top of the log is deliberate. When you return to these results months later, or when a reviewer asks exactly which reference and read files produced a BAM file, that block is your answer. Printing the full configuration at the start of every run is one of the cheapest reproducibility investments available, and it addresses the same problem that motivated the admixr authors to automate configuration file generation inside a single framework rather than leave it to scattered shell commands and manual edits [1].

To process a whole batch, wrap the same script in a short loop rather than copying it:

```bash
for sample in $(cat samples.txt)
do
    bash qc_align.sh -r ref/GRCg6a.fa -s "$sample" \
        -1 "data/${sample}_R1.fastq.gz" \
        -2 "data/${sample}_R2.fastq.gz" \
        -o "results/${sample}" -t 16
done
```

## Key Parameters Reference

The table below collects the variables and expansions used in this article so you can keep it open while writing your own scripts.

| Syntax | What it holds or does | Example use |
|, - |, - |, - |
| `$1` to `$9` | First through ninth positional argument | `input=$1` |
| `${10}` | Tenth and later arguments (braces required) | `sample_id=${10}` |
| `$#` | Number of arguments passed | `if [ "$#" -ne 2 ]` |
| `"$@"` | All arguments, each kept as a separate word | `for f in "$@"` |
| `"$*"` | All arguments joined into one string | `echo "Called with: $*"` |
| `$0` | Name the script was invoked with | `echo "Usage: $0 ..."` |
| `$?` | Exit status of the previous command | `cutadapt ... ; check=$?` style logic is better done with `if` |
| `${VAR:-default}` | Value of VAR, or a default if unset | `threads=${1:-4}` |
| `${VAR:?message}` | Value of VAR, or exit with message if unset | `reads=${1:?"missing input"}` |
| `$OPTARG` | Value of the current option inside getopts | `input="$OPTARG"` |
| `$OPTIND` | Index of the next unparsed argument | `shift $((OPTIND - 1))` |
| `shift N` | Discards the first N positional arguments | `shift 2` after consuming an option and its value |

## Bash Script Arguments for Bioinformatics: Tips That Pay Off

These tips come from the difference between a script that works once on your data and a script a colleague can run on theirs.

**Quote every variable expansion.** Write `"$input"`, not `$input`. Unquoted expansions undergo word splitting, so a sample named `patient 04` becomes `patient`, `04`, and a broken command. This single habit prevents more script failures than any other.

**Prefer "$@" over "$*" in loops and command calls.** Both expand to all arguments, but `"$@"` preserves the boundaries between them while `"$*"` merges them into one string. In a loop over FASTQ files, `"$@"` gives you each file separately and `"$*"` gives you one mangled filename.

**Derive sample names from file names with parameter expansion.** If your files are named `sampleA_R1.fastq.gz`, you can extract the sample name without any external tool:

```bash
r1=data/sampleA_R1.fastq.gz
filename=${r1##*/}            # sampleA_R1.fastq.gz
sample=${filename%_R1.fastq.gz}  # sampleA
echo "Sample: $sample"
```

```
Sample: sampleA
```

`##*/` strips the longest match of anything followed by a slash, leaving the basename. `%_R1.fastq.gz` strips the shortest match of that suffix from the end. Learning these two patterns removes most of your `sed` and `basename` calls.

**Check that a reference index exists before aligning.** An aligner run against a missing or stale index fails late and cryptically. A three line check fails early and clearly:

```bash
if [ ! -f "${ref}.bwt.2bit.64" ] && [ ! -f "${ref%.fa}.amb" ]
then
    echo "WARNING: no prebuilt index found for $ref, the aligner may build one" >&2
fi
```

**Log stdout and stderr together.** When you run a script noninteractively, capture everything:

```bash
bash qc_align.sh -r ref.fa -s sA -1 a_R1.fq.gz -2 a_R2.fq.gz -o out 2>&1 | tee logs/sA.log
```

With `set -o pipefail` in the script, a failure inside the pipeline still produces a nonzero exit status even though the pipe to `tee` succeeds, so downstream job schedulers see the failure too.

**Validate numeric arguments.** A thread count of `8t` or a negative value will be passed straight to your tools and fail unpredictably. A simple pattern check catches this:

```bash
if ! [ "$threads" -gt 0 ] 2> /dev/null
then
    echo "ERROR: threads must be a positive integer, got: $threads" >&2
    exit 1
fi
```

**Make scripts executable and give them a shebang.** The line `#!/usr/bin/env bash` at the top plus `chmod +x qc_align.sh` lets you run `./qc_align.sh` directly. Without the shebang, the script may be executed by a different shell with different behavior, which produces bugs that look impossible.

**Echo what you are about to run.** For long or expensive steps, printing the command before running it makes logs self documenting and makes dry run debugging trivial. Combine this with a `-dry-run` flag that prints commands instead of executing them, and you can review a whole batch before committing an hour of compute.

**Keep each script doing one thing.** A script that trims, aligns, calls variants, and emails you takes ten arguments and is impossible to test. A script that trims one sample takes four and is easy to verify. Chain the small scripts with a driver loop or, eventually, a workflow manager.

## Common Mistakes and Limitations

These are the mistakes that show up repeatedly in real bioinformatics shell scripts, along with the exact symptoms and fixes.

**Unquoted variables in loops.** Writing `for f in $@` instead of `for f in "$@"` splits arguments on whitespace. The symptom is a tool complaining that a file called `patient` does not exist when the file is called `patient 04_R1.fastq.gz`. The fix is always quoting `"$@"` and every variable expansion that holds a path.

**Assuming the argument order instead of checking it.** A script that treats `$1` as R1 and `$2` as R2 will happily align the reverse file as forward reads if the caller swaps them, and the run may complete with plausible looking but wrong results. Guard against it by checking that both files exist and, where it matters, that the R1 name contains the expected suffix, and fail loudly otherwise.

**Relying on the current working directory.** A script that opens `data/reads.fastq.gz` works on your laptop and fails on the cluster, or works today and fails tomorrow because it was launched by a scheduler from a different directory. Convert paths to absolute form early with `realpath`, or document loudly that the script must be run from the project root.

**Missing `set -u`, so typos fail silently.** Without `set -u`, a typo like `$OUTDIR` where you meant `$outdir` expands to an empty string, and files land in the project root or the command fails with a confusing error far from the real cause. `set -euo pipefail` at the top of every script catches typos the moment they execute.

**Silent overwriting of outputs.** A script that always writes `results/sampleA.bam` will overwrite previous results if you rerun it with corrected inputs and forget the old files exist. Either write into a fresh, timestamped output directory, or check for an existing output and refuse to overwrite unless a force flag is passed.

**Hardcoded parameters hidden inside the script.** Adapter sequences, minimum lengths, and reference paths buried in the script body are invisible to whoever reads your log files later. Move every parameter that could vary into an argument or a sourced config file, and print the final values at the start of the run. The ADMIXTOOLS example is instructive here: the workflow relied on text configuration files generated by shell scripting and manual editing, and the reproducibility gains came precisely from automating and unifying that configuration step [1]. Your bash arguments are your own version of that configuration layer.

**Parsing file formats with grep and awk when a real parser exists.** Bash arguments are the right tool for inputs and parameters, but not for extracting values from SAM, VCF, or tabular output. Tools built for those formats handle edge cases such as header lines and ragged fields that ad hoc parsing misses. Use bash to orchestrate, and dedicated tools to parse.

**Testing only with clean filenames.** If you only ever test with `sampleA_R1.fastq.gz`, you will not discover that your unquoted loop breaks on `sample A (replicate 2)_R1.fastq.gz` until a collaborator sends files with spaces and parentheses in the names. Test once with a hostile filename: create `touch "weird name (v2)_R1.fastq.gz"` and run your script on it.

One limitation to keep in view: argument handling makes a script reusable, but it does not make a pipeline reproducible on its own. Versions of every tool, the reference genome build, and the computing environment all need to be recorded too, for example with a conda environment export or a container image. And, as with any analytical result, individual datasets can behave in ways no script anticipates, so results from an automated pipeline should be reviewed by someone who understands the biology before they drive decisions.

## How to Check That Your Script Actually Works

Before pointing a new script at a 40 hour sequencing run, verify it in layers:

1. **Syntax check without running.** `bash -n qc_align.sh` parses the script and reports syntax errors without executing a single command. Run this after every edit.
2. **Static analysis.** `shellcheck qc_align.sh` flags unquoted variables, useless use of cat, and dozens of other traps, with explanations for each finding. Install it once and run it on every script you write. Most shellcheck warnings on bioinformatics scripts are real bugs waiting to happen.
3. **Dry run with fake data.** Combine a `-dry-run` flag with two tiny FASTQ files of a few thousand reads each so the whole pipeline completes in seconds. Confirm the output files appear in the right places with the right names.
4. **Check the exit status.** After a test run, `echo $?` should print `0`. Deliberately break something, for example pass a nonexistent reference, and confirm the script exits nonzero with the intended error message rather than limping forward.
5. **Inspect the outputs.** `head results/sampleA.flagstat.txt` should show a read count consistent with your input file, and `samtools quickcheck results/sampleA.bam` should pass without complaint, confirming the BAM is not truncated.
6. **Verify the log records the inputs.** Open the log and confirm the settings block lists the exact files, reference, and thread count you intended. If you cannot reconstruct the run from the log alone, add more echoes.

## Next Steps Once Arguments Feel Comfortable

Once a script takes clean, validated arguments, three natural upgrades await. First, batch processing: combine your script with a samples file and a loop, or with GNU parallel, to process an entire sequencing run in one command. Second, environment pinning: export the conda environment or container your script depends on so the tool versions are recorded alongside the arguments. Third, a workflow manager. When you find yourself writing bash scripts that track which samples completed, retry failures, and decide what to rerun, you have outgrown bash orchestration, and tools such as Snakemake or Nextflow express the same logic with dependency tracking and automatic reruns built in. The argument handling skills in this article transfer directly, because those tools still shell out to the same commands with the same parameters, just managed for you.

The broader lesson from the admixr work applies at every scale: the reproducibility problem in scripted analyses is usually the manual glue between steps, and every piece of that glue you make explicit, validated, and logged is a piece that no longer depends on memory [1]. Argument handling is where that work starts.

## Frequently Asked Questions

### How do I pass arguments to a bash script in bioinformatics?

Separate the arguments with spaces after the script name, then read them inside the script as `$1`, `$2`, and so on. For example, `bash trim.sh sampleA_R1.fastq.gz sampleA_R2.fastq.gz` makes the two file names available as `$1` and `$2`. Always check `$#` first to confirm the expected number of arguments was supplied.

### What is the difference between "$@" and "$*"?

Both expand to all script arguments, but `"$@"` keeps each argument as a separate word while `"$*"` joins them into one string. Use `"$@"` in loops and when forwarding arguments to tools, because it preserves file names that contain spaces.

### How many arguments can a bash script accept?

Bash imposes no practical limit on the total number of arguments, but the direct variables `$1` through `$9` require braces beyond nine, so `${10}` is the tenth argument. In practice, more than four or five positional arguments is a signal to switch to flags or a config file instead.

### How do I give an argument a default value?

Use the expansion `${VAR:-default}`, which returns the value of VAR if it is set and the default otherwise. For example, `threads=${1:-4}` uses 4 threads when the caller supplies no first argument, and the caller supplied value otherwise.

### How do I pass flags like -t 8 to my script?

Use the builtin getopts with an option string such as `i:o:t:h`, where a colon after a letter means that option takes a value. Read the value from `$OPTARG` inside the parsing loop, then run `shift $((OPTIND - 1))` so any remaining positional arguments are available in `"$@"`.

### How do I accept long options like, input?

getopts only handles single letter flags, so parse long options with a while loop that tests `$1`, assigns `$2` when the option takes a value, and calls `shift 2` (or `shift 1` for a valueless flag). Include an else branch that rejects unknown options with a clear error rather than ignoring them.

### Why does my script break when a file name contains spaces?

Because an unquoted variable or `$@` is being word split on the spaces in the name. Quote every expansion, including `"$f"` in loops and `"$1"` in commands, and the file name travels as a single unit. Test your scripts once with a file name containing spaces and parentheses to confirm.

### When should I stop using bash and move to a workflow manager?

Move to a workflow manager such as Snakemake or Nextflow when you need automatic dependency tracking, resuming after failures, or per sample parallelization across many steps. Scripts that hand roll those features with status files and loops are harder to maintain than the same logic expressed in a workflow language.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "How do I pass arguments to a bash script in bioinformatics?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Separate the arguments with spaces after the script name, then read them inside the script as $1, $2, and so on. For example, bash trim.sh sampleA_R1.fastq.gz sampleA_R2.fastq.gz makes the two file names available as $1 and $2. Always check $# first to confirm the expected number of arguments was supplied."
      }
    },
    {
      "@type": "Question",
      "name": "What is the difference between \"$@\" and \"$*\"?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Both expand to all script arguments, but \"$@\" keeps each argument as a separate word while \"$*\" joins them into one string. Use \"$@\" in loops and when forwarding arguments to tools, because it preserves file names that contain spaces."
      }
    },
    {
      "@type": "Question",
      "name": "How many arguments can a bash script accept?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Bash imposes no practical limit on the total number of arguments, but the direct variables $1 through $9 require braces beyond nine, so ${10} is the tenth argument. In practice, more than four or five positional arguments is a signal to switch to flags or a config file instead."
      }
    },
    {
      "@type": "Question",
      "name": "How do I give an argument a default value?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Use the expansion ${VAR:-default}, which returns the value of VAR if it is set and the default otherwise. For example, threads=${1:-4} uses 4 threads when the caller supplies no first argument, and the caller supplied value otherwise."
      }
    },
    {
      "@type": "Question",
      "name": "How do I pass flags like -t 8 to my script?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Use the builtin getopts with an option string such as i:o:t:h, where a colon after a letter means that option takes a value. Read the value from $OPTARG inside the parsing loop, then run shift $((OPTIND - 1)) so any remaining positional arguments are available in \"$@\"."
      }
    },
    {
      "@type": "Question",
      "name": "How do I accept long options like, input?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "getopts only handles single letter flags, so parse long options with a while loop that tests $1, assigns $2 when the option takes a value, and calls shift 2 (or shift 1 for a valueless flag). Include an else branch that rejects unknown options with a clear error rather than ignoring them."
      }
    },
    {
      "@type": "Question",
      "name": "Why does my script break when a file name contains spaces?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Because an unquoted variable or unquoted $@ is being word split on the spaces in the name. Quote every expansion, including \"$f\" in loops and \"$1\" in commands, and the file name travels as a single unit. Test your scripts once with a file name containing spaces and parentheses to confirm."
      }
    },
    {
      "@type": "Question",
      "name": "When should I stop using bash and move to a workflow manager?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Move to a workflow manager such as Snakemake or Nextflow when you need automatic dependency tracking, resuming after failures, or per sample parallelization across many steps. Scripts that hand roll those features with status files and loops are harder to maintain than the same logic expressed in a workflow language."
      }
    }
  ]
}
</script>

## Sources

1. [admixr-R package for reproducible analyses using ADMIXTOOLS.](https://pubmed.ncbi.nlm.nih.gov/30668635/)