Skill Tracer
Skills › Scientific Agent Skills › pysam › variant_files.md

variant_files.md

416 lines · skills/pysam/references/variant_files.md at 5f2bc03

Open on GitHub
1# Variant Files: VCF and BCF
2
3This reference targets pysam 0.24.1. Numeric pysam coordinates are 0-based,
4half-open even though VCF text uses a 1-based `POS`.
5

Variant Files: VCF and BCF

This reference targets pysam 0.24.1. Numeric pysam coordinates are 0-based, half-open even though VCF text uses a 1-based POS.

Raw lines 1 to 5
6## Open and Iterate
7
8`VariantFile` auto-detects VCF, BGZF-compressed VCF, and BCF input:
9
10```python
11import pysam
13with pysam.VariantFile("cohort.vcf.gz", threads=4) as variants:
14 for record in variants:
15 print(record.contig, record.pos, record.ref, record.alts)
16```
18Common modes:
20- `r`: VCF input
21- `rb`: BCF input; input auto-detection usually makes an explicit mode
22 unnecessary
23- `w`: VCF output; a `.vcf.gz` suffix selects BGZF-compressed VCF
24- `wb`: compressed BCF output
25- `wz`: explicitly BGZF-compressed VCF output
26- `wb0`: BCF in BGZF blocks with compression level zero
28The advertised `wbu` mode is rejected as conflicting format specifiers in
290.24.1; use `wb0`. Do not mistake level-zero BGZF for a raw unframed BCF stream.
31Writing requires a `VariantHeader`.
33Useful constructor options:
35- `index_filename=` for a nonstandard or remote index
36- `drop_samples=True` to skip sample data
37- `threads=` for compression/decompression
38- `ignore_truncation=True` for missing BGZF EOF markers; not compatible with
39 `threads > 1`

Open and Iterate

VariantFile auto-detects VCF, BGZF-compressed VCF, and BCF input:

import pysam

with pysam.VariantFile("cohort.vcf.gz", threads=4) as variants:
    for record in variants:
        print(record.contig, record.pos, record.ref, record.alts)

Common modes:

  • r: VCF input
  • rb: BCF input; input auto-detection usually makes an explicit mode unnecessary
  • w: VCF output; a .vcf.gz suffix selects BGZF-compressed VCF
  • wb: compressed BCF output
  • wz: explicitly BGZF-compressed VCF output
  • wb0: BCF in BGZF blocks with compression level zero

The advertised wbu mode is rejected as conflicting format specifiers in 0.24.1; use wb0. Do not mistake level-zero BGZF for a raw unframed BCF stream.

Writing requires a VariantHeader.

Useful constructor options:

  • index_filename= for a nonstandard or remote index
  • drop_samples=True to skip sample data
  • threads= for compression/decompression
  • ignore_truncation=True for missing BGZF EOF markers; not compatible with threads > 1
Raw lines 6 to 40
41## Coordinates and Region Queries
43```python
44with pysam.VariantFile("cohort.vcf.gz") as variants:
45 # Numeric coordinates: [999_999, 2_000_000)
46 numeric = variants.fetch("chr1", 999_999, 2_000_000)
48 # Region string: 1-based inclusive
49 text = variants.fetch(region="chr1:1000000-2000000")
50```
52`VariantFile.fetch()` explicitly defines numeric `start`/`stop` as 0-based,
53half-open. Region strings follow samtools notation.
55For each `VariantRecord`:
57- `contig` / `chrom`: contig name
58- `pos`: 1-based VCF position
59- `start`: 0-based inclusive position
60- `stop`: 0-based exclusive record end
61- `rlen`: reference span
63Do not subtract one from a numeric `start` passed to `fetch()`. Use
64`record.start` when integrating with BAM, BED, or FASTA APIs.
66Random access requires:
68- BGZF VCF: `.tbi` or `.csi`
69- BCF: `.csi`
71An unindexed VCF.gz or BCF can still be read sequentially with normal
72iteration. In 0.24.1, `fetch()` with no contig/region rewinds to the start of
73records and returns a sequential iterator without requiring an index. Normal
74`for record in variants` iteration starts at the current position. This differs
75from the broad index-driven wording in the upstream fetch docstring; verified
76against tagged source and native VCF/BCF tests.
78Use the canonical `contig=` keyword. In 0.24.1, supplying only the legacy
79`reference=` alias takes the no-contig sequential branch and ignores the
80intended region bounds.
82For simultaneous iterators, `VariantFile.fetch()` uses `reopen=True`:
84```python
85first = variants.fetch("chr1", reopen=True)
86second = variants.fetch("chr2", reopen=True)
87```

