Variant Calling Pipeline#
This tutorial builds a complete paired somatic variant calling pipeline using oxo-flow — from raw FASTQ files to filtered VCF output. It demonstrates multi-environment workflows, tumor-normal pairing, resource scheduling, and real-world bioinformatics patterns.
Overview#
The pipeline follows the GATK best-practices workflow for paired tumor-normal calling:
graph TD
A[fastp: trim reads] --> B[bwa mem: align]
B --> C[samtools: sort & index]
C --> D[GATK MarkDuplicates]
D --> E[GATK BaseRecalibrator]
E --> F[GATK ApplyBQSR]
F --> G[GATK Mutect2: paired tumor-normal]
G --> H[GATK FilterMutectCalls]
Every rule applies to both the tumor and the normal sample — [[pairs]] drives the expansion automatically.
Project setup#
Environment files#
Create separate environments for different toolsets:
# envs/alignment.yaml
name: alignment
channels:
- bioconda
- conda-forge
dependencies:
- bwa=0.7.19
- samtools=1.24
- fastp=1.3.6
Workflow definition#
[workflow]
name = "variant-calling"
version = "1.0.0"
description = "Paired somatic variant calling pipeline (tumor-normal)"
[config]
reference = "/data/references/hg38/hg38.fa"
known_sites = "/data/references/hg38/dbsnp_146.hg38.vcf.gz"
known_indels = "/data/references/hg38/Mills_and_1000G_gold_standard.indels.hg38.vcf.gz"
germline_resource = "/data/references/hg38/af-only-gnomad.hg38.vcf.gz"
panel_of_normals = "/data/references/hg38/1000g_pon.hg38.vcf.gz"
results = "results"
[defaults]
threads = 4
memory = "8G"
[[pairs]]
pair_id = "P001"
experiment = "TUMOR_01"
control = "NORMAL_01"
[[pairs]]
pair_id = "P002"
experiment = "TUMOR_02"
control = "NORMAL_02"
[[rules]]
name = "trim_reads"
input = [
"raw/{experiment}_R1.fastq.gz", "raw/{experiment}_R2.fastq.gz",
"raw/{control}_R1.fastq.gz", "raw/{control}_R2.fastq.gz"
]
output = [
"{config.results}/trimmed/{experiment}_R1.fastq.gz", "{config.results}/trimmed/{experiment}_R2.fastq.gz",
"{config.results}/trimmed/{control}_R1.fastq.gz", "{config.results}/trimmed/{control}_R2.fastq.gz"
]
environment = { conda = "envs/alignment.yaml" }
shell = """
fastp --in1 raw/{experiment}_R1.fastq.gz --in2 raw/{experiment}_R2.fastq.gz --out1 {config.results}/trimmed/{experiment}_R1.fastq.gz --out2 {config.results}/trimmed/{experiment}_R2.fastq.gz --thread {threads}
fastp --in1 raw/{control}_R1.fastq.gz --in2 raw/{control}_R2.fastq.gz --out1 {config.results}/trimmed/{control}_R1.fastq.gz --out2 {config.results}/trimmed/{control}_R2.fastq.gz --thread {threads}
"""
[[rules]]
name = "align"
# -R adds read-group tags (required by GATK downstream); \\t stays a literal backslash-t so bwa can split it into header fields
input = [
"{config.results}/trimmed/{experiment}_R1.fastq.gz", "{config.results}/trimmed/{experiment}_R2.fastq.gz",
"{config.results}/trimmed/{control}_R1.fastq.gz", "{config.results}/trimmed/{control}_R2.fastq.gz"
]
output = [
"{config.results}/aligned/{experiment}.bam",
"{config.results}/aligned/{control}.bam"
]
environment = { conda = "envs/alignment.yaml" }
shell = """
bwa mem -t {threads} -R "@RG\\tID:{experiment}\\tSM:{experiment}\\tLB:lib_{experiment}\\tPL:ILLUMINA" {config.reference} {config.results}/trimmed/{experiment}_R1.fastq.gz {config.results}/trimmed/{experiment}_R2.fastq.gz | samtools sort -@ {threads} -o {config.results}/aligned/{experiment}.bam
samtools index {config.results}/aligned/{experiment}.bam
bwa mem -t {threads} -R "@RG\\tID:{control}\\tSM:{control}\\tLB:lib_{control}\\tPL:ILLUMINA" {config.reference} {config.results}/trimmed/{control}_R1.fastq.gz {config.results}/trimmed/{control}_R2.fastq.gz | samtools sort -@ {threads} -o {config.results}/aligned/{control}.bam
samtools index {config.results}/aligned/{control}.bam
"""
[rules.resources]
threads = 16
memory = "32G"
[[rules]]
name = "mark_duplicates"
input = ["{config.results}/aligned/{experiment}.bam", "{config.results}/aligned/{control}.bam"]
output = [
"{config.results}/dedup/{experiment}.dedup.bam", "{config.results}/dedup/{experiment}.metrics.txt",
"{config.results}/dedup/{control}.dedup.bam", "{config.results}/dedup/{control}.metrics.txt"
]
environment = { conda = "envs/gatk.yaml" }
shell = """
gatk MarkDuplicates -I {config.results}/aligned/{experiment}.bam -O {config.results}/dedup/{experiment}.dedup.bam -M {config.results}/dedup/{experiment}.metrics.txt --CREATE_INDEX true
gatk MarkDuplicates -I {config.results}/aligned/{control}.bam -O {config.results}/dedup/{control}.dedup.bam -M {config.results}/dedup/{control}.metrics.txt --CREATE_INDEX true
"""
[[rules]]
name = "base_recalibration"
input = ["{config.results}/dedup/{experiment}.dedup.bam", "{config.results}/dedup/{control}.dedup.bam"]
output = ["{config.results}/recal/{experiment}.recal.table", "{config.results}/recal/{control}.recal.table"]
environment = { conda = "envs/gatk.yaml" }
shell = """
gatk BaseRecalibrator -I {config.results}/dedup/{experiment}.dedup.bam -R {config.reference} --known-sites {config.known_sites} --known-sites {config.known_indels} -O {config.results}/recal/{experiment}.recal.table
gatk BaseRecalibrator -I {config.results}/dedup/{control}.dedup.bam -R {config.reference} --known-sites {config.known_sites} --known-sites {config.known_indels} -O {config.results}/recal/{control}.recal.table
"""
[[rules]]
name = "apply_bqsr"
input = [
"{config.results}/dedup/{experiment}.dedup.bam", "{config.results}/recal/{experiment}.recal.table",
"{config.results}/dedup/{control}.dedup.bam", "{config.results}/recal/{control}.recal.table"
]
output = ["{config.results}/recal/{experiment}.recal.bam", "{config.results}/recal/{control}.recal.bam"]
environment = { conda = "envs/gatk.yaml" }
shell = """
gatk ApplyBQSR -I {config.results}/dedup/{experiment}.dedup.bam -R {config.reference} --bqsr-recal-file {config.results}/recal/{experiment}.recal.table -O {config.results}/recal/{experiment}.recal.bam
gatk ApplyBQSR -I {config.results}/dedup/{control}.dedup.bam -R {config.reference} --bqsr-recal-file {config.results}/recal/{control}.recal.table -O {config.results}/recal/{control}.recal.bam
"""
[[rules]]
name = "mutect2"
input = ["{config.results}/recal/{experiment}.recal.bam", "{config.results}/recal/{control}.recal.bam"]
output = ["{config.results}/variants/{pair_id}.vcf.gz"]
environment = { conda = "envs/gatk.yaml" }
shell = """
gatk Mutect2 \
-R {config.reference} \
-I {config.results}/recal/{experiment}.recal.bam \
-I {config.results}/recal/{control}.recal.bam \
-normal {control} \
--germline-resource {config.germline_resource} \
--panel-of-normals {config.panel_of_normals} \
--native-pair-hmm-threads {threads} \
-O {config.results}/variants/{pair_id}.vcf.gz
"""
[[rules]]
name = "filter_variants"
input = ["{config.results}/variants/{pair_id}.vcf.gz"]
output = ["{config.results}/filtered/{pair_id}.filtered.vcf.gz"]
environment = { conda = "envs/gatk.yaml" }
shell = """
gatk FilterMutectCalls -R {config.reference} -V {config.results}/variants/{pair_id}.vcf.gz -O {config.results}/filtered/{pair_id}.filtered.vcf.gz
"""
Running the pipeline#
Validate#
With 2 pairs, all 7 rules expand to 14 concrete rule instances — trim_reads_P001, align_P001, mutect2_P001, and so on. Each pre-processing rule handles both the tumor and the normal sample of one pair, keeping the DAG fully connected at expansion time.
Preview#
The dry-run lists all expanded rules and suggests a -j value based on your machine's threads divided by the workflow's heaviest rule.
Execute#
# Two pairs can run concurrently — one job stream per pair
oxo-flow run variant-calling.oxoflow -j 2 -r 1
# Keep going even if a job fails
oxo-flow run variant-calling.oxoflow -j 2 -k
The engine's resource pool schedules jobs so concurrent rules never oversubscribe the CPU — align (16 threads) will not run alongside other heavy rules on a 16-core machine.
Generate a report#
Key Patterns Demonstrated#
| Pattern | Example |
|---|---|
| Tumor-normal pairing | [[pairs]] expands each rule per pair; Mutect2 runs in paired mode |
| Multiple environments | Different conda envs for alignment and GATK |
| Resource scaling | align overrides [defaults] with 16 threads / 32G; all other rules inherit 4 threads / 8G |
| Piped commands | bwa mem \| samtools sort in the align rule |
| Config variables | {config.reference}, {config.results} used across all rules |
| Linear dependency chain | Each rule's output is the next rule's input |
| Retry on failure | -r 1 flag retries failed jobs once |
Next Steps#
- Environment Management — docker, singularity, and mixed environments
- Run on a Cluster — submit to SLURM, PBS, or SGE