Container Format¶
A .fqxv file is a small header followed by a sequence of independently-coded
row groups (blocks), then a footer index and an EOF trailer. Row groups are
the unit of parallelism and of coarse random access; the footer lets a reader
seek to any of them without scanning the file.
Layout¶
[4] magic "FQXV"
[1] format version major (LE) (reader refuses a differing major)
[1] format version minor (LE) (tolerated within a major; informational)
[8] required_features (LE u64) (coarse capability bits; an unknown set bit is
refused rather than mis-decoded)
[1] sequence context order (k)
[1] quality binning tag (0 lossless, 1 bin8, 2 bin4, 3 bin2,
4 ont, 5 hifi)
[1] flags (bit0 '+' normalized; bit1 reordered;
bit2 keep-order; bit3 global-reorder;
bit4 regen-names; bit5 global-reference;
bits6-7 free)
[1] group size G (1 single-end, 2 paired, 3-4 single-cell)
[1] platform tag (0 unknown, 1 Illumina, 2 Nanopore,
3 PacBio, 4 MGI/BGI)
[2] ext_len (LE u16) (bytes of the extension region below)
[ ] extension region (TLV records [1 tag][2 len][len bytes];
empty at 1.0, lets a later minor add skippable
header fields without a major bump)
[4] header CRC-32C (LE) (over the header prefix + extension region)
repeated until the terminator:
[4] block sync marker "FQXB" (recovery scans for it to resync)
[8] block payload length (LE, nonzero)
[4] CRC-32C of the payload (LE) (verified before the block is decoded)
[ ] block payload
[4] "FQXB" [8] 0 (zero-length terminator frame)
footer:
[4] n_row_groups (LE)
per row group: [8] byte_offset (LE) (points at the group's frame marker)
[4] read_count (LE)
per stream (names, sequence, quality, in that order):
[8] stream_offset (LE) (absolute offset of the coded bytes)
[4] stream_len (LE) (coded length)
[4] stream_crc32c (LE) (over exactly those coded bytes)
[8] total_reads (LE)
[4] whole_file_crc (LE) (CRC-32C of byte 0 .. total_reads)
[4] footer_crc (LE) (CRC-32C of the footer body above)
trailer (at EOF):
[8] footer_offset (LE)
[4] magic "FQXF"
The on-disk format is versioned major.minor (currently 1.0). A reader
refuses an archive whose major differs from its own — that archive is
wire-incompatible — but tolerates a newer minor, since a minor bump only adds
backward-compatible things (a skippable header extension record, or a new optional
codec gated by a required_features bit). The fixed header prefix is 21 bytes; with
the empty 1.0 extension region and the 4-byte CRC the whole header is 25 bytes.
Each block payload:
[8] names_digest (LE) (xxh3-64 of the block's decoded names)
[8] seq_digest (LE) (xxh3-64 of the block's decoded sequence)
[8] qual_digest (LE) (xxh3-64 of the block's decoded quality)
[4] n_reads (LE)
[4] names_len (LE) [ ] names (fqxv-tokenizer)
[4] seq_len (LE) [ ] seq (fqxv-seq)
[4] qual_len (LE) [ ] qual (fqxv-fqzcomp)
The three streams are contiguous within the payload, so each one's absolute
(offset, len, crc32c) is recorded in the footer index (above). That is what
lets a reader fetch a single column — just the names, or just the
sequence — without reading the whole block; see
Column projection. The three
*_digest fields are an end-to-end round-trip check over the block's decoded
content — one digest per stream (names, sequence, post-binning quality), verified
after decode, so a mismatch localizes which stream a codec round-tripped wrong —
see Integrity.
Streaming vs. seeking¶
The block region carries an inline [8] length before every payload, so a
streaming decoder reads row groups one after another and stops at the
zero-length terminator — it never needs to seek, so fqxv decompress stays
bounded-memory and pipe-friendly (fqxv decompress x.fqxv -Z | bwa mem -p). A
seeking reader instead jumps to the EOF trailer, follows footer_offset back
to the footer, and reads the row-group index directly. The terminator is what
lets the same file serve both: the streaming reader stops before the footer, the
seeking reader skips straight past it.
Footer index, info, and random access¶
The footer records, per row group, its frame-marker byte offset, its read count
(read-start is the running sum), and an (offset, len, crc32c) triple for each
of the three coded streams (names, sequence, quality). It covers
the plain layout; reordered archives use a distinct self-describing layout
(below). This buys three things over a scan-everything approach:
infois O(row groups), not O(bytes). The per-stream sizes live in the footer itself, soinforeads the footer once and sums them — no per-block seeks at all.- Coarse random access. The per-group
read_count(read-start is the running sum) locates the row groups overlapping any read range, so a reader can seek to and decode just those. Granularity is the row group, not the read — every codec carries model state across the reads within a group, so a group is the smallest independently decodable unit. - Column projection. Because the footer pins each stream's absolute
(offset, len), a reader can fetch a single column across some or all row groups — just the read names, or just the sequence — with one range request per group and never touch the others. Read names are typically <1% of the archive, so a client that only wants IDs (counting reads, building an external index) fetches ~100× less than the whole file; a sequence-only client (k-mer screening, taxonomic classification, alignment-free QC) skips the quality stream entirely. The per-streamcrc32cmakes the projection verifiable: the block's content digests cover the decoded streams and so cannot check a projected fetch of coded bytes, so each coded stream also carries its own CRC-32C in the footer.
Random-access API¶
This is exposed IO-free so a caller can drive fetching however it likes (a local
File, object_store, async HTTP over Range requests). The fqxv library
provides an Index that parses from a seekable reader (Index::read) or from a
fetched archive tail (Index::from_suffix, which reports the exact longer
tail to refetch when the first suffix fell short of the footer). From it,
Index::byte_ranges(groups, stream) yields the byte ranges to fetch,
Index::verify_stream checks a fetched column against its CRC, and
decode_names / decode_sequence / decode_quality (or decode_block_contents
for a whole fetched block) turn the fetched bytes back into reads. The reordered
layout has no footer index, so Index::read rejects it — its streams are
mutually dependent and cannot be projected.
The Python fqxv.remote module surfaces this projection over HTTP Range
requests: RemoteArchive fetches the footer tail with parse_index_suffix, then
reads one column with Index.stream_range / verify_stream and decode_*_bytes
(a custom async client can drive those primitives directly for concurrent fetches).
Whole-archive streaming decode needs no index at all — it reads the blocks
front-to-back and stops at the terminator frame — so it works over any forward byte
stream: fqxv decompress - (pipe in aws s3 cp s3://… -, curl, …) on the CLI,
and fqxv.open(file_like) / fqxv.remote.stream(url) in Python.
Blocks and parallelism¶
The compressor reads FASTQ into row groups and hands a batch to rayon to
compress concurrently, writing them back in order and recording each in the
footer index. Decompression reads length-prefixed blocks and decodes a batch in
parallel. Because each block is self-contained (its own name/sequence/quality
models), no block depends on another — so the archive, including the footer
offsets, is byte-identical regardless of thread count, and a block can be
decoded without touching the rest of the file.
Row-group sizing (short and long reads)¶
A row group is cut at whichever comes first: block_reads reads (set by
--level) or a fixed raw-sequence byte budget. For fixed short reads
(Illumina) the read count binds and the byte budget never triggers. For long,
ragged reads (nanopore) the byte budget binds first — otherwise a read-count
block of, say, 1M × 10 kb reads would be a ~10 GB row group that destroys
parallelism and random-access granularity and could overflow the u32 per-stream
compressed length. Byte cuts still land on whole-spot boundaries. Boundaries
depend only on the read lengths and the two limits, never on thread scheduling,
so determinism holds.
Capping a block at the byte budget also caps how much coverage a long-read block's
overlap codec sees, and each block otherwise self-assembles (and re-stores) its own
consensus reference. For long-read input the container hoists that reference out:
it assembles one consensus over the whole file and stores it once in a framed
region between the header and the first block (gated by the GLOBAL_REFERENCE
feature bit and flag bit5), then codes every block's reads against that frozen
frame (sequence method byte 2). Placement is per-read against an immutable frame,
so blocks stay byte-budgeted, parallel, and independently decodable given the
frame, while the genome is stored once rather than once per block. A whole-file
never-worse gate adopts the layout only when it beats the plain order-k total. See
long reads.
Grouping (paired-end and single-cell)¶
When G > 1, per-spot reads are interleaved (m0₀, m1₀, …, m0₁, m1₁, …). Blocks
always contain whole spots and start on member 0, so a block splits back into
its G files by local read index mod G. Interleaving lets the name tokenizer
collapse the near-identical mate names and keeps a spot's reads adjacent for the
sequence model.
fqxv decompress emits interleaved FASTQ (ideal for aligners); --split
restores the G separate files.
Reordered archives¶
--order any uses a distinct whole-file, globally-clustered layout (flag bit3,
global-reorder) that is self-describing and carries no footer or terminator —
decode dispatches on the flag before reading any block, so the footer index
applies only to the plain layout. Both reorder modes share this one path: with
keep-order (flag bit2) names/quality are coded in original order and a
permutation restores it; without it they follow the clustered order and no
permutation is written. Grouped (paired / single-cell) input reorders too: the
reads are clustered ignoring mate structure, but the group size is recorded in
the header and the permutation reconstructs the original spot interleaving, so
keep-order is forced on and the archive de-interleaves cleanly on --split. See
Read Reordering.
After the shared header, the reorder layout is a run of length-prefixed,
CRC-guarded frames (each [4] len · [4] CRC-32C · payload):
[8] n_reads (LE)
flip bitmap (one bit per clustered read: stored reverse-complemented)
permutation (byte-plane split → rANS; empty when order is not kept)
name template (counter template for regenerated names; empty otherwise)
global reference (present only when flag bit5, global-reference, is set)
[4] n_blocks (LE)
per block: sequence payload
per block: names payload, quality payload
output digest (xxh3-64 over the reads in emit order)
Sequence codecs (chosen per block, smaller wins). Each sequence block is differential-coded against the clustered order and tagged with a leading version byte, so blocks may mix versions and decode dispatches on the byte:
- v2 — single-contig clustered coding (block-local).
- v3 — adds literal-rescue (block-local): reads the single-contig codec would strand as literals are recovered against a block-local reference.
- v4 — codes reads as
(contig, offset, mismatches)positions on one frozen whole-file global reference assembled (SPRING-style) over every clustered read, so cross-block overlaps v3 strands as literals collapse to a cheap back-reference. The reference is assembled once, then overlap-merged (contigs whose suffix overlaps another's prefix are chained into fewer, longer super-contigs) and stored once in theglobal referenceframe.
v4 is enabled by the adaptive rescue path (default under --order any; disabled by
--no-rescue). It is only ever adopted when reference frame + Σ min(v2,v3,v4) is
strictly smaller than the block-local Σ min(v2,v3) total, so the flag bit5
reference is written only when it nets a whole-file win — v4 can never enlarge the
archive.
Reference-frame coding. The global reference frame begins with a method byte.
Both methods use the clean-room order-k fqxv-seq context coder — there is no
external compressor here (the earlier xz/liblzma reference path was removed, so
the whole codec stack is pure-Rust clean-room):
0— the whole reference coded in a singlefqxv-seqpass.1(the default) — the reference split into a fixed number of contig blocks, each coded withfqxv-seqin parallel. The block count is fixed (never derived from--threads), so the coded bytes are byte-identical regardless of thread count.
Integrity¶
Every archive carries CRC-32C (Castagnoli) checksums — the same checksum family BGZF/BAM and CRAM use — layered so a single flipped bit is both detected and localized rather than silently decoded into wrong bases or quality scores. Four checksums sit at four levels of the layout above:
- Per-block payload CRC. Each block frame is
[8] length · [4] CRC-32C · [ ] payload; the CRC is verified before the payload is handed to the codecs, so corruption is caught and confined to one row group. This is what makesdecompress --recoverpossible — a bad block is identified and skipped, not decoded into garbage. - Per-stream CRC. The footer's per-group
(offset, len, crc32c)triples carry a CRC-32C over each coded stream's bytes, so a projected single-column fetch (Column projection) stays verifiable — the per-block payload CRC covers all three coded streams at once, and the content digests cover the decoded streams, so neither can check a projected fetch of one coded stream in isolation. - Footer CRC (
footer_crc) guards the row-group index. It covers the footer body and is checked before any byte offset in the index is trusted, so a damaged footer can't send a seeking reader to a bogus location. - Whole-file CRC (
whole_file_crc) is a CRC-32C over the archive from byte 0 through thetotal_readsfield — a single end-to-end digest of everything but the two footer CRCs and the trailer.
A fifth check is not a CRC: each block payload leads with three xxh3-64 digests over its decoded content — one each for names, sequence, and post-binning quality — verified after decode. Where the CRCs catch corruption of the stored bytes, these digests catch a codec that turned CRC-valid bytes into wrong-but-in-bounds output, and localize the failure to the offending stream.
fqxv verify checks the whole-file, footer, and per-block
CRCs in one pass without running any codec: it re-hashes the archive prefix
against whole_file_crc, validates footer_crc, and confirms every per-block
CRC — far cheaper than a full decompress. (The per-stream CRCs are checked on
demand by the projection path rather than by verify.) The globally-clustered
reordered layout carries no footer, so verify there
drives every frame CRC by decoding into a sink instead.
Losslessness¶
Read name + description, sequence, and quality bytes are preserved exactly. The
+ separator line is normalized to a bare + (its optional repeated header is
not retained), matching SPRING and fqz_comp. With --quality-bin, quality is
mapped through the chosen binning table (Illumina, ONT, or PacBio HiFi) before
coding — an explicit, opt-in lossy transform.