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

coordinates_and_indexing.md

329 lines · skills/pysam/references/coordinates_and_indexing.md at 5f2bc03

Open on GitHub
1# Coordinates and Indexing
2
3Coordinate mistakes and stale indexes are the most common causes of plausible
4but wrong genomic results. This reference targets pysam 0.24.1.
5

Coordinates and Indexing

Coordinate mistakes and stale indexes are the most common causes of plausible but wrong genomic results. This reference targets pysam 0.24.1.

Raw lines 1 to 5
6## The Pysam Rule
7
8For region API numeric arguments and span properties such as `reference_start`
9and `VariantRecord.start/stop`, pysam uses **0-based, half-open** intervals:
11```text
12[start, stop)
13```
15The first base is `0`; `start` is included and `stop` is excluded. Interval
16length is `stop - start`.
18`VariantRecord.pos` preserves the VCF 1-based POS field. A textual
19samtools-style region string is also
20**1-based, inclusive**:
22```text
23chr1:100-199
24```
26These refer to the same 100 bases:
28```python
29file.fetch("chr1", 99, 199)
30file.fetch(region="chr1:100-199")
31```
33This rule applies to:
35- `AlignmentFile.fetch()`, `count()`, `count_coverage()`, and `pileup()`
36- `VariantFile.fetch()`
37- `FastaFile.fetch()`
38- `TabixFile.fetch()`
40Do not treat numeric `VariantFile.fetch()` arguments as VCF text coordinates.

The Pysam Rule

For region API numeric arguments and span properties such as reference_start and VariantRecord.start/stop, pysam uses 0-based, half-open intervals:

[start, stop)

The first base is 0; start is included and stop is excluded. Interval length is stop - start.

VariantRecord.pos preserves the VCF 1-based POS field. A textual samtools-style region string is also 1-based, inclusive:

chr1:100-199

These refer to the same 100 bases:

file.fetch("chr1", 99, 199)
file.fetch(region="chr1:100-199")

This rule applies to:

  • AlignmentFile.fetch(), count(), count_coverage(), and pileup()
  • VariantFile.fetch()
  • FastaFile.fetch()
  • TabixFile.fetch()

Do not treat numeric VariantFile.fetch() arguments as VCF text coordinates.

Raw lines 6 to 41
42## Format Conversion Table
44| Source representation | Source convention | Convert to pysam numeric |
45|---|---|---|
46| BED `chromStart`, `chromEnd` | 0-based, half-open | use unchanged |
47| VCF `POS` and REF | 1-based position | `start = POS - 1`; `stop = start + len(REF)` unless record semantics provide another end |
48| `VariantRecord.start`, `.stop` | 0-based, half-open | use unchanged |
49| GFF/GTF start/end columns | 1-based, inclusive | `start = start_text - 1`; `stop = end_text` |
50| SAM `POS` | 1-based leftmost base | use `AlignedSegment.reference_start` |
51| samtools region string | 1-based, inclusive | pass as `region=...`, or convert both endpoints |
53For VCF structural variants, symbolic alleles, breakends, and records with
54`INFO/END`, use `VariantRecord.start` and `VariantRecord.stop` rather than
55reconstructing the interval from `len(REF)`. This is the indexed local record
56span, not a complete biological event: BND mate loci, confidence intervals,
57per-allele SVLEN, and sample-specific lengths require their own interpretation.
58VCF 4.5 deprecates INFO/END in favor of computed end semantics.
60SAM text POS and CRAM absolute alignment starts are 1-based; BAM/BCF binary
61positions are 0-based. Pysam translates the stored encodings; never apply a
62second conversion to its numeric span properties.

Format Conversion Table

Source representationSource conventionConvert to pysam numeric
BED chromStart, chromEnd0-based, half-openuse unchanged
VCF POS and REF1-based positionstart = POS - 1; stop = start + len(REF) unless record semantics provide another end
VariantRecord.start, .stop0-based, half-openuse unchanged
GFF/GTF start/end columns1-based, inclusivestart = start_text - 1; stop = end_text
SAM POS1-based leftmost baseuse AlignedSegment.reference_start
samtools region string1-based, inclusivepass as region=..., or convert both endpoints

For VCF structural variants, symbolic alleles, breakends, and records with INFO/END, use VariantRecord.start and VariantRecord.stop rather than reconstructing the interval from len(REF). This is the indexed local record span, not a complete biological event: BND mate loci, confidence intervals, per-allele SVLEN, and sample-specific lengths require their own interpretation. VCF 4.5 deprecates INFO/END in favor of computed end semantics.

