If Statements in Bash for Bioinformatics

By Dr. Zubair Khalid, DVM, MS, PhD ·

If Statements in Bash for Bioinformatics

By the end of this article you will be able to write bash if statements that gate a genomics pipeline on real conditions. You will check that input files exist and are complete, react to the exit status of tools such as fastqc, bwa, and samtools, compare read counts between paired-end FASTQ files, skip samples that already have results, and log the decisions your pipeline makes. You will also learn the specific ways these statements fail, including the exact error messages bash prints, and how to verify your logic with bash -n and shellcheck.

You need a Linux or macOS machine (or the Windows Subsystem for Linux), bash version 4 or later, and a plain text editor. The examples use standard Linux commands available on any distribution, plus common bioinformatics tools such as fastqc, bwa, samtools, and bcftools. You do not need root access. Every example is small enough to test on a directory with a handful of files.

Why If Statements Matter in Bioinformatics Workflows

A typical sequencing analysis is a chain of commands. You demultiplex, trim, align, sort, mark duplicates, call variants, and summarize. Each stage reads files written by the previous stage. If a stage fails silently, every later stage either crashes with a confusing error or, worse, produces plausible-looking output from incomplete input.

The classic description of this problem comes from population genetics. In a traditional ADMIXTOOLS workflow, a researcher must generate a set of text configuration files for each analysis, often using a combination of shell scripting and manual text editing, and then parse non-tabular output files to extract values of interest [1]. That combination of hand-built files and scripted glue is exactly where unguarded bash code breaks. A missing input file, a truncated download, or an unnoticed nonzero exit status propagates downstream and can invalidate an entire analysis.

If statements are the smallest unit of defense. They let a script make a decision before doing expensive work: run this step only if the input is present, skip this sample only if the output is complete, stop the pipeline if the alignment returned an error. A single well-placed conditional is often the difference between a pipeline that fails loudly at the right moment and one that fails quietly two days later.

This matters for reproducibility as much as for convenience. Packages such as admixr were built specifically to automate the low-level configuration and parsing steps of ADMIXTOOLS and to unify workflows under a single programmatic framework, which the authors note significantly improves the reproducibility of population genetic studies [1]. The if statements you write in bash serve the same goal at the shell level: they replace human judgment ("did that file look right?") with explicit, repeatable checks.

The Core Syntax of Bash If Statements

Bash does not test conditions directly. Every command in Linux returns an exit status, which is a number from 0 to 255. An exit status of 0 means success. Any nonzero value means failure, and the meaning of each nonzero code depends on the program. The if statement simply runs a command and checks whether that command returned 0.

The structure looks like this:

if [ -f sample_1.fastq.gz ]
then
    echo "Input file found"
else
    echo "Input file missing"
    exit 1
fi

Read the keywords as plain English. if starts the test. then begins the block that runs when the test succeeds. else begins the block that runs when it fails. elif (short for else if) lets you chain additional tests. fi (if spelled backward) closes the whole statement.