Coordinates and Region Queries

with pysam.VariantFile("cohort.vcf.gz") as variants:
    # Numeric coordinates: [999_999, 2_000_000)
    numeric = variants.fetch("chr1", 999_999, 2_000_000)

    # Region string: 1-based inclusive
    text = variants.fetch(region="chr1:1000000-2000000")

VariantFile.fetch() explicitly defines numeric start/stop as 0-based, half-open. Region strings follow samtools notation.

For each VariantRecord:

  • contig / chrom: contig name
  • pos: 1-based VCF position
  • start: 0-based inclusive position
  • stop: 0-based exclusive record end
  • rlen: reference span

Do not subtract one from a numeric start passed to fetch(). Use record.start when integrating with BAM, BED, or FASTA APIs.

Random access requires:

  • BGZF VCF: .tbi or .csi
  • BCF: .csi

An unindexed VCF.gz or BCF can still be read sequentially with normal iteration. In 0.24.1, fetch() with no contig/region rewinds to the start of records and returns a sequential iterator without requiring an index. Normal for record in variants iteration starts at the current position. This differs from the broad index-driven wording in the upstream fetch docstring; verified against tagged source and native VCF/BCF tests.

Use the canonical contig= keyword. In 0.24.1, supplying only the legacy reference= alias takes the no-contig sequential branch and ignores the intended region bounds.

For simultaneous iterators, VariantFile.fetch() uses reopen=True:

first = variants.fetch("chr1", reopen=True)
second = variants.fetch("chr2", reopen=True)
Raw lines 41 to 88
89## Header Model
91```python
92with pysam.VariantFile("cohort.vcf.gz") as variants:
93 header = variants.header
94 print(list(header.samples))
96 for name, contig in header.contigs.items():
97 print(name, contig.length)
99 for name, field in header.info.items():
100 print(name, field.number, field.type, field.description)
101```
103Important collections:
105- `header.contigs`
106- `header.samples`
107- `header.filters`
108- `header.info`
109- `header.formats`
110- `header.records`
112INFO and FORMAT values can only be interpreted safely when their definitions
113exist in the header. Do not infer missing Number/Type metadata.

Header Model

with pysam.VariantFile("cohort.vcf.gz") as variants:
    header = variants.header
    print(list(header.samples))

    for name, contig in header.contigs.items():
        print(name, contig.length)

    for name, field in header.info.items():
        print(name, field.number, field.type, field.description)

Important collections:

  • header.contigs
  • header.samples
  • header.filters
  • header.info
  • header.formats
  • header.records

INFO and FORMAT values can only be interpreted safely when their definitions exist in the header. Do not infer missing Number/Type metadata.

Raw lines 89 to 114
115## Record Fields
117```python
118for record in variants:
119 alleles = record.alleles # (REF, ALT1, ALT2, ...)
120 alt_alleles = record.alts # tuple or None
121 identifier = record.id # string or None
122 quality = record.qual # float or None
123 filters = tuple(record.filter.keys())
124```
126`record.alleles_variant_types` classifies alleles with values such as `REF`,
127`SNP`, `MNP`, `INDEL`, `BND`, `OVERLAP`, and `OTHER`.
129Handle non-simple alleles:
131- multiallelic records can have several ALT alleles
132- symbolic alleles include `<DEL>`, `<DUP>`, `<INS>`, and others
133- paired breakends use bracket notation; single breakends use `A.` or `.A`
134- spanning deletion uses `*`
135- gVCF records often use `<NON_REF>` or `<*>`
137Do not apply single-nucleotide logic to every record.

