A practical tour of dataflow pipelines, anchored in a real nf-core production workflow: hlatyping.
MD Shakhaowat Hossain
PhD Candidate, Biomedical Sciences
Center for Biomedical Informatics and Genomics
Tulane University School of Medicine
HLA immunogenetics · transplant informatics
mhossain1@tulane.edu
Press S for speaker view · Space to advance · Esc for overview
This lecture is an educational walkthrough. Its goal is to build a clear conceptual understanding of how Nextflow, a widely adopted workflow management system, behaves in a real project setting rather than a toy example.
The worked example, nf-core/hlatyping, is an open-source, publicly available community pipeline. I use it here purely as a teaching artifact because it is production-grade and freely inspectable.
github.com/nf-core/hlatyping · nf-co.re/hlatyping.
nf-core/hlatyping performs 4-digit HLA class I typing from NGS reads using OptiType, with an optional HLA-HD path for class I + II. We will return to it on almost every slide.
Concrete > abstract. Every concept lands on a file you can grep.
# A typical sequencing pipeline, written as bash.
# Looks fine until sample 47 fails on a transient I/O blip.
for s in $(cat samples.txt); do
fastqc reads/${s}_1.fq.gz reads/${s}_2.fq.gz -o qc/
bwa mem -t 8 ref.fa reads/${s}_1.fq.gz reads/${s}_2.fq.gz \
| samtools sort -@ 8 -o bam/${s}.bam -
samtools index bam/${s}.bam
done
# Now: which sample is on which step? Which finished? Restart how?
// Identical work — main.nf. Run with: nextflow run main.nf -resume
process FASTQC {
tag "$id"
publishDir 'qc', mode: 'copy'
input:
tuple val(id), path(reads)
output:
path "*_fastqc.zip"
script:
"fastqc ${reads} -o ."
}
process BWA_MEM {
tag "$id"
cpus 8
publishDir 'bam', mode: 'copy'
input:
tuple val(id), path(reads)
path ref
output:
tuple val(id), path("${id}.bam"), path("${id}.bam.bai")
script:
"""
bwa mem -t ${task.cpus} ${ref} ${reads} \\
| samtools sort -@ ${task.cpus} -o ${id}.bam -
samtools index ${id}.bam
"""
}
workflow {
reads_ch = Channel.fromFilePairs('reads/*_{1,2}.fq.gz')
FASTQC(reads_ch)
BWA_MEM(reads_ch, file('ref.fa'))
}
fromFilePairs fans out; every sample runs FASTQC and BWA_MEM concurrently. No for loop, no manual job bookkeeping.-resume: the 46 finished samples are cache hits, only 47 re-executes.-profile docker | singularity | awsbatch runs this same code on a laptop, an HPC queue, or the cloud.container directive per process locks the tool version; every run emits execution_report.html and software_versions.yml.| Tool | Paradigm | Lang | Containers | Cloud-native | Resume |
|---|---|---|---|---|---|
| Make | File-target DAG | Make | Manual | No | By timestamp |
| Snakemake | File-target DAG | Python | Yes | Add-ons | Yes |
| WDL / Cromwell | Task graph | WDL | Yes | Yes | Yes |
| CWL | Task graph (YAML) | CWL | Yes | Engine-dep. | Engine-dep. |
| Nextflow | Dataflow + channels | Groovy DSL2 | First-class | First-class | -resume |
Snakemake and Nextflow dominate bioinformatics. The Broad uses WDL via Cromwell at planet-scale. We are teaching Nextflow today because nf-core, the community standard for curated pipelines, is built on it.
Dataflow programming, channels, processes, and the DAG that builds itself.
You do not write the order things run in. You write what depends on what. Nextflow infers the DAG from that.
workflow {
reads_ch = Channel.fromFilePairs('data/*_{1,2}.fq.gz')
FASTQC(reads_ch) // runs on every pair, in parallel
BWA_MEM(reads_ch) // also runs in parallel
MULTIQC(FASTQC.out.zip.collect()) // waits for ALL FASTQCs
}
Sample-level parallelism is free. collect() is the only synchronization barrier in the example above.
Click Morph to transform the bash pipeline on the left into the Nextflow equivalent on the right.
for s in $(cat samples.txt); do
bwa mem ref.fa \
reads/${s}_1.fq.gz reads/${s}_2.fq.gz \
| samtools sort -o ${s}.bam -
samtools index ${s}.bam
done
process BWA_MEM {
tag "$meta.id"
input: tuple val(meta), path(reads)
output: tuple val(meta), path("*.bam"), path("*.bai")
script:
"""
bwa mem ref.fa ${reads[0]} ${reads[1]} \
| samtools sort -o ${meta.id}.bam -
samtools index ${meta.id}.bam
"""
}
workflow {
Channel.fromFilePairs('reads/*_{1,2}.fq.gz')
| map { id, files -> [[id: id], files] }
| BWA_MEM
}
Processes, workflows, take/emit, modules, sub-workflows.
processprocess OPTITYPE {
tag "$meta.id"
label "process_medium"
container 'quay.io/biocontainers/optitype:1.3.5--hdfd78af_3'
publishDir "${params.outdir}/optitype", mode: 'copy'
input:
tuple val(meta), path(bam), path(bai)
output:
tuple val(meta), path("*_result.tsv"), emit: hla_type
tuple val(meta), path("*_coverage_plot.pdf"), emit: coverage_plot
path "versions.yml", emit: versions
script:
"""
OptiTypePipeline.py -i ${bam} \\
--${meta.seq_type} -v -o ./ -p ${meta.id}
"""
}
Directives (top): runtime metadata.
tag: human label per tasklabel: resource bucket in conf/base.configcontainer: pinned image, local and cloudpublishDir: where outputs land in results/I/O contract: tuples, paths, val, named emit:s.
Script: bash by default; can be Python, R, or anything with a shebang.
workflow { take / emit }: composable sub-workflowsworkflow NFCORE_HLATYPING {
take:
samplesheet // channel: samplesheet read in from --input
main:
HLATYPING ( samplesheet )
emit:
multiqc_report = HLATYPING.out.multiqc_report
}
From main.nf in nf-core/hlatyping. take declares inputs; emit declares outputs. The body in main: is just like any workflow.
take: turns a workflow into a reusable function with named inputs.emit: exposes named outputs that callers reference as WORKFLOW.out.name.workflow {...}).PIPELINE_INITIALISATION sub-workflow that handles param parsing, and the named scientific workflow (HLATYPING).
include// workflows/hlatyping.nf (real, abridged)
include { CHECK_PAIRED } from '../modules/local/check_paired'
include { HLAHD_INSTALL } from '../modules/local/hlahd/install'
include { HLAHD } from '../modules/local/hlahd/genotype'
include { CAT_FASTQ } from '../modules/nf-core/cat/fastq'
include { FASTQC } from '../modules/nf-core/fastqc/main'
include { MULTIQC } from '../modules/nf-core/multiqc/main'
include { GUNZIP } from '../modules/nf-core/gunzip/main'
include { OPTITYPE } from '../modules/nf-core/optitype/main'
include { SAMTOOLS_COLLATEFASTQ } from '../modules/nf-core/samtools/collatefastq/main'
include { SAMTOOLS_VIEW } from '../modules/nf-core/samtools/view/main'
include { YARA_INDEX } from '../modules/nf-core/yara/index/main'
include { YARA_MAPPER } from '../modules/nf-core/yara/mapper/main'
modules/nf-core/ = community-curated, version-pinned, identical across pipelines.modules/local/ = pipeline-specific (HLA-HD installer is license-gated, paired-end check for BAM input).main.nf entrypoint, in fullworkflow {
main:
PIPELINE_INITIALISATION (
params.version, params.validate_params,
params.monochrome_logs, args,
params.outdir, params.input,
params.help, params.help_full,
params.show_hidden
)
NFCORE_HLATYPING ( PIPELINE_INITIALISATION.out.samplesheet )
PIPELINE_COMPLETION (
params.email, params.email_on_fail,
params.plaintext_email, params.outdir,
params.monochrome_logs, params.hook_url,
NFCORE_HLATYPING.out.multiqc_report
)
}
Three sub-workflows, wired by named outputs. This is the entire entrypoint of a production pipeline. Everything else is composition.
Queue vs value, operators, and the patterns hlatyping actually uses.
Channel.fromFilePairs('reads/*_{1,2}.fq.gz')
// emits one tuple per sample pair
ch_ref = Channel.value(
file("$projectDir/data/references/grch38.fa")
)
// every YARA_MAPPER task sees the same reference
.combine() / .cross() or convert one to a value channel.
collect() Barrierch_reads has 5 items and ch_index has 1 item, a process reading both runs only once.meta) attached to trace elements and ensure sizes align.
.collect() operator gathers all elements from a queue channel and emits them as a single list (value channel).// MultiQC runs once, waiting for all FastQC zips
MULTIQC ( FASTQC.out.zip.collect() )
| Operator | What it does | Used in hlatyping for |
|---|---|---|
map | Transform each item | Attach the right HLA reference per seq_type |
branch | Route items into named outputs | Split samplesheet into bam / fastq_multiple / fastq_single |
cross | Pair items by their first element | Pair each sample's reads with its Yara index |
multiMap | Emit into multiple named outputs from one input | Split a cross result into reads and index streams |
combine | Cartesian product | Broadcast HLA-HD install artifact to every FASTQ |
groupTuple | Group by key | Group versions by process name before MultiQC |
collect | Gather all items into a single list | Wait for all FastQC zips before MultiQC |
mix | Merge streams of the same shape | Concatenate version-yaml outputs from every process |
join | Inner join on first element | Pair Yara BAM with its BAI before OptiType |
Click an operator to see a marble diagram drawn from hlatyping's actual usage.
branch in production: input-type routing// workflows/hlatyping.nf — verbatim
ch_samplesheet
.branch { meta, files ->
bam : files[0].getExtension() == "bam"
fastq_multiple :
(meta.single_end && files.size() > 1) ||
(!meta.single_end && files.size() > 2)
fastq_single : true
}
.set { ch_input_files }
// Downstream: ch_input_files.bam, ch_input_files.fastq_multiple, ch_input_files.fastq_single
true on the last branch makes it the catch-all default.cross + multiMap: pairing reads with their index// workflows/hlatyping.nf — verbatim
ch_all_fastq // [ meta, [ reads ] ]
.cross(YARA_INDEX.out.index) // [ [meta, [reads]], [meta, index] ]
.multiMap { reads, index -> // splits joined cross tuple
reads: reads // emitted as: [ meta, [ reads ] ]
index: index // emitted as: [ meta, index ]
}
.set { ch_mapping_input }
YARA_MAPPER(
ch_mapping_input.reads,
ch_mapping_input.index
)
ch_all_fastq: Emits [meta, [fastq_1, fastq_2]] per sample.YARA_INDEX.out.index: Emits [meta, index_dir].cross(): Aligns the elements by their first item (the meta map key). It outputs a nested tuple: [ [meta, [reads]], [meta, index] ].multiMap(): Deconstructs the pair and routes them into separate channels, ensuring the reads and index streams flow to YARA_MAPPER in exact lockstep.A 4-digit class I caller you can put into a transplant-matching audit.
A*02:01), protein-level resolution.sample,fastq_1,fastq_2,bam,seq_type
CONTROL_REP1,reads/c1_R1.fq.gz,reads/c1_R2.fq.gz,,dna
PATIENT_07,reads/p7_R1.fq.gz,reads/p7_R2.fq.gz,,rna
DONOR_A,,,reads/dA.bam,dna
Column rules: paired-end fills fastq_1 and fastq_2; BAM input goes in its own bam column with the FASTQ columns left empty. Only sample and seq_type are required; seq_type drives reference selection.
cat.--tools optitype,hlahd with a valid --hlahd_path and the pipeline additionally produces class I + II calls at up to 6-digit (3-field) resolution.
Press Next to walk the DAG. The right column shows the channel contents at that stage.
// workflows/hlatyping.nf — verbatim, abridged
if ( "optitype" in tools.tokenize(",") ) {
ch_all_fastq
.map { meta, reads ->
[ meta, file("$projectDir/data/references/hla_reference_${meta['seq_type']}.fasta") ]
}
.set { ch_input_with_references }
YARA_INDEX ( ch_input_with_references )
ch_all_fastq
.cross(YARA_INDEX.out.index)
.multiMap { reads, index ->
reads: reads
index: index
}
.set { ch_mapping_input }
YARA_MAPPER ( ch_mapping_input.reads, ch_mapping_input.index )
OPTITYPE ( YARA_MAPPER.out.bam.join(YARA_MAPPER.out.bai) )
}
Note how seq_type (dna vs rna) selects a different reference FASTA, automatically, per sample.
YARA_MAPPER first constrains reads to the curated HLA reference (an all-mappings, not best-hit, alignment), then OptiType resolves the ambiguity globally: it formulates allele selection as an integer linear program over all class-I loci at once, choosing the genotype that best explains the full read set rather than locus by locus. That global step is what gets you transplant-grade 4-digit calls.
Channel.topic("versions")// workflows/hlatyping.nf — verbatim
def topic_versions = Channel.topic("versions")
.distinct()
.branch { entry ->
versions_file: entry instanceof Path
versions_tuple: true
}
def topic_versions_string = topic_versions.versions_tuple
.map { process, tool, version ->
[ process[process.lastIndexOf(':')+1..-1], " ${tool}: ${version}" ]
}
.groupTuple(by: 0)
.map { process, tool_versions ->
tool_versions.unique().sort()
"${process}:\n${tool_versions.join('\n')}"
}
"versions") without explicit wiring.groupTuple, the pipeline ends with a software_versions.yml that lists every tool and version it ran.if ( "hlahd" in tools.tokenize(",") ) {
def hlahd_software_meta = file("$projectDir/assets/hlahd_software_meta.json",
checkIfExists: true)
def jsonSlurper = new groovy.json.JsonSlurper()
def hlahd_meta = jsonSlurper.parse(hlahd_software_meta)['hlahd']
def ch_hlahd_install = Channel.of([
'hlahd', hlahd_meta.version, hlahd_meta.software_md5,
file(params.hlahd_path, checkIfExists: true),
params.hlahd_update_reference_dict,
])
HLAHD_INSTALL(ch_hlahd_install)
HLAHD(ch_all_fastq.combine(HLAHD_INSTALL.out.hlahd))
ch_versions = ch_versions.mix(HLAHD.out.versions)
}
--tools optitype,hlahd.combine broadcasts the single install artifact to every sample (Cartesian product on a size-1 channel).ch_versions.mix(...) appends HLA-HD's versions to the same provenance stream as everything else.Change ${params.outdir}, the OptiType flags, or meta.seq_type on the left. The right pane shows how Nextflow would render the script block at runtime. Switch samplesheet rows to watch the same process body resolve differently per sample.
Param substitution preview only. No execution — that is the runtime's job.
Executors, containers, and the configuration layer that abstracts them.
// Filled by app.js
The process definitions and workflow wiring never change. Only nextflow.config swaps. That is the entire portability story.
process OPTITYPE {
container 'quay.io/biocontainers/optitype:1.3.5--hdfd78af_3'
// ... rest of the process
}
// Or, with separate Docker/Singularity URIs:
process YARA_MAPPER {
container "${ workflow.containerEngine == 'singularity' &&
!task.ext.singularity_pull_docker_container ?
'https://depot.galaxyproject.org/singularity/yara:1.0.2--he1c1bb9_4' :
'quay.io/biocontainers/yara:1.0.2--he1c1bb9_4' }"
}
nextflow.config and profiles// conf/base.config (real hlatyping pattern)
process {
cpus = { 1 * task.attempt }
memory = { 6.GB * task.attempt }
time = { 4.h * task.attempt }
errorStrategy = { task.exitStatus in [137,143] ? 'retry' : 'finish' }
maxRetries = 2
withLabel: process_medium {
cpus = { 6 * task.attempt }
memory = { 36.GB * task.attempt }
}
withName: OPTITYPE {
memory = { 16.GB * task.attempt }
}
}
-profile docker — local Docker-profile singularity — HPC-profile conda — Conda fallback-profile test — tiny test data + Docker-profile test_full — full-size CI
test, test_fastq, test_fastq_cat, test_rna, test_dna_rna, test_hlahd, test_optitype_hlahd, test_full — each exercising a different input pattern.
The community standard, the runtime trick that pays for itself in an afternoon, and the audit trail.
modules/nf-core/.nf-test, GitHub Actions, full-size AWS runs).nextflow_schema.json.modules/
├── local/
│ ├── check_paired/ # is this BAM paired-end?
│ ├── hlahd/install/ # license-gated install
│ └── hlahd/genotype/ # license-gated typing
└── nf-core/
├── cat/fastq/
├── fastqc/
├── gunzip/
├── multiqc/
├── optitype/
├── samtools/collatefastq/
├── samtools/view/
├── yara/index/
└── yara/mapper/
If a future pipeline needs Yara, it imports the same module. No copy-paste drift.
nf-core/tools, nf-test, and the schema-driven UInf-core createnf-core lintnf-core syncnf-core modules installCLI for spinning up, updating, and lint-checking nf-core pipelines.
hlatyping ships an nf-test.config and a tests/ tree. Changes that break a process do not merge.
--help outputOne schema, three surfaces (CLI, docs, web UI). No drift between them.
-resume: the single most loved Nextflow feature
Nextflow hashes (inputs + script body + container digest + directives) per task. On -resume, every task with a matching hash is fetched from work/ instead of rerun.
--tools optitype,hlahd -resumeOnly the HLA-HD branch is new in run 2. FastQC, Yara, OptiType are cache-hit; MultiQC re-runs because its input set has changed.
pipeline_info/execution_report.html — CPU, memory, wall-clock per task.pipeline_info/execution_timeline.html — Gantt chart.pipeline_info/pipeline_dag.svg — the rendered DAG.pipeline_info/software_versions.yml — provenance, every tool, every version.multiqc/multiqc_report.html — domain-level QC + typing summary.nextflow_schema.json.pipeline_dag.svg after your first real run. It is the fastest way to internalize how dataflow composes processes.
Left: execution_timeline.html for one sample — hover any task for its resource profile. Right: a live Seqera run, the same data streamed during execution.
OPTITYPE is the resource hotspot: modest CPU, but the ILP solve drives peak RSS. That single fact is why the SLURM profile gave it a withName: OPTITYPE memory override.
-profile awsbatch; the provenance YAML lands next to every result for downstream meta-analysis.Slides and the worked-example references are linked from the deck.
MD Shakhaowat Hossain
PhD Candidate, Biomedical Sciences
Center for Biomedical Informatics and Genomics
Tulane University School of Medicine
HLA immunogenetics · transplant informatics
mhossain1@tulane.edu