Skip to content

Streaming FastQC in polars-bio: compatible and scalable

FastQC is the de-facto first look at any sequencing run β€” but it is a single-threaded Java tool, and the fast Rust reimplementations tend to trade away correctness. polars-bio now runs the full FastQC module suite as a single streaming pass over FASTQ, computed on Apache DataFusion: FastQC 0.12.1-compatible under explicit parity checks (bit-exact for deterministic/count modules, tolerance-checked for floating metrics), and a fraction of the time and memory.

What shipped

Each QC module is a streaming accumulator (update β†’ merge β†’ finalize) folded over Arrow batches, so the work parallelizes across partitions and merges deterministically. All 12 core FastQC modules are implemented and validated against FastQC 0.12.1 golden output β€” bit-for-bit on deterministic/count modules, with floating metrics checked to tight tolerances (Kmer Content matches the FastQC top-20 set at single-partition parity; FastQC's top-20 k-mer list is inherently order-sensitive on real data). One call computes them all in a single out-of-core pass:

import polars_bio as pb

qc = pb.fastqc("reads_R1.fastq.gz")
qc.per_base_quality.collect()   # any module as a LazyFrame
qc.summary().collect()          # PASS / WARN / FAIL per module

# ...or as SQL
pb.sql("SELECT * FROM fastqc('reads_R1.fastq.gz')").collect()

The dataset

We benchmark on a real, citable clinical run rather than a synthetic file: SRR39421268, a Homo sapiens HER2 breast-cancer targeted-capture (exome) library.

Field Value
Run SRR39421268 (experiment SRX34138143)
Study / BioProject SRP714544 / PRJNA1484801
BioSample SAMN61257782 (HER2_PT06_OP_N)
Organism Homo sapiens
Instrument Illumina NovaSeq 6000
Library Targeted-Capture (hybrid selection), genomic, paired
Reads 32,167,982 pairs β†’ 64,335,964 reads @ 101 bp (~6.5 Gbp)
Size 3.3 GB BGZF
prefetch SRR39421268
fasterq-dump --split-spot -Z SRR39421268 | bgzip -c > SRR39421268.fastq.gz  # -Z streams to stdout; bgzip -c writes BGZF
bgzip -r SRR39421268.fastq.gz                                               # build the .gzi index (what lets polars-bio split for parallel scan)

The bgzip -r reindex step writes the BGZF .gzi index β€” that is what lets polars-bio split the file and scan it in parallel. All three tools are compared on the 11 modules FastQC runs by default.1

Environment

All three tools were run on the same laptop; wall-clock is best-of-two warm runs, memory via /usr/bin/time -l (footprint) with the FastQC JVM sampled separately.

Machine Apple M3 Max β€” 16 cores (12 performance + 4 efficiency)
Memory 64 GB
OS macOS 15.6 (Darwin 24.6.0), arm64
polars-bio 0.32.0 (FastQC Phase 1) Β· Python 3.12
FastQC 0.12.1 (OpenJDK)
RastQC 0.1.0
Data prep sra-tools 3.4.1 Β· htslib/bgzip 1.21

Tools compared

Tool Source Reference
FastQC s-andrews/FastQC Andrews S. (2010), FastQC, Babraham Bioinformatics
RastQC Huang-lab/RastQC Huang K-L, RastQC: A fast, Rust-based quality control tool…, bioRxiv (2026)
polars-bio biodatageeks/polars-bio this post

How each tool was run β€” all three compute the same 11 default FastQC modules (Kmer Content off):

# Fetch + convert to an indexed BGZF (.gzi lets polars-bio split it for parallel scan)
prefetch SRR39421268
fasterq-dump --split-spot -Z SRR39421268 | bgzip -c > reads.fastq.gz  # -Z β†’ stdout; bgzip -c β†’ BGZF
bgzip -r reads.fastq.gz                                               # write the .gzi index (required for parallel scan)

# FastQC β€” 11 default modules, single-threaded
fastqc --nogroup reads.fastq.gz

# RastQC β€” disable its default Kmer Content so it runs the same 11 modules
printf 'kmer\tignore\t1\n' >> limits.txt
rastqc -t 8 --limits limits.txt reads.fastq.gz          # -t 1/2/4/8

# polars-bio β€” N partitions (target_partitions = 1/2/4/8)
python - <<'PY'
import polars_bio as pb
pb.set_option("datafusion.execution.target_partitions", "8")
pb.fastqc("reads.fastq.gz").tidy.collect()
PY

Performance: four real datasets, 0.7M β†’ 64M reads

We benchmark on four real, citable libraries: our flagship 64M-read clinical HER2 exome (SRR39421268) plus the three short-read runs from the RastQC preprint itself (0.72M, 4.3M, 24.8M reads). All three tools compute the same 11 default modules; lower is better.

Our flagship: the 64M-read clinical exome

polars-bioRastQCFastQC (1 thread)
0 50 100 150 200 250 sec 1 2 4 8 FastQC β€” 212.5s 117.7 60.1 30.2 15.5 243.6 124.6 61.8 57.1

polars-bio scales 7.6Γ— to 15.5 s at 8 cores β€” 13.8Γ— faster than FastQC (213 s, single-threaded) and 3.7Γ— faster than RastQC's best (57.1 s; RastQC barely improves past 4 threads). polars-bio is faster than both at every thread count β€” and does it at ~2.5Γ— less total CPU than RastQC.

Each row lists wall (elapsed) and total CPU (user + system CPU-seconds β€” the actual machine work):

cores / threads pb wall pb CPU-s RastQC wall RastQC CPU-s FastQC
1 117.7 s 117.7 243.6 s 298.1 212.5 s (1 thr)
2 60.1 s 119.9 124.6 s 303.1 β€”
4 30.2 s 120.3 61.8 s 302.5 β€”
8 15.5 s 120.8 57.1 s 302.6 β€”

polars-bio's total CPU stays flat (~120 CPU-s) as threads rise β€” each partition adds ~1 core of real work, so wall time falls near-linearly. RastQC spends ~2.5Γ— more total CPU (~300 CPU-s) yet its wall time floors at ~57 s: it is throwing more machine at the problem and getting less back.

It also holds on the RastQC paper's datasets

The flagship is a single run. To check the pattern holds across sizes, we repeated the matched comparison β€” all three tools, the same 11 default modules β€” on the three short-read libraries taken from the RastQC preprint: 0.72M, 4.3M, and 24.8M reads, thread-scaled 1 β†’ 8. (All fetched with prefetch + fasterq-dump, converted to indexed BGZF; FastQC single-threaded as it has no parallel mode.)

The largest, DRR013000 (24.8M reads), tells the whole story:

polars-bioRastQCFastQC (1 thread)
0 10 20 30 40 50 60 70 80 sec 1 2 4 8 FastQC β€” 68.4s 38.1 19.4 9.88 5.34 70.3 35.5 22.4 22.6
cores / threads pb wall pb CPU-s RastQC wall RastQC CPU-s FastQC
1 38.1 s 38.1 70.3 s 92.3 68.4 s (1 thr)
2 19.4 s 38.5 35.5 s 92.6 β€”
4 9.9 s 38.8 22.4 s 93.2 β€”
8 5.3 s 39.2 22.6 s 93.7 β€”

polars-bio scales ~7Γ— to 5.3 s at 8 cores β€” 12.8Γ— faster than FastQC and 4.2Γ— faster than RastQC's best (22.4 s at 4 threads, after which RastQC plateaus). Even single-threaded, polars-bio (38.1 s) is nearly 2Γ— faster than FastQC β€” and its total CPU (~39 CPU-s) is less than half of RastQC's (~93 CPU-s).

All four, side by side

Across all four sizes the ranking never changes: polars-bio is fastest at every thread count, and its single-threaded time already beats FastQC on every run.

Each tool's best config, as wall / total-CPU-s:

dataset reads BGZF FastQC (1t) RastQC best (wall / CPU) pb best (wall / CPU) pb vs FastQC pb vs RastQC
DRR609229 R1 0.72M 28 MB 4.56 s 2.38 s / 2.4 (2t) 0.14 s / 1.0 (8c) 31.9Γ— 16.6Γ—
ERR5897746 R1 4.3M 315 MB 17.59 s 5.03 s / 23.7 (4t) 1.58 s / 9.1 (8c) 11.1Γ— 3.2Γ—
DRR013000 R1 24.8M 1.5 GB 68.35 s 22.40 s / 93.2 (4t) 5.34 s / 39.2 (8c) 12.8Γ— 4.2Γ—
SRR39421268 (ours) 64.3M 3.3 GB 212.5 s 57.1 s / 302.6 (8t) 15.5 s / 120.8 (8c) 13.8Γ— 3.7Γ—

Paired R2 mates behave identically (full 1/2/4/8 grid in the repo). Two things stand out in RastQC. On the small 0.72M file it shows no thread benefit at all (~2.4 s flat, 1β†’8t) β€” but that is by design: its ~28 MB BGZF is below RastQC's 50 MB parallelism threshold, so -t is a no-op there (polars-bio parallelizes it anyway, down to 0.14 s). On the two larger runs, which do clear the threshold, RastQC's single-threaded time is still slower than FastQC (18.6 vs 17.6 s; 70.3 vs 68.4 s; 243.6 vs 212.5 s) β€” whereas polars-bio scales cleanly and leads throughout, at 2–2.5Γ— less total CPU.

Memory

We measure peak memory exactly as RastQC's own benchmark does β€” /usr/bin/time -l, maximum resident set size β€” applied identically to every tool. Because the rest of this comparison runs the same 11 default modules (Kmer off), we report RastQC both ways: its default (Kmer on, the config the paper measured) and Kmer off (strict 11-module parity). polars-bio always runs the 11-module parallel path; FastQC is single-threaded per file.

# macOS /usr/bin/time -l, "maximum resident set size" (bytes Γ· 1048576 = MB), same for all
/usr/bin/time -l python - <<'PY'                                  # polars-bio (target_partitions=N)
import polars_bio as pb
pb.set_option("datafusion.execution.target_partitions", "8")
pb.fastqc("reads.fastq.gz").tidy.collect()
PY
/usr/bin/time -l rastqc -t 8 -q --time            -o out reads.fastq.gz   # RastQC default (Kmer on)
/usr/bin/time -l rastqc -t 8 --limits kmer_off.txt -o out reads.fastq.gz  # RastQC 11-module (Kmer off)
/usr/bin/time -l fastqc -t 1 --quiet              -o out reads.fastq.gz   # FastQC (single-threaded JVM)
polars-bioRastQCFastQC
0 400 800 1200 1600 683 polars-bio (8t) 651 RastQC kmer-on (8t) 1535 RastQC kmer-off (8t) 636 FastQC (1t)

Peak RSS (MB) across all four datasets at 1 / 4 / 8 threads (FastQC is single-threaded, so one value):

dataset reads polars-bio RastQC (Kmer on) RastQC (Kmer off) FastQC (1t)
DRR609229 0.72M 268 / 266 / 287 52 / 52 / 52 27 / 27 / 27 466
ERR5897746 4.3M 292 / 433 / 620 122 / 358 / 590 144 / 360 / 518 559
DRR013000 24.8M 302 / 466 / 630 146 / 344 / 622 367 / 687 / 858 528
SRR39421268 64M 330 / 497 / 683 167 / 419 / 651 757 / 1483 / 1535 636

Four honest takeaways β€” no tool wins memory outright:

  1. RastQC's default is genuinely lean and reproduces the paper: 52 / 358 / 344 MB at 4 threads vs its reported 49 / 332 / 315 β€” which confirms our method matches theirs.
  2. A RastQC quirk: turning Kmer off raises its memory 2–3Γ— on large files (flagship at 4 threads: 419 β†’ 1483 MB). Disabling a module via --limits drops RastQC's duplication-memory bounding β€” so the strict 11-module-parity comparison paradoxically penalizes RastQC. We flag it rather than lean on it.
  3. polars-bio's memory is the most predictable β€” 270–680 MB across a 90Γ— range of input sizes, growing gently with threads and barely at all with file size (its duplication tracking is bounded by FastQC's 100k-unique cutoff). It is competitive with RastQC's default and never balloons.
  4. FastQC's JVM RSS (~470–640 MB at 1 thread) sits in the paper's range, but is heap/GC-driven, not data-driven: fastqc -t 4 reserves ~250 MB per worker and inflates a single-file run to ~900 MB for zero speedup, because FastQC's -t is per-file, not intra-file. That is true in the source, not just observed: AnalysisQueue sets its slot count to threads, then starts one Thread per whole file (one AnalysisRunner = one SequenceFile), and AnalysisRunner.run() reads that file's sequences serially and applies every module in a plain loop β€” no thread is spawned inside a file. Measured: eff cores = 1.0 at -t 1, -t 2, and -t 4 on one file.

