# How to Handle Errors in Bash Scripts for Bioinformatics

The most common failure in a bioinformatics bash script is the silent one. A command deep inside the script fails, bash prints a short error to the screen, and then the script keeps running as if nothing happened. Twenty minutes later your pipeline finishes with exit code 0, and you are left counting reads in a half empty BAM file or staring at an empty count matrix. Bash does this by default. It runs every line independently and reports success as long as the last line worked.

The first thing to check, before anything else, is whether your script starts with strict error handling. Open the file and look at the lines just below the shebang. If you do not see something close to `set -Eeuo pipefail`, your script has no protection at all, and every downstream problem you are chasing is probably a symptom of that one missing line. Add it, rerun, and in most cases the real failure point surfaces immediately with a line number.

This guide covers how to handle errors in bash scripts for bioinformatics from first principles. It explains why bash behaves the way it does, gives you a short diagnostic sequence, works through the causes from most common to rarest, and ends with a reusable template and prevention habits for day to day linux for bioinformatics work.

## Why Bash Fails Quietly in Bioinformatics Work

Bash is not a workflow engine. It is a command interpreter from an era when a script was a short list of steps a person typed by hand. Its default contract is simple: run each line, print any error message, continue with the next line, and exit with the status of the last command. That contract made sense for interactive use. It is dangerous for pipelines.

Three properties of bash combine to create the classic bioinformatics failure mode.

**Independent commands.** Each command runs on its own. If `fastqc` fails on sample 3, bash prints the error and moves on to sample 4. Your loop finishes, your summary message prints, and the script exits 0.

**Pipelines report the last command only.** In `bwa mem reference reads.fq | samtools sort -o out.bam`, bash only reports the exit status of `samtools sort`. If the aligner died halfway through, `samtools sort` sees a closed input, writes whatever it buffered, and exits 0. You get a truncated BAM with a clean exit code.

**Exit codes are easy to ignore.** Every command returns a number. Zero conventionally means success, nonzero means failure. But nothing forces you to look at that number. A script full of unchecked commands is a script full of blind spots.

The stakes are higher here than in most scripting contexts because the data is large and the steps are expensive. A failed alignment that produces a plausible looking BAM costs hours to detect and days to trace back. A population genetics workflow that depends on shell scripts to generate configuration files and parse non tabular output by hand is especially exposed, which is one reason the admixr authors moved the entire ADMIXTOOLS pipeline into a single reproducible framework rather than leaving users to juggle shell scripts and manual text editing [1].

### Exit codes, the 30 second version

![Decision path from the exit code number to the likely cause: 127 means PATH or environment, 137 means memory, 143 means scheduler cancel.](/article-images/illustrations/bash-error-handling-bioinformatics-script-exit-code-triage.webp)

Run any command, then run `echo $?`. The number you see is that command's exit status. Zero means the command believes it succeeded. One through 255 mean something went wrong, with the meaning depending on the tool. A few you will see constantly:

- `0` success
- `1` general failure, the catch all
- `2` misuse of the command, often a bad flag
- `126` found the file but could not execute it
- `127` command not found, usually a typo or a missing PATH entry
- `130` the process was killed by Ctrl C
- `137` the process was killed, most often by the out of memory killer
- `143` the process was killed by SIGTERM, common when a scheduler cancels a job

Memorize 127, 137, and 143. They account for a large share of "my script just stopped" questions in bioinformatics support channels.

## The 60 Second Diagnostic Sequence

![Five step flow: reproduce with tracing, check the exit code, confirm the environment, check disk and memory, read the first error.](/article-images/illustrations/bash-error-handling-bioinformatics-script-diagnostic-sequence.webp)

When a script misbehaves, work through this sequence in order. It resolves the majority of cases without further research.

1. **Reproduce with tracing.** Run `bash -x yourscript.sh 2>&1 | tee debug.log`. The `-x` flag makes bash print every command after expansion, so you see the exact values of your variables at the moment of failure. Search the log upward from the first error message.
2. **Check the exit code.** Immediately after the failure, run `echo $?`. A 127 points to PATH problems. A 137 points to memory. A 2 points to a malformed command line.
3. **Confirm the environment.** Run `which bwa`, `which samtools`, or whichever tool failed, plus `type toolname` to catch shell aliases. Then run `conda env list` or `module list` to see what is actually loaded. A tool that "disappeared" is almost always an environment problem.
4. **Check disk and memory.** Run `df -h` on every relevant mount point, `du -sh *` in your working directory, and `free -h`. Full disks and exhausted memory cause failures that look like tool bugs.
5. **Look at the first error, not the last.** In long logs, the last error is usually a cascade. The first command that failed is the cause. Everything after it is noise.

