coordinates_and_indexing.md
329 lines · skills/pysam/references/coordinates_and_indexing.md at 5f2bc03
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 5The 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-199These 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(), andpileup()VariantFile.fetch()FastaFile.fetch()TabixFile.fetch()
Do not treat numeric VariantFile.fetch() arguments as VCF text coordinates.
Format Conversion Table
| Source representation | Source convention | Convert to pysam numeric |
|---|---|---|
BED chromStart, chromEnd | 0-based, half-open | use unchanged |
VCF POS and REF | 1-based position | start = POS - 1; stop = start + len(REF) unless record semantics provide another end |
VariantRecord.start, .stop | 0-based, half-open | use unchanged |
| GFF/GTF start/end columns | 1-based, inclusive | start = start_text - 1; stop = end_text |
SAM POS | 1-based leftmost base | use AlignedSegment.reference_start |
| samtools region string | 1-based, inclusive | pass 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 63Single Positions
A 1-based position p becomes the one-base Python interval:
start = p - 1
stop = pFor a VCF record:
assert record.start == record.pos - 1
base = fasta.fetch(record.contig, record.start, record.start + 1)Raw lines 64 to 79Overlap 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.
Parser Coordinates
Pysam parser objects normalize coordinates:
asBed().start/.end: 0-based, half-openasGTF().start/.end: exposed in Python coordinate conventionasVCF().pos: 0-based, unlike 1-basedVariantRecord.pos; useVariantFilefor full VCF record semanticsasGFF3().start/.end: 0-based, half-open
When creating a custom tabix index:
seq_col,start_col, andend_colare 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 121Contig Identity
Coordinate conversion does not solve contig mismatches. Check:
chr1versus1- 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.
Index Matrix
| Data | Random-access index | Sort requirement |
|---|---|---|
| BAM | .bai or .csi | coordinate order |
| CRAM | .crai | coordinate order |
| BGZF VCF | .tbi or .csi | contig/position order |
| BCF | .csi | contig/position order |
| FASTA | .fai; BGZF FASTA also .gzi | FASTA layout, not coordinate sort |
| BED/GFF/GTF/custom BGZF table | .tbi or .csi | contig/start order |
| SAM / ordinary VCF / FASTQ | no random-access index through these APIs | sequential 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 161BAI/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 201Sort 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.
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.
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.
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.
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