Record Fields

for record in variants:
    alleles = record.alleles       # (REF, ALT1, ALT2, ...)
    alt_alleles = record.alts      # tuple or None
    identifier = record.id         # string or None
    quality = record.qual          # float or None
    filters = tuple(record.filter.keys())

record.alleles_variant_types classifies alleles with values such as REF, SNP, MNP, INDEL, BND, OVERLAP, and OTHER.

Handle non-simple alleles:

  • multiallelic records can have several ALT alleles
  • symbolic alleles include <DEL>, <DUP>, <INS>, and others
  • paired breakends use bracket notation; single breakends use A. or .A
  • spanning deletion uses *
  • gVCF records often use <NON_REF> or <*>

Do not apply single-nucleotide logic to every record.

Raw lines 115 to 138
139## INFO Fields
141```python
142for record in variants:
143 depth = record.info.get("DP")
144 frequencies = record.info.get("AF")
145 if frequencies is not None:
146 for allele_index, frequency in enumerate(frequencies, start=1):
147 alt = record.alleles[allele_index]
148 print(alt, frequency)
149```
151Number semantics matter:
153- `1`: one value
154- `A`: one value per ALT allele
155- `R`: one value per REF+ALT allele
156- `G`: one value per genotype
157- `.`: variable number
159For FORMAT fields, 0.24.1 also supports `P` (ploidy), local-allele `LA`, `LR`,
160`LG`, and modification `M` cardinalities. Preserve the source VCF version and
161required companion fields such as `LAA`; these are not interchangeable with
162ordinary `A`/`R`/`G`. Consult VCF 4.5 and test the actual caller's schema.
164Flag fields are represented as booleans. Missing values may be `None`, a tuple
165containing `None`, or absent from the mapping depending on the field.

INFO Fields

for record in variants:
    depth = record.info.get("DP")
    frequencies = record.info.get("AF")
    if frequencies is not None:
        for allele_index, frequency in enumerate(frequencies, start=1):
            alt = record.alleles[allele_index]
            print(alt, frequency)

Number semantics matter:

  • 1: one value
  • A: one value per ALT allele
  • R: one value per REF+ALT allele
  • G: one value per genotype
  • .: variable number

For FORMAT fields, 0.24.1 also supports P (ploidy), local-allele LA, LR, LG, and modification M cardinalities. Preserve the source VCF version and required companion fields such as LAA; these are not interchangeable with ordinary A/R/G. Consult VCF 4.5 and test the actual caller's schema.

Flag fields are represented as booleans. Missing values may be None, a tuple containing None, or absent from the mapping depending on the field.

Raw lines 139 to 166
167## FILTER Semantics
169```python
170filters = tuple(record.filter.keys())
172if "PASS" in filters:
173 status = "pass"
174elif not filters:
175 status = "unfiltered" # VCF '.'
176else:
177 status = "failed"
178```
180`PASS`, an empty filter set (`.`), and a failed filter are distinct states. Do
181not treat `.` as equivalent to `PASS` without an explicit policy.

FILTER Semantics

filters = tuple(record.filter.keys())

if "PASS" in filters:
    status = "pass"
elif not filters:
    status = "unfiltered"  # VCF '.'
else:
    status = "failed"

PASS, an empty filter set (.), and a failed filter are distinct states. Do not treat . as equivalent to PASS without an explicit policy.

Raw lines 167 to 182
183## Sample and Genotype Data
185```python
186for sample_name, call in record.samples.items():
187 genotype = call.get("GT")
188 depth = call.get("DP")
189 genotype_quality = call.get("GQ")
190 phased = call.phased
191 allele_strings = call.alleles
192```
194Genotype allele integers index `record.alleles`:
196- `0`: REF
197- `1`: first ALT
198- `2`: second ALT
199- `None`: missing allele
201Do not assume diploidy:
203```python
204def called_alleles(genotype):
205 if genotype is None:
206 return ()
207 return tuple(allele for allele in genotype if allele is not None)
210called = called_alleles(record.samples["sample_A"].get("GT"))
211alternate_count = sum(allele > 0 for allele in called)
212```
214`call.phased` records separator semantics but the Python GT remains a tuple.

