Conditional Rule Execution with when#
This guide explains how to use the when field (WF-01) to skip rules based on configuration values.
Problem#
A single pipeline often needs to adapt to different inputs: WGS vs. WES sequencing modes, optional annotation steps, or analysis paths that depend on coverage thresholds. Without conditional syntax, you either ship multiple pipeline files or rely on shell-level if statements that obscure the workflow structure.
Solution: when expressions#
Add a when field to any rule. The expression is evaluated against your [config] section at execution time. When when evaluates to false, the rule is not removed from the DAG — it stays in the graph but is skipped at execution time (reported as Skipped in the run output), so its outputs are not produced.
[[rules]]
name = "fastqc"
when = "config.run_qc"
input = ["raw/sample_R1.fq.gz"]
output = ["qc/sample_fastqc.html"]
shell = "fastqc {input[0]} -o qc/"
Expression Syntax#
Simple truthiness#
when = "config.run_qc" # true if truthy: boolean true, non-zero number, or non-empty string other than "false"/"0"
when = "!config.skip_step" # true if skip_step is falsy
Comparisons#
when = 'config.mode == "WGS"' # string equality
when = 'config.mode != "WES"' # string inequality
when = "config.min_coverage >= 20" # numeric (>=, >, <=, <)
when = "config.threads == 8" # numeric equality
Boolean equality#
File existence#
Relative paths resolve against the workflow root (the workflow file's
directory) — the same root every other engine path uses — so the gate reads
the same no matter which directory you launch oxo-flow from. Absolute
paths pass through unchanged.
Data-dependent gates (runtime functions)#
when can also inspect files produced earlier in the same run — the gate
becomes data-dependent (issue #282). Five read-only functions are
available in when strings:
| Function | Reads | Returns |
|---|---|---|
reads_count(path) |
FASTQ (plain or .gz) |
record count (lines ÷ 4, truncated) |
wc_lines(path) |
any text file (plain or .gz) |
line count of the decompressed content |
file_size(path) |
any file | size in bytes |
regex_extract(path, pattern, group?) |
any text file | integer parsed from the first regex match (group defaults to 0 = whole match; the captured text must be a number) |
file_exists(path) |
— | truth probe — true only when the path exists (don't write == 1) |
[[rules]]
name = "downsample_qc"
when = "reads_count('trimmed/{sample}.fq.gz') > config.min_reads"
input = ["trimmed/{sample}.fq.gz"]
output = ["qc/downsample_report.txt"]
shell = "seqkit stats {input[0]} > {output[0]}"
Phase semantics. The file is resolved against the run workdir (absolute
paths pass through; {sample} and other wildcards expand from the
instance context). Because a gate may reference a file its producer has
not written yet, the two phases behave differently:
- Planning (
oxo-flow dry-run, DAG build): a runtime-function atom whose file is missing — or whose file is an output of this run but still holds a previous run's stale value — makes the whole gate defer: it evaluates totrueand the rule stays in the plan to be judged again at execution time. The defer propagates through!,&&,||and parentheses (three-valued logic), so a negated or chained gate is never pruned at plan time just because its file is not ready. - Execution: a missing or unreadable file makes the condition
evaluate to
false(fail-closed) — a gate that cannot count what it was asked to count does not run the rule.
Verdicts are recorded in the checkpoint's when_verdicts map; when a
threshold like config.min_reads changes between runs, only instances
whose recorded verdict actually flipped are invalidated (see "Resume &
checkpoints"). A when-false verdict also removes the rule from the
checkpoint's failed_rules set — a gated-off rule is never counted as a
failure, and oxo-flow status reports such rules under Skipped (when
condition false) (skipped_by_when in --json), never as ✗ Failed
(issue #690).
Scope limits. These functions are intentionally restricted to pure
file reads — no shell, no globbing beyond {wildcard} expansion, and
reads_count assumes 4-line FASTQ records (BAM/CRAM input is out of
scope for now — it needs a BGZF parser, not line arithmetic). Only
when strings may call them; shell, input,
and output still cannot read the filesystem at plan time.
Wildcard-scoped conditions (wildcard.<key>)#
Conditions can reference the pair/group expansion context through the
wildcard. prefix — the same values that drive {pair_id}, {experiment},
{control} and {sample} fan-out:
when = "wildcard.control != ''" # only pairs with a control sample
when = "wildcard.control == ''" # tumor-only pairs
when = "wildcard.group == 'case' && config.min_coverage >= 20"
Unlike config. conditions, wildcard. conditions are evaluated per
expansion instance at DAG build time — a non-matching instance never
enters the DAG (snakemake-style per-sample morphing), rather than being
planned and skipped at execution. A rule whose pair/group scope is
expressed only in when (no {pair_id}/{sample} in its paths) still
fans out per combo.
Notes:
control/normal/tumorare always present — a pair without a control expands them to the empty string, sowildcard.control != ''exactly separates paired from tumor-only pairs.- Optional wildcards (
experiment_type,tumor_type) expand to the empty string when unset. config.conditions keep the execution-time flow (planned, then skipped when false); the two scopes compose with&&.{key}placeholders insidewhenare not substituted — use thewildcard.<key>form.
[workflow]
# samplesheet.csv: one row with Normal fastqs, one without
pairs_file = "samplesheet.csv"
[[rules]]
name = "paired_mapping"
when = "wildcard.control != ''"
input = ["reads/{pair_id}.fq.gz"]
output = ["bam/{pair_id}.paired.bam"]
shell = "mapper --paired {input[0]} -o {output[0]}"
[[rules]]
name = "tumor_only_mapping"
when = "wildcard.control == ''"
input = ["reads/{pair_id}.fq.gz"]
output = ["bam/{pair_id}.tumor_only.bam"]
shell = "mapper --tumor-only {input[0]} -o {output[0]}"
Length checks (len(...))#
when = "len(config.gene_sets) > 0" # list is non-empty
when = "len(config.samples) >= 2" # at least two samples
when = "len(config.gene_sets)" # truthy shorthand for len > 0
len() counts array items, string characters, or table keys. A missing
config key has length 0, so len(config.x) == 0 covers both an empty list
and an undefined key. Note that a bare config.gene_sets is true even
for an empty array (the value exists) — use len(...) > 0 when emptiness
must gate the rule.
Logical operators#
when = "config.run_qc && config.min_coverage >= 20"
when = 'config.mode == "WGS" || config.mode == "WES"'
when = '(config.run_annotation && config.min_coverage >= 20) || config.force_annotate'
Practical Example#
[config]
sequencing_mode = "WGS"
run_qc = true
min_coverage = 35
target_bed = ""
[[rules]]
name = "align"
# No `when` — always runs
input = ["raw/sample_R1.fq.gz"]
output = ["aligned/sample.bam"]
shell = "bwa mem ref.fa {input[0]} > {output[0]}"
[[rules]]
name = "fastqc"
when = "config.run_qc"
input = ["raw/sample_R1.fq.gz"]
output = ["qc/fastqc.html"]
shell = "fastqc {input[0]} -o qc/"
[[rules]]
name = "wgs_coverage"
when = 'config.sequencing_mode == "WGS"'
input = ["aligned/sample.bam"]
output = ["qc/sample.mosdepth.summary.txt"]
shell = "mosdepth qc/sample aligned/sample.bam"
[[rules]]
name = "wes_coverage"
when = 'config.sequencing_mode == "WES" && config.target_bed != ""'
input = ["aligned/sample.bam"]
output = ["qc/sample.mosdepth.summary.txt"]
shell = "mosdepth --by {config.target_bed} qc/sample aligned/sample.bam"
[[rules]]
name = "annotate"
when = "config.run_qc && config.min_coverage >= 20"
input = ["variants/sample.vcf.gz"]
output = ["annotated/sample.vcf.gz"]
shell = "vep --input_file {input[0]} --output_file {output[0]}"
With sequencing_mode = "WGS" and run_qc = true and min_coverage = 35:
align— runsfastqc— runs (run_qcis true)wgs_coverage— runs (sequencing_mode == "WGS")wes_coverage— skipped (sequencing_mode != "WES")annotate— runs (run_qc && min_coverage >= 20)
Checking Which Rules Will Run#
Use oxo-flow dry-run to preview the execution plan:
Note that dry-run does evaluate when conditions: rules whose condition
is false appear in the plan marked [skip: when condition false], and the
summary counts them under will skip. At execution time the same rules are
skipped and reported as Skipped in the run output.
Full Example#
See examples/gallery/11_conditional_workflow.oxoflow for a complete WGS/WES adaptive pipeline that demonstrates most when expression types (truthiness, string comparisons, &&, >=).