mafutils is a command-line toolkit for indexing, extracting, and summarizing
MAF (Multiple Alignment Format) files.
It currently provides six commands:
mafutils indexmafutils infomafutils fetchmafutils statsmafutils gcmafutils validate
This project was developed with significant assistance from large language models (GPT-5 / Codex, Claude Sonnet).
mafutils/lib/bgzf.py is vendored from Biopython
(BSD 3-Clause License option) rather than pulled in as a dependency — see the
notice at the top of that file and THIRD_PARTY_LICENSES.md
for full attribution and license text.
pip install mafutilsThis installs the mafutils command.
Show top-level help:
mafutils --helpShow the installed version:
mafutils --version # also accepts -version, -v, -VCreate block and scaffold indexes for a MAF (defaults to
input.maf.block.idx / input.maf.scaffold.idx if output paths are omitted):
mafutils index input.mafPrint a quick overview of an indexed MAF (sub-second on any size):
mafutils info input.mafFetch trimmed MAF regions from a BED file (index defaults to
input.maf.block.idx):
mafutils fetch input.maf regions.bed -o outdirFetch FASTA output instead of MAF:
mafutils fetch input.maf regions.bed -o outdir -f -fh species-coords-idExtract full scaffolds using a scaffold index:
mafutils fetch input.maf scaffolds.bed -m scaffold -o outdirSummarize an indexed MAF:
mafutils stats input.maf -o summary/exampleCalculate per-species GC content:
mafutils gc input.maf -o summary/exampleCheck whether an index is still trustworthy (see Index Integrity below):
mafutils validate input.mafmafutils supports three MAF compression types, detected automatically:
| Compression | Random access | Notes |
|---|---|---|
none (plain .maf) |
Real, parallelizable | Baseline behavior |
bgzip (BGZF, e.g. produced by bgzip) |
Real, parallelizable | Detected distinctly from plain gzip via its BC extra-field marker; uses virtual offsets for true random access |
gzip (plain .gz) |
Not available | Python's gzip module can't jump to an arbitrary offset without decompressing everything before it, so fetch/stats/gc all fall back to a single sequential pass regardless of --processes, with a warning |
If you plan to use --processes > 1 against compressed input, compress with
bgzip rather than plain gzip to get real parallel speedup.
No extra dependency for bgzip: BGZF support is vendored in
mafutils/lib/bgzf.py rather than depending on the biopython package —
see DEVELOPMENT.md for why, and THIRD_PARTY_LICENSES.md
for attribution.
mafutils index writes a one-line #-prefixed header at the top of every
index recording the MAF filename, compression, file size, modification
time, and a content hash (MD5) of the exact file it was built from
(backward compatible — parsers already skip # lines; computed for free,
in the same read already used to find block offsets). Index paths default
to <MAF_FILE>.block.idx / <MAF_FILE>.scaffold.idx next to the MAF file,
but can always be overridden explicitly.
An index only ever means anything paired with the exact file it was built
from — if that file gets regenerated (e.g. a rerun alignment pipeline) with
the same name and compression, an index built against the old version would
otherwise look valid while pointing at stale offsets. fetch/stats/gc
check for this every run, at two tiers:
- Default (no flag, cheap — no file read): compression mismatch is an
error; file size mismatch is an error (a strong signal — files
differing in size are definitely not the same content); modification
time mismatch is only a warning (a weak signal — files get
touched/copied without content changing). A completely missing header
(an index built by an older
mafutils) only warns, since there's nothing to check. --verify-hash(opt-in, onfetch/stats/gc): compares against the stored content hash instead — authoritative, but requires reading the whole file to recompute it, so it's not the default.
For a "check once, trust thereafter" workflow instead of passing
--verify-hash on every invocation, use mafutils validate (see below).
Create block and scaffold indexes for a MAF file.
mafutils index MAF_FILE [BLOCK_INDEX] [SCAFFOLD_INDEX]Arguments:
| Argument | Description |
|---|---|
MAF_FILE |
Input MAF file (.maf, .maf.gz, or bgzip-compressed .maf) |
BLOCK_INDEX |
Output block index path (default: <MAF_FILE>.block.idx) |
SCAFFOLD_INDEX |
Output scaffold index path (default: <MAF_FILE>.scaffold.idx) |
Options:
| Option | Description |
|---|---|
--progress-interval |
Seconds between progress lines (default and minimum 60); 0 turns them off |
Print a quick overview of a MAF without reading it.
mafutils info [OPTIONS] MAF_FILEMAF : cricetid-15spec.Mus_musculus.nodupes.maf
size : 42.70 GB (none)
index : matches MAF (size + mtime)
ref scaffolds : 61
blocks : 8,094,203
ref bases : 2,728,222,451
aln columns : 3,031,569,326
seq lines : 103,950,448 (all species, including the reference)
max seqs/block : 15
species : 15 (sampled from the first 1,000 block(s) -- possibly incomplete)
Arvicola_amphibius, Cricetulus_griseus, Cricetus_cricetus, ...
For exact per-species presence across the whole file, run `mafutils stats`.
Measured on that 42.70 GB file: 1.3 s, or 0.7 s with --sample-blocks 0.
| Option | Description |
|---|---|
--index, -i |
Block index (default: <MAF_FILE>.block.idx) |
--scaffold-index |
Scaffold index (default: <MAF_FILE>.scaffold.idx) |
--sample-blocks |
Blocks to sample for species names (default 1000); 0 disables the sample and reads no MAF data at all |
--all-species |
List every sampled species instead of the first 20 |
Where the numbers come from. mafutils index records the block counts in
the index header, so info normally reads nothing but that one line. Three
tiers, and the MAF is never scanned for these counts:
- Index built by mafutils 0.7 or later — instant, header only.
- Older index — falls back to streaming the index (~52 MB/s, so ~9 s for a 445 MB index but ~12 min for a 35 GB one), with a warning naming the index size and recommending a rebuild.
- No index — reports what needs none and points at
mafutils index.
species is a sample, not a count. Species names are not recorded in the
index, and collecting them during indexing would mean parsing every
non-reference s line — measured at 2.06x slower indexing. Sampling the first
1,000 blocks costs milliseconds instead. On a whole-genome MAF those blocks all
sit at the start of the first scaffold, so a species absent from that region is
missed. Use mafutils stats for an exhaustive per-species breakdown,
including each species' source scaffolds and their lengths.
Column meanings. ref scaffolds and ref bases are measured on the
reference sequence. seq lines counts every s line including the
reference. aln columns is the block width, gap columns included, which all
species share.
Fetch regions or scaffolds from a MAF using an existing index.
mafutils fetch [OPTIONS] MAF_FILE BED_FILEArguments:
| Argument | Description |
|---|---|
MAF_FILE |
Input MAF file (.maf, .maf.gz, or bgzip-compressed .maf) |
BED_FILE |
BED file with regions or scaffold names |
Options:
| Option | Description |
|---|---|
--index, -i |
Index file, block-level or scaffold-level matching --mode. Required — if omitted, looked up at <MAF_FILE>.block.idx or <MAF_FILE>.scaffold.idx by default; errors if not found there. |
--basename, -b |
Output basename strategy: id (the BED's 4th column, which must be present and unique), coords, or count |
--output, -o |
Output directory or output filename in single-output mode |
--fasta, -f |
Write FASTA instead of MAF |
--fasta-header, -fh |
FASTA header format: species-coords-id, species-coords, or species-only. The coords-bearing modes always include the source scaffold (species.scaffold); species-only is deliberately bare so headers match tree tip labels |
--expected-species |
Comma-separated expected species list for FASTA filling |
--expected-species-file |
File with one expected species name per line |
--fasta-dedupe |
FASTA duplicate handling: none or most-seq |
--processes, -p |
Number of worker processes (see Compression above — plain gzip always runs single-process) |
--mode, -m |
Fetch mode: block or scaffold |
--scaffold-subdirs |
Group output files into subfolders named by reference scaffold (<output>/<scaffold>/<basename>) instead of one flat directory |
--loci-table |
Also write <output>/maf_fetch_loci.tsv (see below). Requires --fasta |
--verbose |
Emit warning lines from each completed batch |
--profile |
Log internal timing breakdowns |
--verify-hash |
Verify the index's stored content hash against the MAF file (see Index Integrity above) |
--progress-interval |
Seconds between progress lines (default and minimum 60); 0 turns them off |
BED pre-check. In block mode, fetch checks the whole BED before doing
any work and stops with one error listing every problem: unusable lines
(malformed, or end ≤ start), regions without a name under -b id, and regions
that would overwrite each other's output file (a repeated name under -b id,
or a repeated region under -b coords). # comments and track/browser
lines are skipped.
Outputs:
- One file per region (
.maf, or.fawith--fasta), named by--basename, in--output(or in per-scaffold subfolders with--scaffold-subdirs). Scaffold mode writes one<scaffold>.mafper scaffold instead. maf_fetch_summary.tsv: one row per BED region, in genomic order.maf_fetch_loci.tsv(with--loci-table): one row per region, species and contributing block.
Both tables describe how each species' sequence for a region is put together. A region's alignment blocks are contiguous in the reference, but a species' pieces of those blocks ("chunks") need not be contiguous in its own genome, so each species is given a class:
| class | meaning |
|---|---|
single |
one chunk |
contiguous |
several chunks, adjacent in the species' own sequence |
split |
chunks separated by sequence the aligner did not align |
multi_strand |
chunks on both strands |
multi_scaffold |
chunks from more than one source scaffold |
no_bases |
present in the alignment, but all gaps in this region |
Only single and contiguous are one locus: for those, the FASTA header's
span equals the bases written. For the others it never does; the span is a
bounding box over chunks that are separated, on both strands, or on different
scaffolds.
For background on these classes, filtering recipes, and an example of building a per-species BED, see docs/fetch-outputs.md.
| column | meaning |
|---|---|
scaffold, start, end, basename |
the BED region and its output file name |
ref.n.overlapping.blocks |
alignment blocks the region overlaps |
ref.block.bases |
reference bases in the region covered by blocks; less than end - start when part of the region is unaligned (logged as a ref-coverage-gap warning) |
n.sequences |
sequences in the region's FASTA file, including --expected-species filled with Ns; NA for MAF output |
n.species |
species contributing to the region |
n.single, n.contiguous, n.split, n.multi.strand, n.multi.scaffold, n.no.bases |
species in each class |
split.bases.max |
the most unaligned bases in any one split species |
split.gap.median |
median of every individual gap between chunks |
split.boundaries |
block boundaries where at least one species splits |
split.max.boundary.n.species |
the most species splitting at a single boundary |
split.max.boundary.gap.spread |
max - min of those species' gaps at that boundary |
The ref. columns describe the reference only. They say nothing about whether
a species' sequence is contiguous; the n.* and split.* columns do.
mafutils fetch input.maf regions.bed -f -fh species-coords-id --loci-table -o out/| column | meaning |
|---|---|
region_scaffold, region_start, region_end, region_id |
the requested reference region |
species, src_scaffold, src_size |
that species' source sequence for this chunk |
chunk_start, chunk_size, chunk_strand |
the chunk, exactly as the MAF states it |
chunk_index, n_chunks |
position among that species' chunks for this region |
block_index |
which block (ordinal within the region) this chunk came from |
gap_to_next |
signed gap to that species' next chunk; . for the last |
class |
the species' class for this region (table above); the same on all its rows |
max_gap |
largest signed gap between consecutive chunks; 0 for single/contiguous, . where undefined |
Coordinates are exactly as in the MAF, so on the - strand they count from the
end of the source sequence. src_size is included so forward coordinates are
one subtraction away: src_size - (chunk_start + chunk_size) to
src_size - chunk_start. The table has roughly (regions × species) rows, so it
is off by default.
Summarize an indexed MAF at overall, species, and block levels.
mafutils stats [OPTIONS] MAF_FILE [INDEX_FILE]Arguments:
| Argument | Description |
|---|---|
MAF_FILE |
Input MAF file (.maf, .maf.gz, or bgzip-compressed .maf) |
INDEX_FILE |
Block index produced by mafutils index. Required — if omitted, looked up at <MAF_FILE>.block.idx by default; errors if not found there. |
Options:
| Option | Description |
|---|---|
--output-prefix, -o |
Output prefix/path |
--processes, -p |
Number of worker processes |
--chunk-size |
Blocks per worker task |
--no-block-table |
Skip writing the per-block table |
--expected-species |
Comma-separated species names for exact missing lists |
--expected-species-file |
File with one species name per line |
--verify-hash |
Verify the index's stored content hash against the MAF file (see Index Integrity above) |
--log-level |
Logging level: DEBUG, INFO, WARNING, ERROR |
--progress-interval |
Seconds between progress lines (default and minimum 60); 0 turns them off |
--html-dashboard |
Write an HTML summary dashboard |
--dashboard-top-species |
Number of species shown in dashboard bar plots |
--dashboard-max-block-points |
Maximum block rows sampled for dashboard plots |
--dashboard-top-scaffolds |
Largest scaffolds listed per species in the dashboard (default 10). The TSV is never capped. |
Outputs:
| File | Contents |
|---|---|
<prefix>.overall.tsv |
One metric per line |
<prefix>.species.tsv |
One row per species |
<prefix>.species_scaffolds.tsv |
One row per (species, source scaffold) present in the MAF — see below |
<prefix>.block.tsv |
One row per block (skip with --no-block-table) |
<prefix>.dashboard.html |
Self-contained summary dashboard (with --html-dashboard) |
stats reads every s line of every block, so it also records which source
scaffolds each species contributes and how long they are. Nothing else reports
this for non-reference species: the index never parses non-reference lines, and
info only samples species names.
Scope: scaffolds present in the MAF, not the whole assembly. A scaffold
appears only if at least one of its bases aligned to the reference. Unaligned
scaffolds — unplaced contigs, sequence outside the aligned regions — never
appear in the MAF, so stats cannot see them. Each row's length is exact (it is
the MAF's srcSize, which the aligner takes from the assembly), but the set
of scaffolds is limited to those in the MAF. The per-species totals therefore
err in opposite directions depending on the comparison:
| Compared with | Direction | Why |
|---|---|---|
| the true assembly size | undercounts | unaligned scaffolds contribute nothing |
aligned bases (nongaps_total) |
overcounts | a barely-aligned 200 Mb scaffold contributes all 200 Mb |
The gap to the true assembly cannot be measured from the MAF alone — it needs
the assembly's own scaffold list. It is small for chromosome-level assemblies
and can be large for fragmented ones. Do not read scaffold_bases_in_maf as an
assembly-size estimate.
<prefix>.species_scaffolds.tsv, sorted by species, then largest scaffold
first:
| Column | Meaning |
|---|---|
species |
Species (the part of the MAF src before the first .) |
scaffold |
Source scaffold (the part after it); . if the src has no . |
src_size |
Full length of that scaffold in that species' assembly (MAF srcSize); . if it did not parse as an integer |
blocks |
Blocks in which this scaffold appears |
aligned_bases |
Non-gap bases from this scaffold across all blocks |
aligned_fraction |
aligned_bases / src_size. Not capped at 1.0: a region aligned into several blocks (duplicated or paralogous alignments) can push it past 1, and that is worth seeing |
Rows are keyed by (species, scaffold), never by scaffold name alone —
human.chr4 and chimp.chr4 are different sequences that happen to share a
name.
Two columns are appended to the end of <prefix>.species.tsv, so no
existing column moves:
| Column | Meaning |
|---|---|
scaffolds_in_maf |
Distinct source scaffolds of this species present in the MAF |
scaffold_bases_in_maf |
Sum of those scaffolds' full lengths |
Per species, the aligned_bases of its scaffolds sum exactly to its
nongaps_total, and their blocks sum to at least its blocks_present
(more when a block holds several copies of the species).
The dashboard adds both columns to its species table and a collapsed section per species listing the largest scaffolds. Each section's summary line reads "showing N of M scaffolds", so a truncated list is visible before expanding it.
srcSize integrity. If one species.scaffold reports two different
srcSize values, the MAF is internally inconsistent and stats exits with an
error naming the scaffold and both values — picking one would hide the problem.
An s line whose srcSize is not an integer still counts toward every other
statistic. Its scaffold takes its length from any other line of that scaffold
that does parse, and is written with src_size . only if none does. The
number of such lines is logged as a warning and written to overall.tsv as
scaffold_size_parse_errors.
Calculate per-species GC content from a MAF file. Writes <prefix>.gc.csv
(one species,gc row per species, GC as a 0-1 fraction) and
<prefix>.gc.mean.txt (a single float: the unweighted mean of the gc
column across species).
GC is computed as (G+C) / (A+C+G+T), case-insensitive; gaps, N, and other
ambiguity codes are excluded from both the numerator and denominator.
mafutils gc MAF_FILE [INDEX_FILE] [OPTIONS]Arguments:
| Argument | Description |
|---|---|
MAF_FILE |
Input MAF file (.maf, .maf.gz, or bgzip-compressed .maf) |
INDEX_FILE |
Block index produced by mafutils index. Not required — gc works fine without one. If given, or found at <MAF_FILE>.block.idx by default, it can speed things up via --processes > 1 on uncompressed/bgzip MAFs. |
Options:
| Option | Description |
|---|---|
--output-prefix, -o |
Output prefix/path |
--processes, -p |
Number of worker processes (see note below) |
--chunk-size |
Blocks per worker task in parallel mode |
--verify-hash |
Verify the index's stored content hash against the MAF file (see Index Integrity above; only checked when an index is actually used) |
--log-level |
Logging level: DEBUG, INFO, WARNING, ERROR |
--progress-interval |
Seconds between progress lines (default and minimum 60); 0 turns them off |
Sequential vs. parallel processing: GC counting is cheap per base, so the bottleneck is I/O/decompression rather than CPU. There are two processing paths:
- Sequential (default): streams through the MAF once, top to bottom, with
no index required. Used whenever no index is found, the MAF is plain-gzip
compressed, or
--processesis1. - Parallel: requires both an index and
--processes > 1, and is used on uncompressed or bgzip-compressed MAFs. It chunks index entries across worker processes that each seek to their own byte ranges (real random access for both of these types), the same architecture asmafutils stats.
Parallel mode is skipped for plain-gzip input even if requested: the index's byte offsets are positions in the decompressed stream, so each worker would have to redundantly re-decompress everything up to its own start point, making things slower rather than faster. A warning is logged and the tool falls back to the sequential path instead of silently ignoring the request — use a bgzip-compressed MAF instead if you need real parallel speedup on compressed input.
Checks a MAF file against its block index's stored header (size, mtime, and
content hash) and reports whether the index can still be trusted — a
"check once, trust thereafter" alternative to passing --verify-hash on
every fetch/stats/gc invocation. Also looks up the scaffold index at
its default location (<MAF_FILE>.scaffold.idx) and cross-checks its
header against the block index's — mafutils index always writes both
from the same size/mtime/hash in the same run, so any difference means the
two indexes weren't built together (e.g. only one was rebuilt) and
shouldn't be trusted as a matched pair.
mafutils validate MAF_FILE [INDEX_FILE]Arguments:
| Argument | Description |
|---|---|
MAF_FILE |
Input MAF file (.maf, .maf.gz, or bgzip-compressed .maf) |
INDEX_FILE |
Block index produced by mafutils index. Required — if omitted, looked up at <MAF_FILE>.block.idx by default; errors if not found there. The scaffold index is always looked up at <MAF_FILE>.scaffold.idx (no separate argument for it). |
Prints one line per checked field (compression, size, hash, mtime, and the block/scaffold header pair) with its match/mismatch status, then an overall verdict. Exit codes:
| Exit code | Verdict | Meaning |
|---|---|---|
0 |
VERIFIED | The index's stored hash matches this file exactly, and the block/scaffold index headers agree. |
1 |
MISMATCH | A conclusive difference was found (compression, size, or hash against the MAF file; or the block and scaffold index headers disagree) — rebuild the index(es). |
2 |
UNVERIFIABLE | Nothing contradicts, but there's no stored hash to be fully sure (the index predates this feature), or the scaffold index is missing/headerless so the pair couldn't be cross-checked. |
For development setup (installing from a source checkout), running the test
suite, and internal implementation notes, see DEVELOPMENT.md.