If the failure survives all five steps, move to the table below and match your symptom.

## Troubleshooting Table

| Symptom | Likely cause | How to confirm | Fix |
|--|--|--|--|
| Script keeps running after a command fails | No `set -e`, errors ignored by default | Run with `bash -x`, watch execution continue past a visible error | Add `set -Eeuo pipefail` at the top of the script |
| Pipeline exits 0 but output is truncated or empty | A command inside a pipe failed, `pipefail` not set | Count records with `samtools view -c` or `wc -l`, compare against expectations, rerun with `set -o pipefail` | Add `pipefail`, add output size checks after each write |
| `command not found` for a tool that worked yesterday | Wrong environment, conda env not active, module not loaded, PATH overwritten | `which toolname`, `conda env list`, `module list` | Activate the environment or load the module inside the script, not just in your interactive shell |
| `No such file or directory` for a file you can list | Unquoted variable with spaces, hidden characters in the filename, wrong working directory | `ls -la`, `pwd`, `cat -A filename` to reveal hidden characters | Quote every variable, `cd` explicitly or use absolute paths |
| Loop processes only one sample, or the wrong samples | Word splitting on spaces, glob matches nothing, `IFS` problems | `echo` each filename inside the loop and inspect output | Quote `"$file"`, use `shopt -s nullglob`, prefer arrays |
| `Permission denied` | Missing execute bit, read only filesystem, wrong ownership | `ls -l script.sh`, `mount`, `id` | `chmod +x script.sh`, verify mount options, never reach for `sudo` as a first response |
| Script killed with exit 137, no error message | Out of memory killer terminated a process | `dmesg -T \| grep -i "killed process"`, `free -h` | Reduce threads, cap sorter memory with explicit flags, request more memory from the scheduler |
| `No space left on device` mid run | Disk or quota exhausted, usually temp files | `df -h` on all mounts, `du -sh *` in the working directory | Point `TMPDIR` at a large volume, clean intermediates, monitor space in the script |
| Tool produced output but the log is empty | `2>/dev/null` swallowed real error messages | Rerun with stderr visible, search the log for `error` and `warning` | Redirect stderr to a timestamped log file, never to `/dev/null` |
| Same script gives different results on reruns | Unpinned tool versions, variable thread counts, nondeterministic ordering | Compare `md5sum` of outputs across runs, record tool versions | Pin versions, fix thread counts, checksum inputs and outputs |
| Job array fails on the cluster but works on the login node | Environment differs on compute nodes, modules not loaded in the job script | `sacct -j jobid`, read the job's stderr file | Activate the environment inside the job script itself, never rely on the login shell |

## The Three Lines That Fix Most Scripts

If you take one thing from this article, take this block. Place it directly under the shebang in every bioinformatics script.

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

Each flag does a specific job.

**`-e` (errexit).** Exit immediately if any command returns a nonzero status. This converts bash from "report and continue" to "stop at the first failure", which is the behavior a pipeline needs. Without it, every other safeguard in this article is decoration.

**`-u` (nounset).** Treat an unset variable as an error instead of silently substituting an empty string. This is the single best defense against the `$OUTDIR` typo that turns `rm -rf "${OUTDIR}/scratch"` into `rm -rf "/scratch"`. With `-u`, the script dies at the typo instead of deleting something you needed.

**`-o pipefail`.** Make a pipeline report the exit status of the first failing command, not the last command. This is the flag that catches the dead aligner upstream of a happy looking `samtools sort`.

**`-E` (errtrace).** Makes `ERR` traps fire inside functions, subshells, and command substitutions, so your error reporting works everywhere rather than only at the top level.

Together these are the standard answer to how to fix bash error handling in bioinformatics scripts that fail silently. They are not magic. They have known edge cases, covered in Common Mistakes below, but they belong in every script you write.

### Adding an error trap with line numbers

`set -e` stops the script but does not explain what happened. A trap does. Add this after the `set` line:

```bash
error_handler() {
  local lineno="$1"
  local cmd="$2"
  echo "FAILED: line ${lineno}, command: ${cmd}" >&2
  echo "Exit status: ${3}" >&2
  exit 1
}
trap 'error_handler ${LINENO} "${BASH_COMMAND}" $?' ERR
```

Now any failing command produces a message with the line number and the exact command string. In a 400 line pipeline, that message saves an hour of searching.

## Causes, From Most Common to Rarest

### 1. Errors ignored by default (missing errexit)

This is the root cause behind most silent failures, and it deserves its own walkthrough even though the table covers it. Without `set -e`, this script is a lie:

```bash
#!/usr/bin/env bash
fastqc sample_R1.fastq.gz
multiqc .
echo "QC complete"
```

If `fastqc` fails on a corrupt file, `multiqc` still runs on whatever partial output exists, the echo prints, and the script exits 0. The mechanism is simple. Bash only aborts on certain conditions by default, and a plain failed command is not one of them. The fix is the `set -Eeuo pipefail` block above, plus the habit of checking for it in any script you inherit from a collaborator or a paper's repository.

### 2. Silent failures inside pipes

Pipelines are the beating heart of bioinformatics one liners, and they are also the biggest hole in naive error handling. Consider a normal RNA-seq counting step:

```bash
featureCounts -a annotation.gtf -o counts.txt aligned.bam
```

That is a plain command, and `set -e` catches it. Now consider the alignment step:

```bash
bwa mem ref.fa R1.fq.gz R2.fq.gz | samtools sort -o sorted.bam
```

Without `pipefail`, the exit status of this pipeline is `samtools sort`'s status alone. If `bwa mem` runs out of memory or hits a corrupt read block and dies at read 40 million of 80 million, `samtools sort` receives end of input, finalizes a valid BAM header, and exits 0. You now have a BAM containing half your reads with no warning anywhere.

Confirm it by counting records:

```bash
samtools view -c sorted.bam
```

Compare against your read counts from `fastp` or `seqkit stats`. If the numbers disagree and the pipeline reported success, you have found a pipefail failure. Fix it with `set -o pipefail` and verify that a deliberately broken pipe now produces a nonzero exit.

### 3. Unset variables and typos

Bash expands a variable that does not exist to an empty string by default. That behavior turns small typos into data loss. The canonical disaster:

```bash
OUTDIR=/project/results
# 200 lines later, after a rename
rm -rf "$OUTDOR/intermediate"
```

Bash expands `$OUTDOR` to nothing, and the command becomes `rm -rf "/intermediate"`. Depending on your filesystem layout, that can be anywhere from a bad afternoon to a career event. **Safety note:** before adding any `rm -rf` that uses a variable, enable `set -u` and protect the path with the `:?` expansion, which forces an error if the variable is empty or unset:

```bash
rm -rf "${OUTDIR:?}/intermediate"
```

If `OUTDIR` is unset, bash aborts with `OUTDIR: parameter null or not set` and deletes nothing. Make this pattern reflexive for every destructive command. The same protection applies to `mv` across filesystems and to any command with a wildcard, because an unmatched glob stays literal and can be passed to `rm` as an argument.

### 4. Spaces, tabs, and hostile filenames

Sample names arrive from collaborators with spaces, parentheses, leading dashes, and non printing characters copied from spreadsheets. Unquoted variables split on whitespace, so `sample 3 R1.fastq.gz` becomes three arguments, and the tool reports `sample: No such file or directory` before processing the rest of the list incorrectly.

Confirm hidden characters with `cat -A`, which shows tabs as `^I` and line endings as `$`. A Windows line ending at the end of a samplesheet line is one of the most common reasons a perfectly correct loop fails on exactly one sample. Fix it with `dos2unix samplesheet.txt` or `tr -d '\r'`.

The robust loop pattern:

```bash
shopt -s nullglob
for fastq in fastq/"${SAMPLE_ID}"_R*.fastq.gz; do
  echo "Processing ${fastq}"
  fastqc "${fastq}"
done
```

Quoting prevents word splitting. `nullglob` makes the loop skip entirely rather than run once with the literal unmatched pattern, which is how you end up with `fastqc` complaining about a file literally named `fastq/SAMPLE_R*.fastq.gz`.

### 5. Environment and dependency drift

A script that worked yesterday fails today with `command not found`, or a tool version changed and your counts shifted. Linux for bioinformatics means managing multiple toolchains, and the two dominant mechanisms are conda environments and environment modules on shared clusters.

