Coordinate Systems
Coordinate Systems
Core Principle: Genomic data formats utilize two distinct and incompatible coordinate systems: 1-based fully closed intervals and 0-based half-open intervals. Distinguishing between them is essential to prevent off-by-one positioning errors.
The two systems
1-based, fully closed ("interbase off"): the first base is position 1, and an interval [start, end] includes both endpoints. A single base at position 5 is represented as 5-5, and interval length is calculated as end - start + 1.
0-based, half-open: the first base is position 0, and an interval [start, end) includes the start but excludes the end. A single base at index 5 is represented as [5, 6), and interval length is calculated as end - start.
sequence: A C G T
1-based: 1 2 3 4
0-based: 0 1 2 3 4 ← coordinates sit *between* bases
The 0-based system is sometimes called "interbase" because the numbers label the gaps between bases, not the bases themselves.
Which format uses which
| Format / tool | System |
|---|---|
| BED | 0-based, half-open |
| BAM (binary fields) | 0-based |
| GFF / GTF | 1-based, closed |
| VCF | 1-based, closed |
| SAM (text POS field) | 1-based |
| HGVS | 1-based, closed |
| Python slicing / NumPy | 0-based, half-open |
| Ensembl API | 1-based |
| UCSC browser display | 1-based (but its BED files are 0-based) |
That last row is the classic trap: UCSC shows you 1-based coordinates but exports 0-based BED.
Converting
# 1-based closed → 0-based half-open (i.e. to a Python slice)
start0, end0 = start1 - 1, end1
# 0-based half-open → 1-based closed
start1, end1 = start0 + 1, end0
seq[start1 - 1 : end1] # extracting a GFF/VCF feature from a Python string
Only the start shifts. The end is numerically identical. That asymmetry is why the bug is so easy to make and so easy to miss; half of standard test cases may still pass.
Biopython does this for you
SeqFeature.location always stores 0-based half-open internally, regardless of the source format. A GFF feature at 1-based 100-200 becomes [99:200] in Biopython. When you print a location you see [99:200], and feature.extract(record.seq) gives the right subsequence.
Do not add or subtract 1 yourself when working with a SeqFeature, as Biopython automatically applies the conversion. See SeqFeature and Location.
hgvs and the extra subtleties
HGVS is 1-based, but c. coordinates carry two extra complications:
- There is no position 0. In
c.numbering, position 1 is the A of the initiator ATG, and the base immediately before it isc.-1, notc.0. Likewise*1is the first base after the stop codon. - Intronic offsets.
c.87+3means "3 bases into the intron after coding position 87";c.88-3means "3 bases before coding position 88". These areoffsetvalues on the position object, not part of the base number.
hgvs models this with BaseOffsetPosition (fields base, offset, datum). Never do naive arithmetic on .base. See SequenceVariant and hgvs Pitfalls.
Insertions are where everyone dies
An insertion has no width, so where does it go?
- HGVS:
c.100_101insAexplicitly specifies the two flanking bases. - VCF:
POS=100 REF=A ALT=ATanchors on the preceding base and includes it in both alleles. - BED:
start == enddefines a zero-width interval.
Converting between these is not a coordinate shift, it is a representation change. Use hgvs Normalizer and a real VCF library rather than string surgery.
Practical defences
- Name your variables
start0/start1, never barestart. - Write one assertion per interval-producing function:
assert end >= startfor half-open,assert end >= startandlength == end - start + 1for closed. - Test with a single-base feature and a two-base feature. Off-by-ones survive tests on long intervals.
- Test at a chromosome start (position 1 / index 0). Underflow shows up there.
See also
SeqFeature and Location · Applied - GFF and Genomic Intervals · Applied - Genomic File Formats · hgvs Pitfalls · HGVS Nomenclature · Indexing and Slicing
Exercise
Write convert(records), where records is a list of (system, start, end)
triples. system is bed for 0-based half-open or gff for 1-based fully
closed. Return one string per record, which the driver prints one per line.
Remember that only the start shifts; the end is numerically identical in both
systems.
Apply these rules in order:
bedwithstart < 0, orgffwithstart < 1, is impossible — there is no negative index and no position 0. Emiterror.bedwithend < start, orgffwithend < start - 1, is alsoerror.bedwithstart == endis a zero-length insertion point, which a fully closed interval cannot express. Emitgff ins <left> <right> len=0, naming the two 1-based bases the insertion falls between. At index 0 there is no base to the left, so<left>is..gffwithend == start - 1is the same insertion point written the other way. Half-open notation handles it directly, so emitbed <start-1> <start-1> len=0.- Otherwise emit
gff <start+1> <end> len=<n>orbed <start-1> <end> len=<n>, wherenis the length of the interval in the system it was given in.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | bed 100 105 gff 101 105 bed 7676153 7676154 gff 7676154 7676154 | gff 101 105 len=5 bed 100 105 len=5 gff 7676154 7676154 len=1 bed 7676153 7676154 len=1 |
| 2 | stdin | bed 100 100 bed 0 0 gff 101 100 gff 1 0 | gff ins 100 101 len=0 gff ins . 1 len=0 bed 100 100 len=0 bed 0 0 len=0 |
| 3 | stdin | gff 0 5 bed -1 4 gff 10 8 bed 9 4 | error error error error |
| 4 | stdin | bed 0 1 | gff 1 1 len=1 |
Hints · 3
01Hint
The length of a half-open interval is `end - start`; the length of a closed interval is `end - start + 1`. Compute it before converting, not after.
02Hint
Check the two degenerate cases before the general one — `start == end` in BED and `end == start - 1` in GFF both pass the ordinary validity test.
03Hint
An insertion at 0-based point `p` sits between the 1-based bases `p` and `p + 1`.