sequence-io/compressed-files/SKILL.md
Read, write, and index compressed sequence files (gzip, bzip2, xz, BGZF) with Biopython and bgzip/samtools. Use when working with .gz, .bz2, or .bgz sequence files, when random access into a compressed FASTA/FASTQ is needed, or when SeqIO.index/faidx/tabix rejects a plain .gz. Covers the BGZF-vs-gzip seekability asymmetry, the 'rt'-not-'rb' handle trap, virtual offsets, and gzip-to-BGZF conversion.
npx skillsauth add GPTomics/bioSkills bio-compressed-filesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference examples tested with: BioPython 1.83+, htslib/bgzip 1.19+, samtools 1.19+
Before using code patterns, verify installed versions match. If versions differ:
pip show <package> then help(module.function) to check signatures<tool> --version then <tool> --help to confirm flagsIf code throws ImportError, AttributeError, or TypeError, introspect the installed package and adapt the example to match the actual API rather than retrying.
Read, write, and randomly access gzip, bzip2, xz, and BGZF compressed sequence files.
"Read a compressed sequence file" -> Open a decompression handle in TEXT mode, then parse with the standard SeqIO interface.
gzip.open(path, 'rt') (Python stdlib)bz2.open(path, 'rt') (Python stdlib)lzma.open(path, 'rt') (Python stdlib)bgzf.open(path, 'rt') (BioPython) - BGZF input ONLY"Make a compressed file randomly accessible" -> Re-compress as BGZF, then index. Only BGZF supports SeqIO.index(), samtools faidx, and tabix on compressed data.
A BGZF (Blocked GNU Zip) file IS a valid gzip file - gunzip and zcat read it transparently. The reverse is FALSE: a plain .gz is NOT BGZF, so faidx/tabix/SeqIO.index reject it, and bgzf.open refuses to read it.
The reason is structural. BGZF is a series of concatenated gzip blocks, each <=64 KiB and independently decodable, so any record can be reached by seeking to its block. Plain gzip is one continuous DEFLATE stream with no block boundaries: reaching byte N means decompressing every byte before it (O(n)). Random access therefore REQUIRES BGZF; on a plain .gz, SeqIO.index() would re-decompress huge prefixes on every lookup, which is why Biopython forbids it outright (it raises rather than running slowly).
Consequences the agent must respect:
*.open(path, 'rt') handle.bgzf.open() reads BGZF input only. Pointing it at a plain .gz raises ValueError: A BGZF block should start with b'\x1f\x8b\x08\x04'.... To read plain gzip use gzip.open().import gzip
import bz2
import lzma
from Bio import SeqIO
from Bio import bgzf
Goal: Parse sequence records from a compressed file without decompressing to disk.
Approach: Open a decompression handle in TEXT mode ('rt'), then pass the handle to SeqIO.parse(). The parser is format-agnostic about the underlying compression.
with gzip.open('reads.fastq.gz', 'rt') as handle:
for record in SeqIO.parse(handle, 'fastq'):
print(record.id, len(record.seq))
Swap gzip.open for bz2.open (.bz2), lzma.open (.xz), or bgzf.open (.bgz) - the parse loop is identical.
SeqIO.parse() in Python 3 needs a TEXT handle that yields str. A binary 'rb' handle yields bytes and raises TypeError: a bytes-like object is required (or a decode error). Always use 'rt' for reading and 'wt' for writing through SeqIO. The low-level SimpleFastaParser/FastqGeneralIterator also require text handles.
Goal: Save records straight to a compressed file with no intermediate plain copy.
Approach: Open a compression handle in TEXT mode ('wt'), then pass it to SeqIO.write().
with gzip.open('output.fasta.gz', 'wt') as handle:
SeqIO.write(records, handle, 'fasta')
For an indexable result write BGZF instead:
with bgzf.open('output.fasta.bgz', 'wt') as handle:
SeqIO.write(records, handle, 'fasta')
BgzfWriter.close() (and the with block exit) automatically appends the 28-byte empty-block EOF marker that htslib tools check for; let the context manager close the handle.
Goal: Pull individual records by id from a large compressed file without a linear scan.
Approach: Compress as BGZF, then build a Biopython offset index. SeqIO.index() keeps virtual offsets in RAM; SeqIO.index_db() stores them in an on-disk SQLite index that persists across sessions and spans multiple files.
records = SeqIO.index('sequences.fasta.bgz', 'fasta')
target = records['gene_042'].seq
records.close()
# Persistent, multi-file, scales beyond RAM:
db = SeqIO.index_db('idx.sqlite', ['a.fasta.bgz', 'b.fasta.bgz'], 'fasta')
SeqIO.index() on a plain .gz raises ValueError: Gzipped files are not suitable for indexing, please use BGZF (blocked gzip format) instead. Convert first (below).
"Convert gzip to an indexable format" -> Decompress the gzip stream and re-compress it as BGZF.
CLI (fastest, htslib-native):
# Either decompress then bgzip in place...
gzip -d sequences.fasta.gz && bgzip sequences.fasta # -> sequences.fasta.gz (now BGZF)
# ...or stream without touching disk:
zcat sequences.fasta.gz | bgzip -@ 4 > sequences.fasta.bgz
# Index a BGZF FASTA for region extraction:
samtools faidx sequences.fasta.bgz # writes BOTH .fai and .gzi
samtools faidx sequences.fasta.bgz gene_042:1-200
samtools faidx on a BGZF FASTA writes TWO index files: .fai (record offsets in uncompressed coordinates) AND .gzi (the compressed-to-uncompressed block map). Deleting .gzi breaks region extraction even though .fai survives. Note that bgzip keeps the .gz extension, so a .gz may be EITHER plain gzip or BGZF - check with bgzip -t file.gz (tests for a valid BGZF stream) rather than trusting the suffix.
Pure-Python equivalent (no external tools):
import gzip
from Bio import SeqIO, bgzf
with gzip.open('input.fasta.gz', 'rt') as in_handle:
with bgzf.open('output.fasta.bgz', 'wt') as out_handle:
SeqIO.write(SeqIO.parse(in_handle, 'fasta'), out_handle, 'fasta')
Bio.bgzf exports open, BgzfReader, BgzfWriter, make_virtual_offset, split_virtual_offset.
A virtual offset packs two coordinates into one 64-bit integer: voffset = coffset << 16 | uoffset, where coffset is the byte position of the block start in the compressed file (top 48 bits) and uoffset is the offset within that block's decompressed data (low 16 bits - 16 bits suffices because a block holds at most 64 KiB).
vo = bgzf.make_virtual_offset(100, 7) # 6553607
coffset, uoffset = bgzf.split_virtual_offset(vo) # (100, 7)
with bgzf.open('sequences.fasta.bgz', 'rt') as handle:
handle.readline()
saved = handle.tell() # a VIRTUAL offset, not a byte position
handle.seek(saved) # jumps back to the same record
Critical caveat: virtual offsets may be COMPARED for ordering but NEVER SUBTRACTED to get a byte length - they live in two coordinate spaces (compressed position and within-block position), so vo2 - vo1 is meaningless. BgzfReader.tell() returns a virtual offset; seek() consumes one. Text mode forces latin1 and does no newline translation.
| Format | Extension | Random access | Speed | Ratio | Stdlib handle |
|--------|-----------|---------------|-------|-------|---------------|
| gzip | .gz | No (O(n) seek) | Fast | Good | gzip.open |
| BGZF | .bgz / .gz | Yes (block-seekable) | Fast, threadable | Good | bgzf.open (BioPython) |
| bzip2 | .bz2 | No | Slow | Better | bz2.open |
| xz / LZMA | .xz | No | Slowest | Best | lzma.open |
| Use case | Format | Why |
|----------|--------|-----|
| Sequential read/write, sharing | gzip | Universal, fast, every tool reads it |
| Need faidx/tabix/SeqIO.index | BGZF | Only seekable compressed format |
| BAM, tabix-indexed VCF/GFF/BED | BGZF | Required natively |
| Cold archive, max shrink, no random access | xz then bzip2 | Highest ratios, slowest |
| Random access into an existing plain .gz without re-bgzipping | pyfastx | Adds a seek-point index over the gzip stream |
pyfastx is the exception that gives random access into a PLAIN gzip FASTA/FASTQ: it builds a seek-point index (via zran from indexed_gzip) plus a SQLite .fxi/.fqi index alongside the file, a different strategy from faidx (which requires the stream itself to be BGZF). Use it when re-compressing a large gzipped genome to BGZF is not an option.
| Symptom | Cause | Fix |
|---------|-------|-----|
| TypeError: a bytes-like object is required | Handle opened 'rb' instead of 'rt' | Open compressed handles with 'rt'/'wt' for SeqIO |
| ValueError: Gzipped files are not suitable for indexing, please use BGZF... | SeqIO.index() on a plain .gz | Re-compress as BGZF (zcat ... | bgzip), then index |
| ValueError: A BGZF block should start with b'\x1f\x8b\x08\x04'... | bgzf.open() pointed at a plain gzip file | Read plain gzip with gzip.open(); reserve bgzf.open for BGZF |
| [bgzf] file ... not BGZF / not compressed with bgzip (htslib) | faidx/tabix given a plain .gz | Convert to BGZF first |
| [faidx] Failed to read ... / could not load .gzi | .gzi deleted next to a BGZF FASTA | Re-run samtools faidx to regenerate .fai + .gzi |
| gzip.BadGzipFile / OSError: Not a gzipped file | File is not gzip (wrong suffix / corrupt) | Verify with bgzip -t or file; match handle to real format |
| UnicodeDecodeError | Non-UTF8 bytes in a text handle | gzip.open(path, 'rt', encoding='latin-1') |
tools
End-to-end CLIP-seq pipeline from FASTQ to ENCODE-compliant binding sites, single-nucleotide crosslink maps, annotation, motifs, and (optionally) differential binding. Use when running the full Yeo lab eCLIP / iCLIP / iCLIP2 / iCLIP3 / irCLIP / PAR-CLIP analysis with SMInput control, protocol-specific UMI extraction, ENCODE STAR parameters, CLIPper or Skipper peak calling with stringent log2 FC and -log10 p thresholds, IDR rescue and self-consistency QC, and downstream motif registration with mCross or PEKA.
development
Detect, date, and contextualize whole-genome duplication (WGD / paleopolyploidy) events using wgd v2 (Chen et al 2024), KsRates (Sensalari 2022 substitution-rate-corrected Ks dating), DupGen_finder (Qiao 2019), MAPS (Li 2018 phylogenomic), POInT (Conant 2008 ordered-block), SLEDGe (2024 ML-based), Whale.jl (Bayesian DL+WGD), and synteny-anchored paranome construction. Use when identifying ancient polyploidy from Ks distributions and synteny block analysis, positioning WGD events relative to speciation, distinguishing tandem from segmental from WGD duplications, dating the 2R/3R vertebrate / fish / salmonid WGDs, building paranome and Ks-age mixture models, applying KsRates substitution-rate correction across lineages, or testing alternative biased-fractionation / dosage-balance models post-WGD.
tools
Build whole-genome alignments using Progressive Cactus (Armstrong 2020 reference-free clade-level WGA), Minigraph-Cactus (Hickey 2024 pangenome-aware), LASTZ chain/net (UCSC pipeline), MUMmer4 (Marçais 2018 pairwise), minimap2 -x asm5/10/20 (Li 2018 fast pairwise), AnchorWave (Song 2022 WGD-aware), and Mauve / progressiveMauve (bacterial). Operates the HAL toolkit (Hickey 2013) for downstream extraction including halSynteny, halLiftover, halBranchMutations, and hal2maf. Use when constructing multi-species alignments for comparative-annotation projection (TOGA), synteny detection, conservation analyses (phyloP / PhastCons), or pangenome graph construction; selecting between reference-free (Cactus) and reference-anchored (LASTZ chains/nets) approaches; tuning sensitivity for closely vs distantly related genomes; or producing HAL files for genome-wide downstream tools.
development
Detect syntenic blocks and structural rearrangements between genomes using MCScanX (Wang 2012), JCVI/MCScan (Tang 2008 Python), GENESPACE (Lovell 2022) for orthology-anchored riparian visualization, SyRI for structural variation, AnchorWave for sequence-level synteny, i-ADHoRe 3.0 for highly diverged species, SynNet for synteny networks, and ntSynt for multi-genome macrosynteny. Use when identifying collinear gene blocks across species, distinguishing macrosynteny from microsynteny, detecting inversions/translocations/duplications, anchoring orthology in WGD lineages, producing publication riparian plots, computing synteny block age via Ks (cross-references whole-genome-duplication), or running synteny-aware ortholog inference in polyploids.