The cause is that your interactive shell state does not transfer to the script. If you activated `rnaseq` in your terminal and then run the script, the script's subprocess inherits the PATH only if the activation happened in the same shell session before the call, and even then a `#!/usr/bin/env bash` script run under a scheduler gets a minimal environment. The fix is to make the script self contained:

```bash
source ~/miniconda3/etc/profile.d/conda.sh
conda activate rnaseq
command -v salmon || { echo "salmon not found in rnaseq env"; exit 1; }
```

On clusters using modules:

```bash
module purge
module load SAMtools/1.19
module load BWA/0.7.17
```

Check `command -v` for every critical tool at script start and fail fast with a readable message. Record exact versions in the log with `samtools, version` style output captured to a file, or with `conda list -n rnaseq > versions.txt`. Version drift is a genuine correctness problem in genomics, where tool updates can change mapping or quantification results, and it is one of the motivations for frameworks that pin the entire analysis chain, as the admixr package does by keeping ADMIXTOOLS analyses inside one versioned R environment [1].

### 6. Loops that lie

A loop over samples can finish "successfully" while doing almost nothing. Three mechanisms cover most cases.

**The glob matched nothing.** Without `nullglob`, bash passes the literal pattern to the command, which fails, and with no `set -e` the loop continues through every iteration failing identically. You get 48 error lines and exit 0.

**The input check was missing.** An empty or truncated FASTQ can pass `[[ -e ]]` (it exists) but fail every downstream step. Check size, not existence:

```bash
require_file() {
  if [[ ! -s "$1" ]]; then
    echo "Missing or empty file: $1" >&2
    exit 1
  fi
}
require_file "${reads}"
```

**The loop variable was clobbered.** Piping into a `while read` loop runs the loop in a subshell, so counters incremented inside are lost. Use process substitution or feed from a file descriptor. The simplest portable pattern:

```bash
while IFS=$'\t' read -r sample fastq1 fastq2; do
  process_sample "$sample" "$fastq1" "$fastq2"
done < samplesheet.tsv
```

`IFS=$'\t'` in front of `read` sets the delimiter for that command only and strips leading tabs, which handles most samplesheet quirks.

### 7. Disk, memory, and resource exhaustion

Large intermediate files are a structural feature of genomics. A single human WGS alignment run can produce BAM files measured in tens of gigabytes, and `sort` and `markdup` steps multiply temp file usage. Two failure modes dominate.

**Disk full.** The symptom is usually a tool error about writing, sometimes cryptic, sometimes a plain `No space left on device`. Confirm with `df -h`. The insidious version is a quota: `df -h` shows space on the volume, but your user quota is exhausted. Check `quota -s` where available. Prevention is to point `TMPDIR` at the right volume and to clean as you go:

```bash
export TMPDIR=/scratch/${USER}/tmp
mkdir -p "${TMPDIR}"
```

**Memory.** Exit code 137 with no error message means the kernel killed the process. Confirm with `dmesg -T` filtered for kill events, or on a cluster with `sacct -j jobid, format=MaxRSS` style fields in your scheduler's syntax. The fix is to cap each tool's memory with its own flags rather than letting it assume the whole machine, and to request matching memory from the scheduler. **Safety note:** never raise memory limits by running analysis scripts as root or by disabling system limits globally, because a runaway process on a shared server can evict every other user's job.

### 8. Tools that return 0 when they fail

Some tools exit 0 after partial failures, especially when they are built to continue past per record errors. Others print errors to stdout instead of stderr, so your log separation hides them. You cannot rely on exit codes alone. The defense is output validation. After each major step, check the output structurally:

```bash
validate_bam() {
  local bam="$1"
  if ! samtools quickcheck "${bam}"; then
    echo "BAM failed quickcheck: ${bam}" >&2
    exit 1
  fi
}
validate_bam sorted.bam
```

For text outputs, check row counts against expectations, check that a VCF is not header only, and check that summary files are non empty. Parsing tool output is exactly the step the admixr authors automated, precisely because hand parsing non tabular output into values for further analysis is fragile and error prone when done with shell utilities [1]. Build the same idea into your own pipelines: a small validation function after each step beats an afternoon of forensic debugging later.

### 9. Concurrency and scheduler problems

