hgvs Pitfalls
In one line: the errors that produce plausible, confidently-wrong variant annotations — collected in one place, because in clinical genomics these are patient-safety issues.
Read this once now, and again after you have built a pipeline. It lands differently the second time.
1. Not normalizing
The single biggest source of variant-matching failures.
An indel in a repeat has several valid descriptions. Without normalization, c.100del and c.103del are different strings for the same event. Your ClinVar lookup returns nothing, which reads as "novel variant" rather than "I asked the wrong question".
var = hn.normalize(hp.parse_hgvs_variant(s)) # always
Normalize both sides of any comparison, in the same coordinate system. See hgvs Normalizer.
2. HGVS and VCF shift in opposite directions
| Standard | Convention |
|---|---|
| HGVS | 3'-most on the reference sequence |
| VCF | leftmost (5'-most on the genome) |
The same deletion is POS=100 in a VCF and c.103del in HGVS. Both correct. Converting between them without renormalizing at each step loses variants silently.
For VCF output, use a 5'-shifting normalizer:
hn5 = hgvs.normalizer.Normalizer(hdp, shuffle_direction=5)
3. Assembly confusion
NC_000017.10:g.7579472G>C GRCh37
NC_000017.11:g.7676154G>C GRCh38
Same variant, ~97,000 bp apart. Mixing assemblies within one analysis produces coordinates that are simply wrong, and nothing errors.
- Track the assembly explicitly, in a column, in every table.
- Use
NC_accessions, neverchr17— the accession version encodes the assembly. - Validation catches only ~75% of assembly mixups, because a wrong position matches the claimed ref base by chance about a quarter of the time.
- Transcript projection is not liftover. Use CrossMap or liftOver with official chain files.
4. Accession versions matter
NM_000546.5 and NM_000546.6 can number the same base differently — a UTR revision shifts every c. coordinate downstream of it.
Never strip a version to make a lookup succeed. If UTA lacks the version you have, that is information: your data and your UTA snapshot disagree. Record the substitution explicitly if you make one.
5. There is no "the" transcript
A gene has many transcripts. A genomic variant has a different c. and p. description in each, sometimes with different consequence classes.
- Use MANE Select as the default.
- Respect gene-specific clinical conventions where they exist.
- Record the transcript in every output row.
- Consider reporting all consequences with MANE flagged.
6. p.(...) is a prediction
The parentheses are HGVS's marker for inferred rather than observed. c_to_p applies the codon table and knows nothing about nonsense-mediated decay, alternative splicing, readthrough, or reinitiation.
Preserve the parentheses. Do not present a predicted protein change as an observation.
7. Minus-strand genes shift the other way
"3'-most" is relative to the reference sequence in use. For a minus-strand gene, shifting 3' on the transcript means moving to lower genomic coordinates.
Consequence: normalize after projecting, in the target coordinate system. Normalizing once at the start and assuming it carries through is wrong.
8. Intronic offsets are not coordinates
c.87+3 and c.87 are different positions. c.12 and c.*12 are different positions. Code that reads posedit.pos.start.base without also checking offset and datum is wrong.
pos.base; pos.offset; pos.datum # all three, always
See SequenceVariant.
9. Constructing objects in a loop
# catastrophic
for s in variants:
hp = hgvs.parser.Parser() # compiles a grammar
hdp = hgvs.dataproviders.uta.connect() # opens a DB connection
am = hgvs.assemblymapper.AssemblyMapper(hdp, ...) # caches transcripts
Build once, reuse. This alone is often a 100× difference.
10. The public UTA instance in production
It is a shared courtesy service. Batch work through it is slow, unreliable, and antisocial. Run UTA and SeqRepo locally for anything beyond exploration. See UTA and Data Providers and SeqRepo.
11. Regex-parsing HGVS
re.match(r"c\.(\d+)([ACGT])>([ACGT])", s) # handles ~40% of real variants
Fails on indels, intronic offsets, UTR positions, repeats, delins, protein notation, and phased alleles. Worse, it partially matches malformed input rather than rejecting it.
String manipulation of HGVS is acceptable only for coarse filtering (which accession? is it coding?). Never for semantics. Use hgvs Parser.
12. Insertions are not a coordinate shift
HGVS c.76_77insT names the flanking bases. VCF anchors on the preceding base and includes it in both alleles (REF=A ALT=AT). BED uses a zero-width interval.
These are different representations, not different coordinate origins. Converting requires a library, not arithmetic.
13. ins where dup is required
If the inserted sequence duplicates the immediately preceding bases, HGVS requires dup. Normalization fixes this — another reason to normalize.
14. Losing phase
c.[215C>G];[847C>T] (in trans) and c.[215C>G;847C>T] (in cis) are clinically different: two LoF variants in trans in a recessive gene is a diagnosis; in cis it is carrier status. Most pipelines discard the brackets.
15. Silent failures in batch processing
try:
...
except Exception:
pass # never do this
Classify errors by type, count them, and inspect the patterns. Failures cluster around systematic causes; the pattern is the diagnosis.
16. Not recording provenance
Three version numbers determine your output and all three must be in it:
print(f"hgvs={hgvs.__version__} "
f"uta={hdp.data_version()} "
f"seqrepo={os.environ.get('HGVS_SEQREPO_DIR')} "
f"assembly={am.assembly_name} "
f"aln_method=splign")
Pinning your Python packages is not enough — the data versions change the answers. See Applied - Reproducible Environment.
17. Confusing validation with interpretation
A valid variant is one that exists as described. It says nothing about pathogenicity, sequencing quality, or clinical relevance.
18. Pseudoautosomal regions
PAR loci exist identically on both X and Y. Without in_par_assume="X", projection raises. With it, you have made an assumption — record it.
The defensive checklist
Before trusting variant output, confirm:
- Every variant parsed, or the failure was recorded
- Every variant validated against the reference sequence
- Every variant normalized, in its final coordinate system
- The assembly is tracked in a column, not assumed
- Accession versions are preserved
- The transcript used is recorded per row
-
p.predictions are labelled as predictions - hgvs, UTA and SeqRepo versions are in the output
- Error counts are reported by category
- A known-answer set round-trips correctly
A regression test worth writing
KNOWN = [
# (c. variant, GRCh38 g., p.)
("NM_000546.6:c.215C>G", "NC_000017.11:g.7676154G>C", "NP_000537.3:p.(Pro72Arg)"),
("NM_000492.4:c.1521_1523del", None, "NP_000483.3:p.(Phe508del)"),
]
def test_projection(am, hp):
for c, g, p in KNOWN:
var = hp.parse_hgvs_variant(c)
if g:
assert str(am.c_to_g(var)) == g, f"{c} → {am.c_to_g(var)}, expected {g}"
if p:
assert str(am.c_to_p(var)) == p
Run it whenever you change UTA snapshot, SeqRepo snapshot, hgvs version, or assembly. Two well-known variants take seconds to check and catch configuration errors that would otherwise reach a report.
See also
biocommons hgvs · hgvs Normalizer · hgvs Validator · AssemblyMapper · HGVS Nomenclature · Coordinate Systems · Applied - Variant Normalization Pipeline · Applied - Reproducible Environment