Correct at every thread count

FastQC is single-threaded, so "correct" is unambiguous. Both fast tools are multi-threaded, which raises a question the speed charts don't answer: does the output change with the thread count? We diffed every module at 1 vs N threads on a second run β€” ERR5897746, 4.25M reads:

  • polars-bio is value-identical at 1 and 4 partitions after parsing β€” the checked output values match exactly across partition counts.
  • RastQC is not β€” its duplication and k-mer modules return materially different numbers at -t 1 vs -t 4.

Here is the full per-module drift against the FastQC 0.12.1 golden. Each cell is the largest deviation across every data point in that module; the per-base rows are re-binned into RastQC's own position bins for a like-for-like comparison (RastQC has no --nogroup, so it always groups positions):

Module polars-bio RastQC -t 1 RastQC -t 4
Total sequences βœ… exact βœ… exact βœ… exact
%GC βœ… exact ❌ off by 1 ❌ off by 1
Per base quality βœ… exact βœ… β‰ˆexact (0.005 phred) βœ… β‰ˆexact
Per base content βœ… exact βœ… exact βœ… exact
Per base N βœ… exact βœ… exact βœ… exact
Per sequence GC βœ… exact βœ… exact βœ… exact
Per sequence quality βœ… exact ❌ 671k reads misbinned ❌ 671k reads misbinned
Sequence length βœ… exact βœ… exact βœ… exact
Duplication levels βœ… exact βœ… β‰ˆexact (0.005 pts) ❌ +6.16 pts total dedup
Kmer content βœ… same top-20; Ξ”obs/exp 0.0019 ⚠ top k-mer correct; list differs ❌ top k-mer wrong
Adapter content βœ… β‰ˆexact (float eps) ⚠ diff panel ⚠ diff panel
Per tile / Overrepresented - no data on this run -