SAM text POS and CRAM absolute alignment starts are 1-based; BAM/BCF binary positions are 0-based. Pysam translates the stored encodings; never apply a second conversion to its numeric span properties.

Raw lines 42 to 63
64## Single Positions
66A 1-based position `p` becomes the one-base Python interval:
68```python
69start = p - 1
70stop = p
71```
73For a VCF record:
75```python
76assert record.start == record.pos - 1
77base = fasta.fetch(record.contig, record.start, record.start + 1)
78```

Single Positions

A 1-based position p becomes the one-base Python interval:

start = p - 1
stop = p

For a VCF record:

assert record.start == record.pos - 1
base = fasta.fetch(record.contig, record.start, record.start + 1)
Raw lines 64 to 79
80## Overlap Versus Containment
82Region fetches are overlap queries. An alignment or variant can begin before
83the requested interval and still overlap it.
85To require complete containment:
87```python
88def fully_contained(read, start: int, stop: int) -> bool:
89 return (
90 read.reference_start is not None
91 and read.reference_end is not None
92 and read.reference_start >= start
93 and read.reference_end <= stop
94 )
95```
97For point-based logic, define exactly what "overlap" means for deletions,
98reference skips, symbolic alleles, and breakends.
100`pileup()` has an additional trap: without `truncate=True`, it can emit columns
101outside the requested interval because reads overlap the interval.

Overlap Versus Containment

Region fetches are overlap queries. An alignment or variant can begin before the requested interval and still overlap it.

To require complete containment:

def fully_contained(read, start: int, stop: int) -> bool:
    return (
        read.reference_start is not None
        and read.reference_end is not None
        and read.reference_start >= start
        and read.reference_end <= stop
    )

For point-based logic, define exactly what "overlap" means for deletions, reference skips, symbolic alleles, and breakends.

pileup() has an additional trap: without truncate=True, it can emit columns outside the requested interval because reads overlap the interval.

Raw lines 80 to 102
103## Parser Coordinates
105Pysam parser objects normalize coordinates:
107- `asBed().start` / `.end`: 0-based, half-open
108- `asGTF().start` / `.end`: exposed in Python coordinate convention
109- `asVCF().pos`: **0-based**, unlike 1-based `VariantRecord.pos`; use
110 `VariantFile` for full VCF record semantics
111- `asGFF3().start` / `.end`: 0-based, half-open
113When creating a custom tabix index:
115- `seq_col`, `start_col`, and `end_col` are 0-based **column indices**
116- file coordinates default to 1-based unless `zerobased=True`
117- later `TabixFile.fetch()` numeric query coordinates are still 0-based
119These are three distinct concepts: Python column index, coordinate encoding in
120the stored table, and coordinate encoding in the query.

Parser Coordinates

Pysam parser objects normalize coordinates:

  • asBed().start / .end: 0-based, half-open
  • asGTF().start / .end: exposed in Python coordinate convention
  • asVCF().pos: 0-based, unlike 1-based VariantRecord.pos; use VariantFile for full VCF record semantics
  • asGFF3().start / .end: 0-based, half-open

When creating a custom tabix index:

  • seq_col, start_col, and end_col are 0-based column indices
  • file coordinates default to 1-based unless zerobased=True
  • later TabixFile.fetch() numeric query coordinates are still 0-based

These are three distinct concepts: Python column index, coordinate encoding in the stored table, and coordinate encoding in the query.

Raw lines 103 to 121
122## Contig Identity
124Coordinate conversion does not solve contig mismatches. Check:
126- `chr1` versus `1`
127- mitochondrial names (`chrM`, `MT`, `M`)
128- alternate loci and decoys
129- assembly version (for example GRCh37 versus GRCh38)
130- contig order and lengths
132```python
133alignment_contigs = dict(zip(bam.references, bam.lengths))
134fasta_contigs = dict(zip(fasta.references, fasta.lengths))
136shared = alignment_contigs.keys() & fasta_contigs.keys()
137length_mismatches = {
138 name: (alignment_contigs[name], fasta_contigs[name])
139 for name in shared
140 if alignment_contigs[name] != fasta_contigs[name]
142```
144Do not silently strip or add `chr` across arbitrary assemblies. Use an explicit
145reviewed mapping.

Contig Identity

Coordinate conversion does not solve contig mismatches. Check:

  • chr1 versus 1
  • mitochondrial names (chrM, MT, M)
  • alternate loci and decoys
  • assembly version (for example GRCh37 versus GRCh38)
  • contig order and lengths
alignment_contigs = dict(zip(bam.references, bam.lengths))
fasta_contigs = dict(zip(fasta.references, fasta.lengths))