Sample and Genotype Data

for sample_name, call in record.samples.items():
    genotype = call.get("GT")
    depth = call.get("DP")
    genotype_quality = call.get("GQ")
    phased = call.phased
    allele_strings = call.alleles

Genotype allele integers index record.alleles:

  • 0: REF
  • 1: first ALT
  • 2: second ALT
  • None: missing allele

Do not assume diploidy:

def called_alleles(genotype):
    if genotype is None:
        return ()
    return tuple(allele for allele in genotype if allele is not None)


called = called_alleles(record.samples["sample_A"].get("GT"))
alternate_count = sum(allele > 0 for allele in called)

call.phased records separator semantics but the Python GT remains a tuple.

Raw lines 183 to 215
216## Efficient Sample Subsetting
218Call `subset_samples()` before retrieving any record:
220```python
221samples = ["sample_A", "sample_B"]
223with pysam.VariantFile("cohort.bcf") as source:
224 source.subset_samples(samples)
225 output_header = source.header.copy()
227 with pysam.VariantFile(
228 "subset.bcf", "wb", header=output_header, threads=4
229 ) as destination:
230 for record in source:
231 destination.write(record)
232```
234This reduces decoding and memory. Do not copy a header, clear its sample
235collection, and manually reconstruct records; that approach is error-prone.
236Validate that every requested sample is present before subsetting.

Efficient Sample Subsetting

Call subset_samples() before retrieving any record:

samples = ["sample_A", "sample_B"]

with pysam.VariantFile("cohort.bcf") as source:
    source.subset_samples(samples)
    output_header = source.header.copy()

    with pysam.VariantFile(
        "subset.bcf", "wb", header=output_header, threads=4
    ) as destination:
        for record in source:
            destination.write(record)

This reduces decoding and memory. Do not copy a header, clear its sample collection, and manually reconstruct records; that approach is error-prone. Validate that every requested sample is present before subsetting.

Raw lines 216 to 237
238## Writing Unchanged Records
240When the destination uses the input header:
242```python
243with pysam.VariantFile("input.vcf.gz") as source, pysam.VariantFile(
244 "passing.vcf.gz",
245 "w",
246 header=source.header,
247 threads=4,
248) as destination:
249 for record in source:
250 if "PASS" in record.filter:
251 destination.write(record)
252```
254Write to a new file. Reopen and validate it before replacing any source.

Writing Unchanged Records

When the destination uses the input header:

with pysam.VariantFile("input.vcf.gz") as source, pysam.VariantFile(
    "passing.vcf.gz",
    "w",
    header=source.header,
    threads=4,
) as destination:
    for record in source:
        if "PASS" in record.filter:
            destination.write(record)

Write to a new file. Reopen and validate it before replacing any source.

Raw lines 238 to 255
256## Safely Add Header Fields
258Records are tied to their originating header. When the destination header is
259changed, copy and translate records:
261```python
262with pysam.VariantFile("input.vcf.gz") as source:
263 output_header = source.header.copy()
264 output_header.info.add(
265 "BAM_DP",
266 number=1,
267 type="Integer",
268 description="Aligned base depth from the selected BAM and filters",
269 )
271 with pysam.VariantFile(
272 "annotated.vcf.gz", "w", header=output_header
273 ) as destination:
274 for input_record in source:
275 record = input_record.copy()
276 record.translate(output_header)
277 record.info["BAM_DP"] = 27
278 destination.write(record)
279```
281Declare INFO, FORMAT, FILTER, and contig metadata before assigning values. Use
282distinct field names when the new value has semantics different from an
283existing field.

Safely Add Header Fields

Records are tied to their originating header. When the destination header is changed, copy and translate records:

with pysam.VariantFile("input.vcf.gz") as source:
    output_header = source.header.copy()
    output_header.info.add(
        "BAM_DP",
        number=1,
        type="Integer",
        description="Aligned base depth from the selected BAM and filters",
    )

    with pysam.VariantFile(
        "annotated.vcf.gz", "w", header=output_header
    ) as destination:
        for input_record in source:
            record = input_record.copy()
            record.translate(output_header)
            record.info["BAM_DP"] = 27
            destination.write(record)

Declare INFO, FORMAT, FILTER, and contig metadata before assigning values. Use distinct field names when the new value has semantics different from an existing field.

Raw lines 256 to 284
285## Construct New Records
287```python
288header = pysam.VariantHeader()
289header.contigs.add("chr1", length=248_956_422)
290header.info.add(
291 "DP",
292 number=1,
293 type="Integer",
294 description="Total depth",
296header.formats.add(
297 "GT",
298 number=1,
299 type="String",
300 description="Genotype",
302header.add_sample("sample_A")
304with pysam.VariantFile("new.vcf.gz", "w", header=header) as output:
305 record = output.header.new_record(
306 contig="chr1",
307 start=99_999,
308 stop=100_000,
309 alleles=("A", "G"),
310 id="example",
311 qual=60,
312 )
313 record.filter.add("PASS")
314 record.info["DP"] = 40
315 record.samples["sample_A"]["GT"] = (0, 1)
316 output.write(record)
317```
319`VariantHeader()` already starts with `##fileformat=VCFv4.2`. Do not append a
320second `fileformat` line with `add_meta`; that creates a duplicate rather than
321changing the version. To use newer-version fields, copy a valid header read
322from that version and check `header.version`.
324`start`/`stop` here are numeric Python coordinates. `start=99_999` writes VCF
325`POS=100000`.

Construct New Records

header = pysam.VariantHeader()
header.contigs.add("chr1", length=248_956_422)
header.info.add(
    "DP",
    number=1,
    type="Integer",
    description="Total depth",
)
header.formats.add(
    "GT",
    number=1,
    type="String",
    description="Genotype",
)
header.add_sample("sample_A")

with pysam.VariantFile("new.vcf.gz", "w", header=header) as output:
    record = output.header.new_record(
        contig="chr1",
        start=99_999,
        stop=100_000,
        alleles=("A", "G"),
        id="example",
        qual=60,
    )
    record.filter.add("PASS")
    record.info["DP"] = 40
    record.samples["sample_A"]["GT"] = (0, 1)
    output.write(record)

VariantHeader() already starts with ##fileformat=VCFv4.2. Do not append a second fileformat line with add_meta; that creates a duplicate rather than changing the version. To use newer-version fields, copy a valid header read from that version and check header.version.

start/stop here are numeric Python coordinates. start=99_999 writes VCF POS=100000.

Raw lines 285 to 326
327## Filtering Patterns
329Make missing-value policy explicit:
331```python
332def passes(record, *, min_qual=30.0, min_dp=10) -> bool:
333 if record.qual is None or record.qual < min_qual:
334 return False
335 depth = record.info.get("DP")
336 if depth is None or depth < min_dp:
337 return False
338 return "PASS" in record.filter
339```
341For ALT-frequency filtering:
343```python
344frequencies = record.info.get("AF")
345keep = (
346 frequencies is not None
347 and any(value is not None and value >= 0.01 for value in frequencies)
349```
351For genotype predicates, ignore missing alleles and preserve ploidy:
353```python
354gt = record.samples["sample_A"].get("GT")
355has_alt = gt is not None and any(
356 allele is not None and allele > 0 for allele in gt
358```

Filtering Patterns

Make missing-value policy explicit:

def passes(record, *, min_qual=30.0, min_dp=10) -> bool:
    if record.qual is None or record.qual < min_qual:
        return False
    depth = record.info.get("DP")
    if depth is None or depth < min_dp:
        return False
    return "PASS" in record.filter

For ALT-frequency filtering:

frequencies = record.info.get("AF")
keep = (
    frequencies is not None
    and any(value is not None and value >= 0.01 for value in frequencies)
)