At the rare end of the spectrum sit failures that only appear when scripts run in parallel. Two scripts writing to the same temp filename, a job array where every task assumes it can create the same scratch directory, or a `make -j 16` build of a reference index that collides on the same intermediate file. Symptoms include randomly truncated files and errors that do not reproduce when you rerun a single job.

Confirm by rerunning the failing step in isolation. If it passes alone but fails under parallel load, you have a shared resource collision. Fix it by giving every concurrent task a unique workspace:

```bash
TASK_TMP="$(mktemp -d /scratch/tmp.XXXXXX)"
```

On SLURM clusters, read the job's own stderr file, use `sacct -j` to check actual resource usage against your request, and remember that the compute node environment is not your login environment, which brings us back to activating environments inside the job script itself.

## A Robust Template You Can Reuse

This template combines the solutions above into a starting point for any new bioinformatics script. Copy it, adapt the tool calls, keep the scaffolding.

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

readonly SCRIPT_NAME="$(basename "${BASH_SOURCE}")"
readonly LOG_DIR="$(pwd)/logs"
readonly TMP_ROOT="$(mktemp -d /scratch/tmp.XXXXXX)"

mkdir -p "${LOG_DIR}"

log() {
  printf '[%s] %s\n' "$(date +%Y-%m-%dT%H:%M:%S)" "$*" | tee -a "${LOG_DIR}/${SCRIPT_NAME%.sh}.log"
}

cleanup() {
  local exit_code=$?
  trap - EXIT
  rm -rf "${TMP_ROOT}"
  if (( exit_code == 0 )); then
    log "Pipeline finished successfully"
  else
    log "Pipeline FAILED with exit code ${exit_code}"
  fi
  exit "${exit_code}"
}
trap cleanup EXIT

error_handler() {
  log "FAILED at line $1: $2 (exit $3)"
}
trap 'error_handler ${LINENO} "${BASH_COMMAND}" $?' ERR

require_file() {
  if [[ ! -s "$1" ]]; then
    echo "Missing or empty input: $1" >&2
    exit 1
  fi
}

require_command() {
  if ! command -v "$1" > /dev/null 2>&1; then
    echo "Required tool not found: $1" >&2
    exit 1
  fi
}

# Environment
source ~/miniconda3/etc/profile.d/conda.sh
conda activate rnaseq

# Inputs
READS_R1="$1"
READS_R2="$2"
require_file "${READS_R1}"
require_file "${READS_R2}"
require_command salmon

# Work
log "Starting quantification"
salmon quant -i index -l A \
  -1 "${READS_R1}" -2 "${READS_R2}" \
  -p 8 --validateMappings -o "${TMP_ROOT}/salmon"

# Validate
if [[ ! -s "${TMP_ROOT}/salmon/quant.sf" ]]; then
  echo "quant.sf missing or empty" >&2
  exit 1
fi

log "Done"
```

Notes on the design choices:

- The `cleanup` trap removes the temp directory on both success and failure, so aborted runs do not fill the scratch volume.
- The `ERR` trap logs the failing line, which matters when the script runs unattended overnight.
- `require_file` and `require_command` fail before any expensive work begins.
- The final validation checks the actual output, not the exit code.
- Line continuations with `\` keep long commands readable without introducing semicolon separated compound statements.

## Logging That Actually Helps

Error handling and logging are the same discipline. A script that logs its inputs, versions, and step boundaries turns a mystery into a lookup.

Redirect the whole session once, near the top:

```bash
exec > >(tee -a pipeline.log) 2>&1
```

Every subsequent line, both stdout and stderr, lands in `pipeline.log` with everything still visible on your terminal. Then log the facts you will wish you had at 2 a.m.:

```bash
log "Host: $(hostname)"
log "User: $(USER=t ${USER})"
log "Git commit: $(git rev-parse HEAD 2>/dev/null || echo none)"
log "Salmon version: $(salmon --version 2>&1)"
log "Input checksums:"
md5sum "${READS_R1}" "${READS_R2}" | tee -a pipeline.log
```

Wait, that `USER=t` line is wrong on purpose only as a caution: do not copy it. The correct line is simply `log "User: ${USER}"`. The point of the surrounding block stands: record the host, the code revision, tool versions, and input checksums at start, and output checksums at the end. When a result looks wrong three weeks later, those lines tell you whether the inputs, the code, or the tools changed.

For reproducibility at the workflow level, the literature supports the same conclusion from a different direction. The admixr package was created because traditional ADMIXTOOLS workflows required users to generate text configuration files with a mix of shell scripting and manual editing, then parse non tabular results by hand before further analysis. Moving the whole chain into one programmatic framework significantly improved reproducibility [1]. The lesson generalizes to bash error handling: every manual shell step you eliminate or harden removes a class of silent failure.

## Common Mistakes and Limitations

**Mistake: assuming `set -e` catches everything.** It does not. Commands in `if` conditions, commands before `&&` or `||`, commands in pipelines without `pipefail`, and commands in certain subshell contexts are exempt or partially exempt. That is why the template combines `-e`, `pipefail`, explicit checks, and output validation rather than relying on one flag.

**Mistake: `2>/dev/null` in production scripts.** It hides real errors. If a tool is noisy on stdout, redirect stdout's noise selectively instead of discarding stderr wholesale.

**Mistake: testing only on the one sample that works.** Test with a deliberately corrupted input, an empty file, a filename with spaces, and a missing samplesheet row. Your error handling is only as good as the failures you have exercised.

**Mistake: fixing errors with `|| true`.** A handful of `|| true` statements, each added to silence one annoying failure, will eventually suppress a real failure that matters. If you need to tolerate a failure, write the condition explicitly, log it, and move on:

```bash
if ! multiqc . ; then
  log "multiqc failed, continuing without summary report"