shared = alignment_contigs.keys() & fasta_contigs.keys()
length_mismatches = {
    name: (alignment_contigs[name], fasta_contigs[name])
    for name in shared
    if alignment_contigs[name] != fasta_contigs[name]
}

Do not silently strip or add chr across arbitrary assemblies. Use an explicit reviewed mapping.

Raw lines 122 to 146
147## Index Matrix
149| Data | Random-access index | Sort requirement |
150|---|---|---|
151| BAM | `.bai` or `.csi` | coordinate order |
152| CRAM | `.crai` | coordinate order |
153| BGZF VCF | `.tbi` or `.csi` | contig/position order |
154| BCF | `.csi` | contig/position order |
155| FASTA | `.fai`; BGZF FASTA also `.gzi` | FASTA layout, not coordinate sort |
156| BED/GFF/GTF/custom BGZF table | `.tbi` or `.csi` | contig/start order |
157| SAM / ordinary VCF / FASTQ | no random-access index through these APIs | sequential only |
159An index is a view of a specific file. If the data file changes, rebuild the
160index. A stale index may fail loudly or return wrong/incomplete regions.

Index Matrix

DataRandom-access indexSort requirement
BAM.bai or .csicoordinate order
CRAM.craicoordinate order
BGZF VCF.tbi or .csicontig/position order
BCF.csicontig/position order
FASTA.fai; BGZF FASTA also .gziFASTA layout, not coordinate sort
BED/GFF/GTF/custom BGZF table.tbi or .csicontig/start order
SAM / ordinary VCF / FASTQno random-access index through these APIssequential only

An index is a view of a specific file. If the data file changes, rebuild the index. A stale index may fail loudly or return wrong/incomplete regions.

Raw lines 147 to 161
162## BAI/TBI Versus CSI
164Legacy BAI and standard TBI indexes have a maximum coordinate near `2^29`
165(512 Mi bases). This is insufficient for some plant, animal, and synthetic
166references. CSI is parameterized and supports larger coordinates.
168Create a BAM CSI:
170```python
171import pysam
173pysam.index("-c", "large-reference.bam", catch_stdout=False)
174```
176Create a tabix CSI:
178```python
179pysam.tabix_index(
180 "large-reference.bed.gz",
181 preset="bed",
182 csi=True,
183 min_shift=14,
185```
187Create a VCF/BCF CSI:
189```python
190import pysam.bcftools
192pysam.bcftools.index(
193 "--csi",
194 "variants.vcf.gz",
195 catch_stdout=False,
197```
199Prefer CSI when reference sizes are unknown or potentially large. Confirm
200downstream tools support it.

BAI/TBI Versus CSI

Legacy BAI and standard TBI indexes have a maximum coordinate near 2^29 (512 Mi bases). This is insufficient for some plant, animal, and synthetic references. CSI is parameterized and supports larger coordinates.

Create a BAM CSI:

import pysam

pysam.index("-c", "large-reference.bam", catch_stdout=False)

Create a tabix CSI:

pysam.tabix_index(
    "large-reference.bed.gz",
    preset="bed",
    csi=True,
    min_shift=14,
)

Create a VCF/BCF CSI:

import pysam.bcftools

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

Prefer CSI when reference sizes are unknown or potentially large. Confirm downstream tools support it.

Raw lines 162 to 201
202## Sort Before Indexing
204Indexing does not sort records.
206BAM:
208```python
209import pysam.samtools
211pysam.samtools.sort(
212 "-@", "4",
213 "-o", "sorted.bam",
214 "input.bam",
215 catch_stdout=False,
217pysam.samtools.index(
218 "-@", "4",
219 "sorted.bam",
220 catch_stdout=False,
222```
224VCF:
226```python
227import pysam.bcftools
229pysam.bcftools.sort(
230 "-Oz",
231 "-o", "sorted.vcf.gz",
232 "input.vcf",
233 catch_stdout=False,
235pysam.bcftools.index(
236 "--csi",
237 "sorted.vcf.gz",
238 catch_stdout=False,
240```
242Tabix tables must be sorted before `tabix_index()`. The Python wrapper does
243not sort or perform a full preflight; HTSlib rejects detected ordering errors
244while building the index. Treat any indexing failure as invalid output.

Sort Before Indexing

Indexing does not sort records.

BAM:

import pysam.samtools

pysam.samtools.sort(
    "-@", "4",
    "-o", "sorted.bam",
    "input.bam",
    catch_stdout=False,
)
pysam.samtools.index(
    "-@", "4",
    "sorted.bam",
    catch_stdout=False,
)

VCF:

import pysam.bcftools

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