The square brackets are not decoration. [ is actually a command, an alias for the test command, and the expression inside the brackets is a set of arguments passed to it. This explains the single most common bash error: the brackets must be separated from their contents by spaces, because [ is a program name, not punctuation.

Bash also provides [[, a bash-specific keyword with friendlier behavior. The difference matters and is covered in the table below. For most bioinformatics scripts, [[ is the safer default, while [ keeps your script compatible with the POSIX sh shell.

Three facts about exit status will carry you through most situations:

  1. The variable $? holds the exit status of the most recently completed command. 0 means success.
  2. true is a command that always returns 0. false always returns 1. They are useful for testing your own conditionals.
  3. An if condition can be any command list, not just a bracket test. if grep -q "sample_A" samples.txt is a complete, valid conditional.

Step-by-Step: Writing Bash If Statements for Bioinformatics

This walkthrough builds six conditionals in increasing complexity. Run them in a scratch directory so mistakes cost nothing.

Step 1: Check that an input file exists and is not empty

Create a test file and an empty file first.

mkdir -p scratch && cd scratch
printf "read_one\nread_two\n" > sampleA_1.fastq.gz
touch sampleB_1.fastq.gz

Note that sampleB_1.fastq.gz here is an empty regular file, not real gzip data. It exists, which makes it a good prop for learning the difference between "exists" and "exists and has content."

Now write the check:

if [ -s sampleA_1.fastq.gz ]
then
    echo "sampleA input is present and nonempty"
else
    echo "sampleA input is missing or empty"
fi

if [ -s sampleB_1.fastq.gz ]
then
    echo "sampleB input is present and nonempty"
else
    echo "sampleB input is missing or empty"
fi

Expected output:

sampleA input is present and nonempty
sampleB input is missing or empty

The -s test returns true only if the file exists and has a size greater than zero bytes. This is almost always what you want for pipeline inputs. The -f test returns true for any existing regular file, including a zero-byte one left behind by a crashed download or an earlier failed run. Bioinformatics directories are full of such husks, which is why -s beats -f for gating real work.

Step 2: Act on the exit status of a command

Every stage of a pipeline should be gated on the previous stage's exit status.

fastqc sampleA_1.fastq.gz

if [ $? -eq 0 ]
then
    echo "fastqc finished successfully"
else
    echo "fastqc failed with status $?"
    exit 1
fi

Expected output on success:

Started analysis of sampleA_1.fastq.gz
Approx 5% finished for sampleA_1.fastq.gz
Analysis complete for sampleA_1.fastq.gz
fastqc finished successfully

The $? in the echo inside the else block deserves a warning covered fully in the mistakes section. By the time bash evaluates that echo, $? holds the status of the if condition itself, which is what you want here. But if you insert any other command between the test and the use of $?, the value is overwritten. Capture it immediately when you need it later:

fastqc sampleA_1.fastq.gz
status=$?
if [ $status -ne 0 ]
then
    echo "fastqc failed with status $status"
    exit 1
fi

Step 3: Compare numbers

Numeric comparison uses the -eq (equal), -ne (not equal), -lt (less than), -le (less than or equal), -gt (greater than), and -ge (greater than or equal) operators. A common bioinformatics use is enforcing a minimum read count before running expensive downstream steps. The threshold is your choice and belongs in a variable at the top of the script so collaborators can see and change it.

MIN_READS=1000000
read_count=$(grep -c "^@" sampleA_1.fastq)

if [ $read_count -ge $MIN_READS ]
then
    echo "sampleA passed the depth check with $read_count reads"
else
    echo "sampleA has only $read_count reads, below the minimum of $MIN_READS"
    exit 1
fi

Expected output when the file holds two reads:

sampleA has only 2 reads, below the minimum of 1000000

The $( ) construct is command substitution. Bash runs the inner command and replaces the whole expression with its standard output. Here, grep -c "^@" counts lines starting with the @ character, which in a four-line-per-record FASTQ file approximates the number of sequence records. For a production script you would count records more carefully, but the conditional logic is identical.

Step 4: Compare strings

String comparison uses = for equality, != for inequality, -z for an empty string (zero length), and -n for a nonempty string. String checks are how you validate that required variables were actually set before a script does anything destructive.

SAMPLE_NAME="sampleA"

if [ -z "$SAMPLE_NAME" ]
then
    echo "Error: SAMPLE_NAME is not set"
    exit 1
fi

if [ "$SAMPLE_NAME" = "sampleA" ]
then
    echo "Processing $SAMPLE_NAME"
else
    echo "Unexpected sample name: $SAMPLE_NAME"
    exit 1
fi

Expected output:

Processing sampleA

The quotation marks around "$SAMPLE_NAME" are not optional style. Without them, a variable containing spaces is split into multiple words and the bracket command receives more arguments than it can handle. Always quote variable expansions inside [.

Step 5: Combine conditions

Bash gives you two ways to combine tests. Inside [[ ]]', you can use && for AND and || for OR. Inside [ ], use the -a and -o operators or, better, separate bracket commands joined by && and ||` at the shell level.

R1="scratch/sampleA_1.fastq.gz"
R2="scratch/sampleA_2.fastq.gz"

if [ -s "$R1" ] && [ -s "$R2" ]
then
    echo "Paired-end inputs are both present and nonempty"
else
    echo "One or both paired-end inputs are missing or empty"
    exit 1
fi

if [ ! -s "results/sampleA.bam" ] || [ "$FORCE" = "yes" ]
then
    echo "Alignment needed for sampleA"
else
    echo "Alignment already complete for sampleA"
fi

Expected output for the first block, given the files created earlier:

Paired-end inputs are both present and nonempty

The ! negates a test. The second block implements the checkpoint pattern described later: run the alignment only when the output is missing, empty, or the user explicitly set FORCE=yes.

Step 6: Assemble the pieces into a pipeline gate

Decision path checking that input files exist and are nonempty, then running the tool and confirming a nonempty output before moving on.

Here is a complete, minimal gate for one sample. It checks inputs, runs a step, checks the exit status, and verifies the output.

#!/bin/bash

R1="sampleA_1.fastq.gz"
R2="sampleA_2.fastq.gz"
OUT="sampleA.bam"

if [ ! -s "$R1" ] || [ ! -s "$R2" ]
then
    echo "ERROR: missing or empty FASTQ input for sampleA" >&2
    exit 1
fi

bwa mem reference.fa "$R1" "$R2" 2> sampleA.bwa.log | samtools view -b -o "$OUT"

if [ $? -ne 0 ]
then
    echo "ERROR: alignment failed for sampleA" >&2
    exit 1
fi

if [ -s "$OUT" ]
then
    echo "OK: $OUT written"
else
    echo "ERROR: $OUT was not created" >&2
    exit 1
fi

Expected output on success:

OK: sampleA.bam written

Two details in this block are load-bearing. First, 2> sampleA.bwa.log redirects standard error to a file, because bwa writes its progress messages to standard error and leaving them interleaved with results makes parsing painful. Second, the final -s check catches the case where the aligner "succeeded" but wrote nothing, which happens with corrupt inputs. Checking both the exit status and the output is the minimum standard for any step in a pipeline.

Operators That Matter in Bioinformatics

Side by side comparison of single bracket and double bracket tests in bash, covering quoting, combining conditions and regex matching.

The following table summarizes the operators you will use most often when writing if statements in bash for bioinformatics work.

CategoryOperatorMeaningTypical bioinformatics use
File test-f fileFile exists and is a regular fileConfirm a reference or index exists
File test-s fileFile exists and is larger than zero bytesConfirm a FASTQ, BAM, or VCF is really there
File test-d dirDirectory existsConfirm an output directory before writing
File test-r fileFile is readableCatch permission problems on shared drives
File test!Negates the following testRun a step only when output is absent
Numeric-eq, -neEqual, not equalCompare an exit status to 0
Numeric-lt, -le, -gt, -geLess than, less or equal, greater, greater or equalEnforce minimum read counts or sample numbers
String= , !=Equal, not equalMatch sample names or file extensions
String-z, -nEmpty, nonemptyValidate that a variable was set
Combined&&, ``AND, ORRequire both mates of a pair, allow a forced rerun

Two syntax notes that save hours of debugging. First, [[ treats its contents more leniently than [: it does not word-split variables, it supports pattern matching with ==, and it supports regular expression matching with the =~ operator, as in [[ "$file" =~ \.fastq\.gz$ ]]. Second, when you need arithmetic on numbers that might be floating point, such as coverage values, bash alone cannot do it. Pipe the numbers through awk or bc and test their textual output instead.

Real Patterns for If Statements in Bash for Bioinformatics Pipelines

The step-by-step examples show mechanics. The patterns below show how working pipelines use them. Each one is a self-contained snippet you can adapt.

Pattern 1: Verify paired-end FASTQ files have matching record counts

Uneven mate files are a classic silent killer. The alignment step may run to completion and produce garbage. Count the records in each file and compare before aligning.

n1=$(zcat sampleA_1.fastq.gz | wc -l)
n2=$(zcat sampleA_2.fastq.gz | wc -l)

if [ $n1 -ne $n2 ]
then
    echo "ERROR: mate counts differ ($n1 vs $n2 lines) for sampleA" >&2
    exit 1
fi

Expected output on failure, printed to standard error:

ERROR: mate counts differ (8000000 vs 7999996 lines) for sampleA

Line counts rather than record counts are fine here because both mates must have the same number of four-line records, so equal line counts imply equal record counts. Dividing by four adds nothing.

Pattern 2: Handle the exit status of a pipeline, not just its last command

In bash, the exit status of a pipeline is the exit status of its final command by default. This hides failures in earlier commands. Suppose you filter a VCF and count the results:

bcftools view -f PASS variants.vcf.gz | gzip > variants_pass.vcf.gz
if [ $? -eq 0 ]
then
    echo "Filtering succeeded"
fi

This test examines only gzip, not bcftools. If bcftools fails midway, gzip may still exit 0 with a truncated file, and your if statement reports success. Bash exposes the individual statuses in the PIPESTATUS array, which is populated after each pipeline and immediately overwritten by the next command.

bcftools view -f PASS variants.vcf.gz | gzip > variants_pass.vcf.gz
if [ ${PIPESTATUS} -ne 0 ]
then
    echo "ERROR: bcftools view failed" >&2
    exit 1
fi

Alternatively, start scripts that depend on piped commands with set -o pipefail, which makes the pipeline return the rightmost nonzero status of any component.

Pattern 3: Use checkpoints to make reruns cheap

Long pipelines get interrupted. Rather than rerunning everything, test for a completed, nonempty output and skip finished work.

OUT="results/sampleA_sorted.bam"

if [ -s "$OUT" ]
then
    echo "Skipping sampleA, output already exists"
else
    samtools sort -o "$OUT" sampleA.bam
    if [ $? -ne 0 ] || [ ! -s "$OUT" ]
    then
        echo "ERROR: sorting failed for sampleA" >&2
        rm -f "$OUT"
        exit 1
    fi
fi

The rm -f on failure is deliberate. A partially written BAM is worse than no BAM, because the next rerun would see a nonzero-size file, pass the -s checkpoint, and skip the repair. Deleting failed outputs keeps the checkpoint honest.

Existence alone is not proof of integrity, so strong checkpoints pair the size test with a format-aware check. gzip -t tests the internal integrity of a gzip file. samtools quickcheck verifies that a BAM has a valid header and is not truncated. A combined gate looks like this:

if [ -s "$OUT" ] && samtools quickcheck "$OUT" 2>/dev/null
then
    echo "Skipping sampleA, output exists and passes quickcheck"
else
    echo "Output missing or corrupt, running alignment for sampleA"
fi

Note that a command used directly as an if condition contributes its own exit status, which is why samtools quickcheck needs no brackets around it.

Pattern 4: Flag samples against a threshold while parsing a summary table

Suppose depth_summary.txt holds two columns, sample name and mean depth, produced by another tool. You can drive decisions from it with a bash loop over awk output.

MIN_DEPTH=10

awk '{print $1, $2}' depth_summary.txt | while read -r sample depth
do
    if [ -z "$sample" ]
    then
        continue
    fi
    if awk -v d="$depth" -v m="$MIN_DEPTH" 'BEGIN { exit (d >= m) ? 0 : 1 }'
    then
        echo "$sample depth $depth OK"
    else
        echo "$sample depth $depth BELOW threshold $MIN_DEPTH" >> low_depth_samples.txt
    fi
done

Expected content of low_depth_samples.txt when two samples fall short:

sampleC depth 7.8 BELOW threshold 10
sampleF depth 9.2 BELOW threshold 10

The comparison itself happens inside awk because awk handles decimal numbers natively, and awk's exit with 0 or 1 turns the comparison into an exit status the bash if can test. The -v flags pass the bash variables into the awk program. The continue statement skips blank lines that often appear at the end of tool output.

Pattern 5: Validate sample counts in a VCF

Variant call sets are frequently filtered by sample number, for example when you expect a specific cohort. bcftools query -l lists sample names, one per line.

expected=54
observed=$(bcftools query -l cohort.vcf.gz | wc -l)

if [ $observed -ne $expected ]
then
    echo "ERROR: cohort.vcf.gz has $observed samples, expected $expected" >&2
    exit 1
fi

This two-line check has caught countless renaming and subsetting errors. It belongs near the top of any script that consumes the VCF, before any expensive work.

If Statements Inside Loops for Batch Processing

Real projects rarely process one sample. The loop-plus-conditional combination is where if statements in bash for bioinformatics pay off most. This snippet walks a directory of paired FASTQ files, validates each pair, and records its decision.

MIN_READS=100000
LOG="pipeline_run.log"

for r1 in raw/*_1.fastq.gz
do
    sample=$(basename "$r1" _1.fastq.gz)
    r2="raw/${sample}_2.fastq.gz"

    if [ ! -s "$r2" ]
    then
        echo "$sample: missing mate file, skipping" | tee -a "$LOG"
        continue
    fi

    n1=$(zcat "$r1" | grep -c "^@")
    if [ $n1 -lt $MIN_READS ]
    then
        echo "$sample: $n1 reads, below minimum, flagging" | tee -a "$LOG"
        echo "$sample" >> low_input_samples.txt
        continue
    fi

    if [ -s "trimmed/${sample}_1_trimmed.fastq.gz" ]
    then
        echo "$sample: already trimmed, skipping" | tee -a "$LOG"
        continue
    fi

    echo "$sample: $n1 reads, processing" | tee -a "$LOG"
    fastp -i "$r1" -I "$r2" \
          -o "trimmed/${sample}_1_trimmed.fastq.gz" \
          -O "trimmed/${sample}_2_trimmed.fastq.gz"
done

Expected log output for a mixed directory:

sampleA: 2450000 reads, processing
sampleB: missing mate file, skipping
sampleC: 88000 reads, below minimum, flagging
sampleD: 3100000 reads, already trimmed, skipping

Three techniques in this loop are worth internalizing. The basename command with a suffix argument strips the directory and the trailing pattern to recover the clean sample name. The tee -a construct writes each message both to the terminal and to a log file, so the record of what the script decided survives after the terminal scrolls. The continue statement moves to the next sample on any failed check, which keeps one bad sample from halting a batch of two hundred.

Common Mistakes and Limitations

These are the errors that account for most broken conditionals in real analysis scripts, with the messages bash actually prints.

  1. Missing spaces around the brackets. if [$n -eq 0] produces [: missing ] or, for the no-space variant against a word, [$n: command not found. The brackets are a command and need whitespace on both sides. Write if [ "$n" -eq 0 ].
  1. Unquoted variables. if [ $file = "ref.fa" ] breaks when $file contains spaces, with the message unary operator expected or too many arguments. Quote every expansion: [ "$file" = "ref.fa" ], or use [[ which tolerates unquoted variables safely.
  1. Numeric operators on non-numeric strings. if [ "$sample" -eq 5 ] where $sample is sampleA prints integer expression expected and the branch behaves unpredictably. Use string operators for strings, numeric operators for numbers, and validate input with a regex if the type is uncertain: [[ "$n" =~ ^+$ ]].
  1. Trusting existence when you need completeness. -f returns true for a zero-byte file left by a killed process. For pipeline inputs and checkpoints, use -s, and add a format-aware integrity test such as gzip -t or samtools quickcheck where truncation is plausible.
  1. Reading $? after another command. $? reflects only the most recent command. This sequence is a classic bug:
bwa mem reference.fa r1.fq r2.fq > aln.sam
echo "Alignment started"
if [ $? -ne 0 ]

The echo reset $? to 0, so the if statement never detects the failure. Capture the status into a named variable immediately, as shown in Step 2, or put the command directly in the if condition.

  1. Pipelines that mask failures. As shown in Pattern 2, the default exit status of a pipeline belongs to the last command only. Without set -o pipefail or an explicit PIPESTATUS check, a failing upstream tool hides behind a successful downstream one.
  1. Forgetting fi. Bash reports syntax error: unexpected end of file when a script ends with an open if block. The fix is to close every if with fi, and every case with esac. Deep nesting makes this common, so flatten logic with early exits where you can.
  1. Confusing [ and [[ features. [ does not support &&, ||, or =~ inside the brackets. Writing if [ "$a" = "x" && "$b" = "y" ] produces missing ]. Either split into two bracket commands joined by shell-level &&, or move to [[.
  1. Windows line endings. Scripts edited on Windows carry carriage return characters that bash cannot parse, producing errors like $'\r': command not found. Convert with dos2unix script.sh or configure your editor for Unix line endings.
  1. Testing a directory with -f. -f is false for directories, so a check intended to confirm the reference directory exists fails confusingly. Use -d for directories.
  1. Scripting past the point where a workflow manager is the right tool. Conditional logic grows: branching by sample metadata, rerunning failed nodes, parallel execution. Bash handles the small end of this well, but beyond a certain size the better answer is to move orchestration to a dedicated system. The admixr authors make this argument for population genetics specifically, replacing hand-managed shell scripts and manual text editing with a single programmatic framework that automates configuration and parsing [1]. The same reasoning applies to any pipeline held together by long chains of conditionals.
  1. Silent decisions. An if statement that quietly skips a sample leaves no trace in the results. Always echo or tee what each branch did, with the sample name and the reason, so a collaborator reading the log months later can reconstruct the run.

Checking Your Work

Before trusting a conditional, verify it three ways.

First, check syntax without executing anything:

bash -n pipeline.sh

Expected output on success is nothing at all. Any syntax error, including a missing fi, prints a line number.

Second, trace execution. Insert set -x at the top of a script or run it as bash -x pipeline.sh. Bash prints each expanded command prefixed with +, which shows exactly how variables were substituted inside your test.

++ '[' -s sampleA_1.fastq.gz ']'
++ echo 'sampleA input is present and nonempty'

Third, run a static analyzer. The shellcheck tool reads a script and reports specific, actionable warnings, including unquoted variables and useless tests.

shellcheck pipeline.sh

A typical finding looks like this:

In pipeline.sh line 12:
if [ $sample = "sampleA" ]
      ^------^ SC2086: Double quote to prevent globbing and word splitting.

Finally, test both branches deliberately. A conditional you have only ever seen succeed is untested. Create the failure condition on purpose, with a zero-byte file or a deliberately wrong variable, and confirm the else branch does what you expect. Pipeline authors who do this catch the majority of logic errors before a real dataset ever touches the script.

Next Steps

Once conditionals feel natural, three upgrades deliver most of the remaining value in Linux for bioinformatics scripting.

Add a strictness header to new scripts: set -euo pipefail makes bash exit on any failed command, treat unset variables as errors, and propagate failures through pipelines. Your explicit if statements then handle the cases you want to decide about, while set -e catches everything else.

Wrap repeated blocks in functions, with the sample name and file paths as arguments, so the validation logic is written once and tested once.

Recognize when the job has outgrown bash. The trajectory of most groups is the same: ad hoc shell commands, then a scripted pipeline with conditionals, then a workflow system such as Snakemake or Nextflow that manages dependencies and retries natively. For population genetics in particular, moving analysis steps into a programmatic framework has been shown to automate configuration and parsing and to improve reproducibility, which is the explicit motivation behind the admixr package for ADMIXTOOLS analyses [1]. Bash if statements remain the right tool inside that trajectory for quick validation, file gating, and batch bookkeeping, and they stay useful even inside workflow systems, which often shell out to small bash snippets for exactly those tasks.

Frequently Asked Questions

What does an if statement actually do in a bioinformatics script?

It runs a test command and executes one block of code when that command returns exit status 0, or a different block when it returns nonzero. In practice this means a script can check that a FASTQ file is present, that an aligner succeeded, or that a sample has enough reads, and only then proceed to the next stage.

Should I use single brackets [ ] or double brackets [[ ]]?

Use [[ ]] in bash scripts unless you need strict POSIX sh compatibility. Double brackets do not word-split variables, handle pattern and regex matching, and produce fewer surprising errors, while single brackets work in every POSIX shell but require careful quoting.

Why does bash say "integer expression expected"?

You used a numeric operator such as -eq or -lt on a value that is not a plain integer, often a string like sampleA or a decimal like 7.8. Check the variable's contents, or perform decimal comparisons in awk and test the result.

How do I confirm a downloaded FASTQ file is complete?

Combine three checks: the file is nonzero size with -s, its gzip integrity passes with gzip -t, and, where available, a format-aware tool such as samtools quickcheck validates the header and structure. A file that merely exists is not evidence that a transfer finished.

How do I skip samples that already have results?

Wrap each stage in a checkpoint: if [ -s "$output" ] then skip else run. Delete partial outputs when a step fails, so a truncated file cannot masquerade as a completed checkpoint on the next run.

Why does my script keep going after a command fails?

By default bash continues after failures, and in a pipeline only the last command's exit status is visible. Start the script with set -euo pipefail, gate important steps with explicit exit status checks, and use PIPESTATUS when you need the status of a specific command inside a pipe.

Can bash compare decimal numbers like mean coverage?

Not with its built-in numeric operators, which are integer only. Compute and compare decimals with awk or bc, then convert the result to an exit status that a bash if statement can test, as shown in the depth-threshold example above.

How do I debug an if statement that always takes the wrong branch?

Run the script with bash -x to see the expanded test, run shellcheck for quoted-variable and syntax warnings, and echo the variables immediately before the conditional. In almost every case the trace reveals an unquoted expansion, a stale $?, or a value with unexpected whitespace.

Sources

  1. admixr-R package for reproducible analyses using ADMIXTOOLS.