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-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:
| 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)
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:
- 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.
- 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
--limitsdrops RastQC's duplication-memory bounding β so the strict 11-module-parity comparison paradoxically penalizes RastQC. We flag it rather than lean on it. - 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.
- 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 4reserves ~250 MB per worker and inflates a single-file run to ~900 MB for zero speedup, because FastQC's-tis per-file, not intra-file. That is true in the source, not just observed:AnalysisQueuesets its slot count tothreads, then starts oneThreadper whole file (oneAnalysisRunner= oneSequenceFile), andAnalysisRunner.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 4on 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 1vs-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').
-
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.Β β©