Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Read/write SAM/BAM/CRAM, VCF/BCF, FASTA/FASTQ. Region queries, pileup, variant filtering, read groups. Python htslib wrapper exposing samtools/bcftools CLI. Use STAR/BWA for alignment; GATK/DeepVariant for variant calling.
.claude/skills/jaechang-hits-pysam-genomic-files/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-11 | ✗→✓ | ▲ Improved | 367% | 0% |
| case-21 | ✗→✓ | ▲ Improved | 676% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 172% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 173% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 267% | 0% |
Pysam provides a Pythonic interface to htslib for reading, manipulating, and writing genomic data files. It handles SAM/BAM/CRAM alignments, VCF/BCF variants, and FASTA/FASTQ sequences with efficient region-based random access. Also exposes samtools and bcftools as callable Python functions.
bashpip install pysam
Note: Requires htslib C library (bundled with pip install on most platforms). On some Linux systems, may need libhts-dev or equivalent. Index files (.bai, .tbi, .fai) required for random access — create with pysam.index(), pysam.tabix_index(), or pysam.faidx().
pythonimport pysam # Read BAM file, fetch reads in a region with pysam.AlignmentFile("sample.bam", "rb") as bam: for read in bam.fetch("chr1", 1000, 2000): print(f"{read.query_name}: pos={read.reference_start}, mapq={read.mapping_quality}") print(f"Total reads in region: {bam.count('chr1', 1000, 2000)}")
Read, query, and write aligned sequencing reads.
pythonimport pysam # Open BAM (binary) or SAM (text) file bam = pysam.AlignmentFile("sample.bam", "rb") # rb=read BAM, r=read SAM, rc=read CRAM # Fetch reads overlapping a region (requires .bai index) for read in bam.fetch("chr1", 10000, 20000): print(f"Name: {read.query_name}") print(f" Position: {read.reference_start}-{read.reference_end}") print(f" MAPQ: {read.mapping_quality}") print(f" CIGAR: {read.cigarstring}") print(f" Sequence: {read.query_sequence[:30]}...") break # Count reads in region (fast, no iteration needed) n_reads = bam.count("chr1", 10000, 20000) print(f"Reads in region: {n_reads}") # Filter reads by quality and flags for read in bam.fetch("chr1", 10000, 20000): if read.mapping_quality >= 30 and not read.is_unmapped and not read.is_duplicate: pass # Process high-quality, mapped, non-duplicate reads bam.close()
python# Write filtered reads to a new BAM file with pysam.AlignmentFile("input.bam", "rb") as inbam: with pysam.AlignmentFile("filtered.bam", "wb", header=inbam.header) as outbam: for read in inbam.fetch("chr1", 10000, 20000): if read.mapping_quality >= 30: outbam.write(read) # Index the output pysam.index("filtered.bam") print("Created filtered.bam + filtered.bam.bai")
Calculate per-base coverage statistics.
pythonimport pysam import numpy as np bam = pysam.AlignmentFile("sample.bam", "rb") # Pileup: per-base coverage with read-level detail for pileup_col in bam.pileup("chr1", 10000, 10100, min_mapping_quality=30): bases = [p.alignment.query_sequence[p.query_position] for p in pileup_col.pileups if not p.is_del and p.query_position is not None] print(f"Pos {pileup_col.reference_pos}: depth={pileup_col.nsegments}, bases={''.join(bases[:5])}") # Quick coverage count per region (faster than pileup) coverage = bam.count_coverage("chr1", 10000, 10100, quality_threshold=20) # Returns tuple of 4 arrays (A, C, G, T counts per position) total_cov = np.array(coverage).sum(axis=0) print(f"Mean coverage: {total_cov.mean():.1f}x") bam.close()
Read, query, and filter genetic variants.
pythonimport pysam # Open VCF/BCF file vcf = pysam.VariantFile("variants.vcf.gz") # Iterate all variants for record in vcf.fetch("chr1", 10000, 50000): print(f"{record.chrom}:{record.pos} {record.ref}>{','.join(record.alts or [])}") print(f" QUAL={record.qual}, FILTER={list(record.filter)}") print(f" INFO: {dict(record.info)}") # Access genotypes per sample for sample in record.samples: gt = record.samples[sample]["GT"] print(f" {sample}: GT={gt}") break vcf.close()
python# Filter variants and write to new VCF with pysam.VariantFile("variants.vcf.gz") as vcf_in: with pysam.VariantFile("filtered.vcf.gz", "wz", header=vcf_in.header) as vcf_out: for record in vcf_in: if record.qual and record.qual >= 30 and "PASS" in record.filter: vcf_out.write(record) pysam.tabix_index("filtered.vcf.gz", preset="vcf") print("Created filtered.vcf.gz + filtered.vcf.gz.tbi")
Random access to reference sequences and sequential reading of raw reads.
pythonimport pysam # FASTA: random access (requires .fai index) fasta = pysam.FastaFile("reference.fasta") seq = fasta.fetch("chr1", 10000, 10050) print(f"Sequence ({len(seq)} bp): {seq}") print(f"Available contigs: {fasta.references[:5]}") print(f"Contig lengths: {dict(zip(fasta.references[:3], fasta.lengths[:3]))}") fasta.close() # Create FASTA index if needed # pysam.faidx("reference.fasta")
python# FASTQ: sequential reading with pysam.FastxFile("reads.fastq.gz") as fq: for i, entry in enumerate(fq): print(f"Read {entry.name}: {len(entry.sequence)} bp, mean_qual={sum(entry.get_quality_array())/len(entry.sequence):.1f}") if i >= 2: break
Extract and filter reads by read group (essential for multi-sample BAM files).
pythonimport pysam bam = pysam.AlignmentFile("multisample.bam", "rb") # Access read group information from BAM header print("Read groups in file:") for rg_dict in bam.header.get("RG", []): print(f" ID: {rg_dict['ID']}, Sample: {rg_dict.get('SM', 'N/A')}, Library: {rg_dict.get('LB', 'N/A')}, Platform: {rg_dict.get('PL', 'N/A')}") # Get all samples in the BAM (from RG headers) samples = set() for rg_dict in bam.header.get("RG", []): if "SM" in rg_dict: samples.add(rg_dict["SM"]) print(f"Samples in BAM: {sorted(samples)}") bam.close()
python# Filter reads by read group ID def extract_reads_by_rg(bam_path, rg_id, output_path): """Extract all reads from a specific read group. WARNING: Uses fetch(until_eof=True), which scans the entire BAM sequentially. Multi-sample BAMs can be tens to hundreds of GB — this may be slow. For large files, prefer region-based filtering: for read in bam.fetch("chr1", start, end): ... Or use the samtools CLI equivalent (faster for one-off extractions): samtools view -b -r <rg_id> input.bam -o output.bam """ with pysam.AlignmentFile(bam_path, "rb") as bam_in: with pysam.AlignmentFile(output_path, "wb", header=bam_in.header) as bam_out: for read in bam_in.fetch(until_eof=True): if read.has_tag("RG") and read.get_tag("RG") == rg_id: bam_out.write(read) pysam.index(output_path) print(f"Extracted reads from RG:{rg_id} → {output_path}") extract_reads_by_rg("multisample.bam", "SAMPLE_001_LaneA", "sample001_laneA.bam")
pythonfrom collections import defaultdict import pysam # Count reads per sample def reads_per_sample(bam_path): """Count reads per sample from read group information. Two distinct "unknown" cases are tracked separately: - "no_sm_field": RG header entry exists but is missing the SM (sample name) field. - "undefined_rg": A read carries an RG tag not declared in the BAM header. """ counts = defaultdict(int) rg_to_sample = {} with pysam.AlignmentFile(bam_path, "rb") as bam: # Build RG → sample mapping from header for rg_dict in bam.header.get("RG", []): rg_id = rg_dict["ID"] # (a) RG header entry lacks SM field rg_to_sample[rg_id] = rg_dict.get("SM", "no_sm_field") # Count reads per resolved sample name for read in bam.fetch(until_eof=True): if read.has_tag("RG"): rg_id = read.get_tag("RG") # (b) Read's RG tag is not declared in the header sample = rg_to_sample.get(rg_id, "undefined_rg") counts[sample] += 1 return dict(counts) sample_counts = reads_per_sample("multisample.bam") for sample, count in sorted(sample_counts.items()): print(f" {sample}: {count:,} reads")
Call samtools and bcftools commands from Python.
pythonimport pysam # Sort BAM file pysam.sort("-o", "sorted.bam", "input.bam") # Index BAM pysam.index("sorted.bam") # View region as BAM pysam.view("-b", "-o", "region.bam", "sorted.bam", "chr1:1000-2000") # BCFtools: compress and index VCF pysam.bcftools.view("-O", "z", "-o", "output.vcf.gz", "input.vcf") pysam.tabix_index("output.vcf.gz", preset="vcf") # Error handling try: pysam.sort("-o", "output.bam", "nonexistent.bam") except pysam.SamtoolsError as e: print(f"samtools error: {e}")
CLI equivalents (for reference — use Python API in automated pipelines):
bash# These are equivalent to the Python calls above: samtools sort -o sorted.bam input.bam samtools index sorted.bam samtools view -b -o region.bam sorted.bam chr1:1000-2000 bcftools view -O z -o output.vcf.gz input.vcf
Critical: pysam uses 0-based, half-open coordinates (Python convention):
| System | Start | End | Example: "bases 1000-2000" | |--------|-------|-----|---------------------------| | pysam Python API | 0-based | exclusive | fetch("chr1", 999, 2000) | | samtools region string | 1-based | inclusive | fetch("chr1:1000-2000") | | VCF file format | 1-based | — | record.pos = 1-based, record.start = 0-based | | BED format | 0-based | exclusive | chr1\t999\t2000 |
| File Type | Index Extension | Create With | |-----------|----------------|-------------| | BAM | .bai | pysam.index("file.bam") | | CRAM | .crai | pysam.index("file.cram") | | FASTA | .fai | pysam.faidx("file.fasta") | | VCF.gz | .tbi | pysam.tabix_index("file.vcf.gz", preset="vcf") | | BCF | .csi | pysam.tabix_index("file.bcf", preset="bcf") |
Without an index, use fetch(until_eof=True) for sequential reading.
| Mode | Format | Direction | |------|--------|-----------| | "rb" | BAM (binary) | Read | | "r" | SAM (text) | Read | | "rc" | CRAM | Read | | "wb" | BAM | Write | | "w" | SAM | Write | | "wz" | VCF.gz (compressed) | Write |
Goal: Calculate coverage statistics for a set of target regions (e.g., exome capture targets).
pythonimport pysam import numpy as np def coverage_for_regions(bam_path, regions, min_mapq=30): """Calculate coverage stats for a list of (chrom, start, end) regions.""" results = [] with pysam.AlignmentFile(bam_path, "rb") as bam: for chrom, start, end in regions: cov = np.array(bam.count_coverage(chrom, start, end, quality_threshold=min_mapq)) total = cov.sum(axis=0) results.append({ "region": f"{chrom}:{start}-{end}", "mean_cov": total.mean(), "min_cov": total.min(), "pct_above_20x": (total >= 20).mean() * 100, }) return results regions = [("chr1", 10000, 10500), ("chr1", 20000, 20500), ("chr2", 5000, 5500)] stats = coverage_for_regions("sample.bam", regions) for s in stats: print(f"{s['region']}: mean={s['mean_cov']:.1f}x, min={s['min_cov']}x, ≥20x={s['pct_above_20x']:.1f}%")
Goal: For each variant in a VCF, count supporting reads from the BAM.
pythonimport pysam def annotate_variants_with_reads(vcf_path, bam_path, output_path): """Add read support counts to each variant.""" with pysam.VariantFile(vcf_path) as vcf_in: # Add INFO field to header vcf_in.header.add_line( '##INFO=<ID=READ_SUPPORT,Number=1,Type=Integer,Description="Reads supporting alt allele">' ) with pysam.VariantFile(output_path, "w", header=vcf_in.header) as vcf_out: with pysam.AlignmentFile(bam_path, "rb") as bam: for record in vcf_in: alt_count = 0 for col in bam.pileup(record.chrom, record.start, record.stop, min_mapping_quality=30, truncate=True): if col.reference_pos == record.start: for p in col.pileups: if (not p.is_del and p.query_position is not None and p.alignment.query_sequence[p.query_position] in (record.alts or [])): alt_count += 1 record.info["READ_SUPPORT"] = alt_count vcf_out.write(record) annotate_variants_with_reads("variants.vcf", "sample.bam", "annotated.vcf") print("Created annotated.vcf with READ_SUPPORT field")
| Parameter | Module | Default | Range / Options | Effect | |-----------|--------|---------|-----------------|--------| | mode string | AlignmentFile, VariantFile | — | "rb", "r", "rc", "wb", "w", "wz" | File format and read/write direction | | min_mapping_quality | pileup() | 0 | 0–60 | Filter reads below this MAPQ | | quality_threshold | count_coverage() | 15 | 0–40 | Minimum base quality to count | | truncate | pileup() | False | True/False | Truncate pileup to exact region (True) vs include overlapping reads (False) | | until_eof | fetch() | False | True/False | Read all records sequentially without index | | multiple_iterators | fetch() | False | True/False | Allow multiple simultaneous iterators (slight overhead) | | preset | tabix_index() | — | "vcf", "bed", "gff", "sam" | File format for tabix indexing |
with statement) for automatic file cleanup. Unclosed files can leak file descriptors..bai/.tbi/.fai files before queries.count() instead of iterating to count reads: bam.count("chr1", 1000, 2000) is much faster than sum(1 for _ in bam.fetch(...)).count_coverage() for coverage, pileup() for base-level detail: count_coverage() is faster when you only need depth numbers. Use pileup() only when you need per-read, per-base information.truncate=True in pileup: Without truncate=True, pileup() extends to the full extent of overlapping reads, which can be much larger than the requested region.pythonimport pysam def get_gene_sequence(fasta_path, chrom, start, end, strand="+"): """Extract gene sequence, reverse-complement if on minus strand.""" with pysam.FastaFile(fasta_path) as fasta: seq = fasta.fetch(chrom, start, end) if strand == "-": complement = str.maketrans("ACGTacgt", "TGCAtgca") seq = seq.translate(complement)[::-1] return seq seq = get_gene_sequence("reference.fasta", "chr1", 10000, 11000, strand="-") print(f"Gene sequence ({len(seq)} bp): {seq[:50]}...")
pythonimport pysam def bam_summary(bam_path): """Quick summary statistics for a BAM file.""" with pysam.AlignmentFile(bam_path, "rb") as bam: stats = {"total": 0, "mapped": 0, "unmapped": 0, "duplicates": 0, "mapq_ge30": 0} for read in bam.fetch(until_eof=True): stats["total"] += 1 if read.is_unmapped: stats["unmapped"] += 1 else: stats["mapped"] += 1 if read.is_duplicate: stats["duplicates"] += 1 if read.mapping_quality >= 30: stats["mapq_ge30"] += 1 return stats summary = bam_summary("sample.bam") for k, v in summary.items(): print(f" {k}: {v:,}")
| Problem | Cause | Solution | |---------|-------|----------| | ValueError: could not open alignment file | Missing file or wrong mode string | Check file path; use "rb" for BAM, "r" for SAM | | ValueError: fetch called on bamfile without index | No .bai index file | Run pysam.index("file.bam") first | | Region returns unexpected reads | Reads overlapping boundaries are included | Use truncate=True in pileup() or filter by read.reference_start >= start | | Coordinate off-by-one errors | Mixing 0-based (pysam) with 1-based (VCF, samtools) | See Key Concepts coordinate table; record.pos is 1-based, record.start is 0-based | | PileupProxy accessed after iterator finished | Pileup iterator went out of scope | Store needed data from pileup columns immediately, don't save PileupProxy references | | SamtoolsError from CLI calls | Invalid arguments or missing input | Wrap in try/except pysam.SamtoolsError; check samtools docs for argument syntax | | Very slow iteration | Iterating all reads without region query | Use fetch("chr1", start, end) for targeted queries; use indexed files | | Read group filter returns 0 reads | RG tag missing or wrong ID specified | Verify RG tag exists: read.has_tag("RG"); list available RGs from bam.header.get("RG", []) |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 17,473 | 16,488 | -6% | 1 | 1 | 0% | 3,489 | 9,495 | +172% | 0 | 0 | — |
case-02 | pass→pass | 14,269 | 8,902 | -38% | 1 | 1 | 0% | 2,835 | 7,727 | +173% | 0 | 0 | — |
case-03 | pass→pass | 10,067 | 5,550 | -45% | 1 | 1 | 0% | 1,894 | 6,955 | +267% | 0 | 0 | — |
case-04 | pass→pass | 9,134 | 7,422 | -19% | 1 | 1 | 0% | 1,790 | 7,388 | +313% | 0 | 0 | — |
case-05 | pass→pass | 7,258 | 5,489 | -24% | 1 | 1 | 0% | 1,551 | 7,044 | +354% | 0 | 0 | — |
case-06 | pass→pass | 5,445 | 3,352 | -38% | 1 | 1 | 0% | 1,013 | 6,559 | +547% | 0 | 0 | — |
case-07 | pass→pass | 7,004 | 5,082 | -27% | 1 | 1 | 0% | 1,369 | 6,901 | +404% | 0 | 0 | — |
case-08 | pass→pass | 10,233 | 5,448 | -47% | 1 | 1 | 0% | 1,967 | 6,964 | +254% | 0 | 0 | — |
case-09 | pass→pass | 8,242 | 6,698 | -19% | 1 | 1 | 0% | 1,694 | 7,362 | +335% | 0 | 0 | — |
case-10 | pass→pass | 4,761 | 3,012 | -37% | 1 | 1 | 0% | 931 | 6,421 | +590% | 0 | 0 | — |
case-11 | fail→pass | 7,230 | 3,407 | -53% | 1 | 1 | 0% | 1,389 | 6,485 | +367% | 0 | 0 | — |
case-12 | pass→pass | 3,756 | 3,855 | +3% | 1 | 1 | 0% | 720 | 6,730 | +835% | 0 | 0 | — |
case-13 | pass→pass | 7,940 | 4,143 | -48% | 1 | 1 | 0% | 1,581 | 6,633 | +320% | 0 | 0 | — |
case-14 | pass→pass | 9,454 | 4,961 | -48% | 1 | 1 | 0% | 2,049 | 6,894 | +236% | 0 | 0 | — |
case-15 | pass→pass | 7,022 | 3,155 | -55% | 1 | 1 | 0% | 1,385 | 6,507 | +370% | 0 | 0 | — |
case-16 | pass→pass | 4,925 | 2,802 | -43% | 1 | 1 | 0% | 921 | 6,400 | +595% | 0 | 0 | — |
case-17 | pass→pass | 5,983 | 3,128 | -48% | 1 | 1 | 0% | 1,190 | 6,528 | +449% | 0 | 0 | — |
case-18 | pass→pass | 7,403 | 6,127 | -17% | 1 | 1 | 0% | 1,535 | 7,288 | +375% | 0 | 0 | — |
case-19 | pass→pass | 6,182 | 3,961 | -36% | 1 | 1 | 0% | 1,318 | 6,784 | +415% | 0 | 0 | — |
case-20 | pass→pass | 3,591 | 2,490 | -31% | 1 | 1 | 0% | 665 | 6,382 | +860% | 0 | 0 | — |
case-21 | fail→pass | 4,266 | 3,419 | -20% | 1 | 1 | 0% | 845 | 6,560 | +676% | 0 | 0 | — |
case-22 | pass→pass | 4,513 | 2,332 | -48% | 1 | 1 | 0% | 833 | 6,390 | +667% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.