fi
```

**Mistake: relying on the login node environment on a cluster.** Compute nodes start jobs with a clean environment. Activate conda environments and load modules inside the job script.

**Limitation: bash is not a workflow manager.** For pipelines beyond roughly a few hundred lines, with many samples, dependencies, and restarts, a workflow engine such as Nextflow, Snakemake, or WDL handles retry logic, parallelization, and resumption natively. Knowing how to handle errors in bash scripts is still essential, because you will read and debug bash inside those systems, and because small tasks will always be scripts.

**Limitation: exit codes are a convention, not a guarantee.** Tools disagree on codes, some lie as described above, and third party wrappers can swallow statuses. Structural validation of outputs is the only defense that does not depend on the tool's good behavior.

## Prevention Practices

The habits below prevent most of the failures in this article from ever reaching your overnight runs.

1. **Start every script with `set -Eeuo pipefail` and an ERR trap.** Make it muscle memory, the way you fasten a seatbelt.
2. **Quote every variable.** `"${var}"` always, no exceptions, and use `:?` on any variable feeding a destructive command.
3. **Validate before and after.** Check inputs with a `require_file` helper at the start. Check outputs with `quickcheck`, row counts, or size checks at the end of each step.
4. **Fail fast on the environment.** `command -v` every required tool before doing work, and activate environments inside the script.
5. **Log inputs, versions, and checksums.** A log with host, git commit, tool versions, and md5sums converts most "what changed" investigations into a two minute read.
6. **Test on tiny data first.** Subset to 10,000 reads, run the entire pipeline end to end in under two minutes, and only then launch the full dataset.
7. **Keep scripts small and single purpose.** One script per logical stage, driven by a samplesheet, beats one 900 line monolith whose failure you cannot localize.
8. **Automate what you would otherwise do by hand.** The reproducibility argument from the admixr work applies broadly: workflows that depend on hand edited configuration files and manual parsing of non tabular output accumulate silent errors, while workflows that generate, run, and parse everything programmatically do not [1].

## Frequently Asked Questions

### What does "command not found" mean in a bioinformatics script?

Exit code 127 means bash could not find the command in any directory listed in your `PATH`. In practice this almost always means the conda environment was not activated or the cluster module was not loaded inside the script. Run `which toolname` to confirm, then add the activation or `module load` lines to the script itself rather than relying on your interactive shell.

### Why does my pipeline report success when a step clearly failed?

By default, a pipeline's exit status is the status of its last command only. If an upstream command fails and a downstream command exits cleanly, the pipeline exits 0. Add `set -o pipefail` so the pipeline reports the first failing command, then add output validation such as `samtools quickcheck` to catch tools that misreport their own status.

### Should I use set -e in every bioinformatics script?

Yes, as part of `set -Eeuo pipefail`, with an awareness of its edge cases around conditional commands and pipelines. Stopping at the first failure is the correct default for pipelines where a partial result is worse than a clean abort. For the few places where a failure is expected and tolerable, handle it with an explicit `if` block and a log message.

### How do I find which command failed in a long pipeline?

Add an `ERR` trap that prints `${LINENO}` and `${BASH_COMMAND}`, or rerun with `bash -x script.sh 2>&1 | tee debug.log` and read upward from the first error. The last error in a long log is usually a downstream consequence. The first failing command is the cause.

### Why does my loop process only the first sample?

Usually the loop variable is unquoted and a filename containing spaces gets split into multiple arguments, or the glob matched nothing and the literal pattern was passed to the tool once. Quote the variable as `"$file"`, enable `shopt -s nullglob`, and print each filename inside the loop while debugging.

### How do I handle tools that return zero even when they fail?

Do not trust the exit code alone. Validate the output structurally after every major step: `samtools quickcheck` for BAM files, row and column counts for tables, non emptiness checks for summary files, and md5sums for anything you want to compare across runs. Treat exit codes as a first signal and output validation as the authority.

### What is pipefail and why does it matter for genomics workflows?

`set -o pipefail` makes a pipeline report the exit status of the first command that fails instead of the last command. It matters because genomics pipelines are chains of pipes, and a failure in an upstream aligner or adapter trimmer otherwise produces a truncated downstream file with a clean exit code. It is one of the four settings in `set -Eeuo pipefail`.

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

Run it on a small subset first, such as the first 10,000 reads, and confirm the full pipeline completes in minutes. Then run a negative test with a deliberately corrupted input and confirm the script aborts with a clear message rather than producing partial output. Finally, run `bash -n script.sh` to catch syntax errors before either test.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "What does \"command not found\" mean in a bioinformatics script?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Exit code 127 means bash could not find the command in any directory listed in your PATH. In practice this almost always means the conda environment was not activated or the cluster module was not loaded inside the script. Run which toolname to confirm, then add the activation or module load lines to the script itself rather than relying on your interactive shell."
      }
    },
    {
      "@type": "Question",
      "name": "Why does my pipeline report success when a step clearly failed?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "By default, a pipeline's exit status is the status of its last command only. If an upstream command fails and a downstream command exits cleanly, the pipeline exits 0. Add set -o pipefail so the pipeline reports the first failing command, then add output validation such as samtools quickcheck to catch tools that misreport their own status."
      }
    },
    {
      "@type": "Question",
      "name": "Should I use set -e in every bioinformatics script?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes, as part of set -Eeuo pipefail, with an awareness of its edge cases around conditional commands and pipelines. Stopping at the first failure is the correct default for pipelines where a partial result is worse than a clean abort. For the few places where a failure is expected and tolerable, handle it with an explicit if block and a log message."
      }
    },
    {
      "@type": "Question",
      "name": "How do I find which command failed in a long pipeline?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Add an ERR trap that prints LINENO and BASH_COMMAND, or rerun with bash -x script.sh 2>&1 | tee debug.log and read upward from the first error. The last error in a long log is usually a downstream consequence. The first failing command is the cause."
      }
    },
    {
      "@type": "Question",
      "name": "Why does my loop process only the first sample?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Usually the loop variable is unquoted and a filename containing spaces gets split into multiple arguments, or the glob matched nothing and the literal pattern was passed to the tool once. Quote the variable, enable shopt -s nullglob, and print each filename inside the loop while debugging."
      }
    },
    {
      "@type": "Question",
      "name": "How do I handle tools that return zero even when they fail?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Do not trust the exit code alone. Validate the output structurally after every major step: samtools quickcheck for BAM files, row and column counts for tables, non emptiness checks for summary files, and md5sums for anything you want to compare across runs. Treat exit codes as a first signal and output validation as the authority."
      }
    },
    {
      "@type": "Question",
      "name": "What is pipefail and why does it matter for genomics workflows?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "set -o pipefail makes a pipeline report the exit status of the first command that fails instead of the last command. It matters because genomics pipelines are chains of pipes, and a failure in an upstream aligner or adapter trimmer otherwise produces a truncated downstream file with a clean exit code. It is one of the four settings in set -Eeuo pipefail."
      }
    },
    {
      "@type": "Question",
      "name": "How do I test a bash script before running it on real data?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Run it on a small subset first, such as the first 10,000 reads, and confirm the full pipeline completes in minutes. Then run a negative test with a deliberately corrupted input and confirm the script aborts with a clear message rather than producing partial output. Finally, run bash -n script.sh to catch syntax errors before either test."
      }
    }
  ]
}
</script>

## Sources

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