Skip to Content
Silent failuresMy variant positions are wrong

My variant positions are wrong

Your variant calls look fine. The positions are plausible, the depths are plausible, the quality scores are plausible. Then an annotation step puts a variant in a gene it has no business being in, or a known pathogenic site comes back as reference, and you start checking which build everything was against.

What actually happened

The reads were aligned against one human reference build and the variants were called against another. GRCh37 and GRCh38 — equivalently hg19 and hg38 — differ by much more than a renaming. Between them the assembly gained sequence, closed gaps, fixed errors, and moved the coordinates of nearly everything.

The practical consequence is that a position means a different place in each build. For most of the genome the offset is in the thousands to hundreds of thousands of bases; around regions that were substantially reassembled it can be far more, and some sequence present in one build is absent from the other.

A position reported in the wrong build is not approximately right. It is a valid coordinate pointing somewhere else.

Why nothing crashed

A variant caller takes a reference and an alignment. If both name a contig 1 with a length that matches, the caller has everything it needs and proceeds. The build is not written in the file.

This is the heart of it: neither FASTA nor BAM carries a build identifier. A FASTA is sequence names and sequence. A BAM’s @SQ lines carry a name and a length, and optionally an M5 checksum that almost nothing writes. The two builds share contig names, and GRCh37 and GRCh38 chromosome lengths differ — but nothing checks the length unless a tool chooses to, and the common ones treat a mismatch as a warning at most.

So the information that would catch the error is either absent from the files or present and unexamined.

What Ligase says

Here the build is declared on each input — which is how it must work, since the files do not record it — and the sequence content is compared regardless.

DimensionErrorin stepcallligase check — nothing ran
ref.reference=GRCh37from FASTA contigs: crates/ligase-core/tests/fixtures/tiny_bare.fasta
aln.reference=GRCh38from SAM @SQ header: crates/ligase-core/tests/fixtures/tiny_other.sam
Contig `1`: same name and length but different sequence (MD5 44e7acaf against a965a71a).
These must match. Did you pass the wrong reference?

The declared names are reported first, because they are what you will recognise. But the engine did not take your word for it. The line that actually proves the mismatch is the third one: a contig with the same name and the same length but a different MD5. That is a statement about sequence, and it holds whether or not anybody declared anything.

Reference identity in Ligase is contig names plus lengths plus MD5s, never a filename and never a declaration alone. Two files you have both labelled GRCh38 will still be reported as different references if their sequence differs.

How it is caught

Declare the build once, on load:

import ligase as lg grch38 = lg.reference("data/refs/GRCh38.fasta", organism="human", build="GRCh38") aln = lg.alignment("data/alignments/sample01.bam") # Nothing has run. This reads the FASTA index and the BAM header. lg.check_step("call", {"ref": grch38, "aln": aln})

The declaration gives the error message a name a human recognises. The fingerprint does the actual work. If the BAM’s @SQ lines carry M5 values they are compared directly; where they do not, the reference’s own contig checksums are compared against the lengths and names the BAM does carry, which catches every case where sequence differs at a shared length.

Pinning the reference by accession and checksum is also what makes the generated methods paragraph trustworthy — see how was this produced?.

Last updated on