For genotype predicates, ignore missing alleles and preserve ploidy:

gt = record.samples["sample_A"].get("GT")
has_alt = gt is not None and any(
    allele is not None and allele > 0 for allele in gt
)
Raw lines 327 to 359
360## Compression and Indexing
362Plain VCF must be coordinate sorted before compression/indexing:
364```python
365import pysam
367pysam.tabix_compress("sorted.vcf", "sorted.vcf.gz")
368pysam.tabix_index("sorted.vcf.gz", preset="vcf")
369```
371The last call creates TBI. To choose CSI instead, compress first and then use
372bcftools indexing:
374```python
375import pysam.bcftools
377pysam.bcftools.index("--csi", "sorted.vcf.gz", catch_stdout=False)
378```
380Do not use ordinary gzip for a random-access VCF. BGZF permits blocked random
381access.
383`tabix_index()` can automatically compress an uncompressed input and delete
384the original unless `keep_original=True`; the explicit two-step pattern above
385is safer. Do not use `force=True` unless overwrite is intended.
387For BCF:
389```python
390pysam.bcftools.index("--csi", "cohort.bcf", catch_stdout=False)
391```
393Use CSI when contigs may exceed the legacy TBI coordinate limit. Read
394`coordinates_and_indexing.md` for index selection.

Compression and Indexing

Plain VCF must be coordinate sorted before compression/indexing:

import pysam

pysam.tabix_compress("sorted.vcf", "sorted.vcf.gz")
pysam.tabix_index("sorted.vcf.gz", preset="vcf")

The last call creates TBI. To choose CSI instead, compress first and then use bcftools indexing:

import pysam.bcftools

pysam.bcftools.index("--csi", "sorted.vcf.gz", catch_stdout=False)

Do not use ordinary gzip for a random-access VCF. BGZF permits blocked random access.

tabix_index() can automatically compress an uncompressed input and delete the original unless keep_original=True; the explicit two-step pattern above is safer. Do not use force=True unless overwrite is intended.

For BCF:

pysam.bcftools.index("--csi", "cohort.bcf", catch_stdout=False)

Use CSI when contigs may exceed the legacy TBI coordinate limit. Read coordinates_and_indexing.md for index selection.

Raw lines 360 to 395
396## Header Translation Across Inputs
398Do not merge VCFs by grouping Python records and appending sample calls. Inputs
399can differ in contig order, INFO/FORMAT definitions, normalization, and allele
400representation. Prefer `bcftools merge`, `bcftools concat`, or a dedicated
401variant-merging tool after normalization and header reconciliation.
403`record.translate(destination_header)` remaps header dictionaries but does not
404normalize alleles, split multiallelic records, or resolve conflicting metadata.

Header Translation Across Inputs

Do not merge VCFs by grouping Python records and appending sample calls. Inputs can differ in contig order, INFO/FORMAT definitions, normalization, and allele representation. Prefer bcftools merge, bcftools concat, or a dedicated variant-merging tool after normalization and header reconciliation.

record.translate(destination_header) remaps header dictionaries but does not normalize alleles, split multiallelic records, or resolve conflicting metadata.

Raw lines 396 to 405
406## Validation Checklist
408- Reopen the written file with `VariantFile`.
409- Confirm contig order and sample order.
410- Confirm each assigned INFO/FORMAT/FILTER field is declared with correct
411 Number and Type.
412- Check missing, haploid, polyploid, multiallelic, symbolic, and filtered
413 records.
414- Index the final coordinate-sorted output and fetch known edge intervals.
415- Use bcftools validation/normalization commands when format-level guarantees
416 are required.

Validation Checklist

  • Reopen the written file with VariantFile.
  • Confirm contig order and sample order.
  • Confirm each assigned INFO/FORMAT/FILTER field is declared with correct Number and Type.
  • Check missing, haploid, polyploid, multiallelic, symbolic, and filtered records.
  • Index the final coordinate-sorted output and fetch known edge intervals.
  • Use bcftools validation/normalization commands when format-level guarantees are required.
Raw lines 406 to 416