RastQC's per-base plots are fine β€” the coarser look is just grouping, not error β€” and its adapter-content values match after re-binning, although it emits two tail bins per adapter that FastQC does not. But per-sequence quality is badly wrong: 671,121 reads land in a Q40 bin FastQC never emits (the same rounding defect that misbinned a third of the 64M run), %GC is off by one, and β€” uniquely β€” its duplication and k-mer outputs change with the thread count. At -t 1, RastQC's FastQC-comparable duplication percentages match within rounding (Total Deduplicated Percentage is +0.002 points), but at -t 4 total dedup jumps from 89.48% to 95.64% (+6.16 points) and the level-1 total bin is +9.95 points. Its most-enriched k-mer is correct at -t 1 but the top-20 list already differs; at -t 4, even the top k-mer is wrong.

polars-bio reproduces FastQC bit-for-bit on deterministic/count modules, stays within tight tolerances for floating metrics, and returns the same parsed values at the checked partition counts β€” its per-partition accumulators merge associatively, and Kmer Content (the one non-associative module) is computed on a single partition, so it is partition-invariant in this comparison too. You never have to ask which thread setting gave you the trustworthy number.

The takeaway

On a 64-million-read clinical exome run, polars-bio is the only tool that is both FastQC-compatible under these parity checks and genuinely fast: 13.8Γ— faster than FastQC, 3.7Γ— faster than RastQC, at ~2.5Γ— less total CPU and with the steadiest memory of the three (~270–680 MB, never ballooning) β€” while RastQC, the other fast option, silently misbins a third of the reads in one module β€” and gives different duplication and k-mer numbers depending on the thread count. That lead is not an artefact of one file β€” it holds from 0.7M to 64M reads. And it is just another table in the engine: SELECT * FROM fastqc('reads.fastq.gz').


  1. FastQC ships Kmer Content disabled by default, so the cross-tool comparison covers the 11 default modules. polars-bio implements Kmer Content too (12/12), parity-tested separately; its FastQC-style top-20 output is inherently non-deterministic on real data β€” a known property of FastQC's Kmer module. ↩