Tabix tables must be sorted before tabix_index(). The Python wrapper does not sort or perform a full preflight; HTSlib rejects detected ordering errors while building the index. Treat any indexing failure as invalid output.

Raw lines 202 to 245
246## Safe Tabix Creation
248Prefer separate compression and indexing:
250```python
251pysam.tabix_compress("regions.bed", "regions.bed.gz")
252pysam.tabix_index("regions.bed.gz", preset="bed")
253```
255Calling `tabix_index("regions.bed")` can automatically create
256`regions.bed.gz` and remove the original. Use `keep_original=True` if relying
257on that one-step path.
259Do not pass `force=True` by default. Existing output should trigger review,
260not silent replacement.

Safe Tabix Creation

Prefer separate compression and indexing:

pysam.tabix_compress("regions.bed", "regions.bed.gz")
pysam.tabix_index("regions.bed.gz", preset="bed")

Calling tabix_index("regions.bed") can automatically create regions.bed.gz and remove the original. Use keep_original=True if relying on that one-step path.

Do not pass force=True by default. Existing output should trigger review, not silent replacement.

Raw lines 246 to 261
262## Nonstandard and Remote Index Locations
264Pass an explicit index:
266```python
267with pysam.AlignmentFile(
268 "sample.bam",
269 "rb",
270 index_filename="indexes/sample.csi",
271) as bam:
272 ...
274with pysam.VariantFile(
275 "cohort.vcf.gz",
276 index_filename="indexes/cohort.vcf.gz.csi",
277) as variants:
278 ...
279```
281Remote random access additionally depends on:
283- an HTSlib build with the relevant network/plugin support
284- a reachable index
285- server range requests
286- stable data and index URLs
288Pass `index_filename` explicitly when automatic URL derivation is unreliable.
289Read `cram_and_performance.md` before remote or CRAM access.

Nonstandard and Remote Index Locations

Pass an explicit index:

with pysam.AlignmentFile(
    "sample.bam",
    "rb",
    index_filename="indexes/sample.csi",
) as bam:
    ...

with pysam.VariantFile(
    "cohort.vcf.gz",
    index_filename="indexes/cohort.vcf.gz.csi",
) as variants:
    ...

Remote random access additionally depends on:

  • an HTSlib build with the relevant network/plugin support
  • a reachable index
  • server range requests
  • stable data and index URLs

Pass index_filename explicitly when automatic URL derivation is unreliable. Read cram_and_performance.md before remote or CRAM access.

Raw lines 262 to 290
291## Index Checks
293Alignment:
295```python
296with pysam.AlignmentFile("sample.bam", "rb") as bam:
297 if not bam.has_index():
298 raise ValueError("random access requires a BAM/CRAM index")
299 bam.check_index()
300```
302Variant constructors discover TBI/CSI indexes. `TabixFile` defaults only to
303`<filename>.tbi`; pass `index="<filename>.csi"` for CSI. Region fetches require
304an index. Reopen output and test known regions rather
305than checking only that an index filename exists.

Index Checks

Alignment:

with pysam.AlignmentFile("sample.bam", "rb") as bam:
    if not bam.has_index():
        raise ValueError("random access requires a BAM/CRAM index")
    bam.check_index()

Variant constructors discover TBI/CSI indexes. TabixFile defaults only to <filename>.tbi; pass index="<filename>.csi" for CSI. Region fetches require an index. Reopen output and test known regions rather than checking only that an index filename exists.

Raw lines 291 to 306
307## Boundary Tests
309For important pipelines, test:
311- first base of a contig
312- exact interval start and stop
313- a record spanning the query boundary
314- a zero-length or invalid interval
315- contig end
316- a high coordinate beyond 512 Mi bases when CSI is expected
317- missing and aliased contigs
318- region string and numeric equivalents
320One useful invariant:
322```python
323numeric = list(file.fetch(contig, start, stop))
324region = f"{contig}:{start + 1}-{stop}"
325textual = list(file.fetch(region=region))
326```
328For the same indexed file and valid nonempty interval, these queries should
329select equivalent records.

Boundary Tests

For important pipelines, test:

  • first base of a contig
  • exact interval start and stop
  • a record spanning the query boundary
  • a zero-length or invalid interval
  • contig end
  • a high coordinate beyond 512 Mi bases when CSI is expected
  • missing and aliased contigs
  • region string and numeric equivalents

One useful invariant:

numeric = list(file.fetch(contig, start, stop))
region = f"{contig}:{start + 1}-{stop}"
textual = list(file.fetch(region=region))

For the same indexed file and valid nonempty interval, these queries should select equivalent records.

Raw lines 307 to 329