HGVS Validator
hgvs Validator
Core Principle:
HGVSValidatorconducts intrinsic syntax checks and extrinsic reference-sequence validation to confirm that variant coordinates and reference alleles agree with underlying biological models.
Parsing tells you a string is well-formed. Validation tells you it describes something that exists.
The two levels
import hgvs.validator
hv = hgvs.validator.Validator(hdp)
hv.validate(var) # both levels; raises on failure
hv.validate(var, strict=False) # returns False instead of raising
| Validator | Needs data? | Checks |
|---|---|---|
IntrinsicValidator |
no | internal consistency: start ≤ end, ref present where required, sensible edit for the type |
ExtrinsicValidator |
yes | the accession exists, positions are within the sequence, ref bases match the actual sequence |
Validator |
yes | both |
from hgvs.validator import IntrinsicValidator, ExtrinsicValidator
iv = IntrinsicValidator() # fast, offline
ev = ExtrinsicValidator(hdp) # needs UTA + SeqRepo
Running IntrinsicValidator first as a cheap filter is worthwhile on large batches: it catches malformed variants without a database round-trip.
What extrinsic validation catches
hv.validate(hp.parse_hgvs_variant("NM_000546.6:c.215A>G"))
# HGVSInvalidVariantError: Variant reference (A) does not agree with
# reference sequence (C)
That single check catches the four most common and most dangerous data errors:
- Wrong assembly. A GRCh37 coordinate interpreted as GRCh38 lands on a different base.
- Wrong accession version.
NM_000546.5and.6can number differently. - Strand error. A minus-strand gene where someone forgot to reverse-complement: the ref base comes out as its complement.
- A genuinely corrupt record. Transcription errors, Excel damage, copy-paste across rows.
All four are silent without validation, and all four produce results that look completely normal downstream.
The exception types
This code completed without producing output.
Classifying by exception type is what turns "1,200 variants failed" into "1,150 are on transcripts absent from our UTA snapshot, 40 have ref mismatches, 10 are out of range": three different problems with three different fixes.
Where validation belongs
var = hp.parse_hgvs_variant(s) # 1. syntax
hv.validate(var) # 2. reality ← HERE
var = hn.normalize(var) # 3. canonical form
var_g = am.c_to_g(var) # 4. projection
Validate before normalizing and projecting. Both of those depend on the reference sequence, so if the ref base is wrong you get a confidently-computed wrong answer rather than an error.
Note that Normalizer(validate=True) and AssemblyMapper(prevalidation_level=...) do some of this for you, but an explicit validation step gives you a clean place to catch and classify failures.
Batch validation
def validate_batch(df, hp, hv, col="hgvs"):
rows = []
for s in df[col]:
try:
var = hp.parse_hgvs_variant(s)
except HGVSParseError as e:
rows.append({"status": "parse_error", "detail": str(e)})
continue
status, detail = validate_one(hv, var)
rows.append({"status": status, "detail": detail})
out = df.join(pd.DataFrame(rows, index=df.index))
print(out["status"].value_counts())
return out
checked = validate_batch(variants, hp, hv)
valid = checked.query("status == 'valid'")
print(f"{len(valid):,} of {len(checked):,} usable ({len(valid)/len(checked):.1%})")
Report the counts, and look at the failure patterns. Failures almost never scatter randomly: they cluster around one systematic cause, and the pattern tells you what it is:
- Nearly all
ref_mismatch→ wrong assembly, or wrong accession versions throughout - Nearly all
no_data→ your UTA snapshot predates the transcript set the file uses - A few
out_of_range→ likely genuine data errors in those rows
Validation is not interpretation
The Validator answers: does this variant exist as described?
It does not answer:
- Is this variant pathogenic? (→ ACMG criteria, ClinVar, InterVar)
- Is it real, or a sequencing artefact? (→ variant caller quality metrics, IGV)
- Is it the best transcript to report on? (→ MANE Select, clinical convention)
- Does the described protein consequence occur? (→ functional data)
A variant can be perfectly valid HGVS, perfectly matched to the reference, and complete nonsense biologically. Validation is a data-integrity check, not a biological one.
Custom checks worth adding
Beyond what the library does:
def extra_checks(var, am, hdp):
warnings = []
# is the accession the MANE Select transcript for its gene?
if var.type == "c" and var.ac not in MANE_TRANSCRIPTS:
warnings.append(f"{var.ac} is not MANE Select")
# is the variant deep intronic? (likely low interpretive value)
off = getattr(var.posedit.pos.start, "offset", 0)
if abs(off) > 20:
warnings.append(f"deep intronic ({off:+d} from the splice site)")
# very large indel: check it is intended
edit = var.posedit.edit
if edit.type in ("del", "delins", "dup"):
span = var.posedit.pos.end.base - var.posedit.pos.start.base + 1
if span > 100:
warnings.append(f"large event ({span} bp)")
return warnings
The MANE check is the most valuable in practice: a variant reported on a non-canonical transcript will not match the literature or ClinVar, even though it is perfectly valid.
Common mistakes
- Skipping validation and computing on bad data.
- Catching bare
Exception, losing the classification. - Validating after normalizing.
- Treating "valid" as "clinically meaningful".
- Not reporting failure counts.
- Not looking at failure patterns.
- Assuming validation catches assembly mixups. It catches the ones where the ref base happens to differ: roughly 3 in 4. The rest pass silently. Track your assembly explicitly; do not rely on validation to catch it.
That last point deserves emphasis. If a GRCh37 coordinate is misread as GRCh38, the base at the wrong position matches the claimed ref about 25% of the time by chance. Validation will catch 75% of such errors: good, but not a substitute for knowing which assembly your data is in.
See also
hgvs Normalizer · hgvs Parser · SeqRepo · AssemblyMapper · hgvs Pitfalls · Applied - Variant Normalization Pipeline
Exercise
The hgvs package is not available in this sandbox, so you
will write the extrinsic check yourself against a small sequence store supplied
on stdin. Validator.validate does exactly this: look the accession up, check
the interval is inside the sequence, compare the stated reference bases with
the real ones: and classifying the failures is the part that turns a batch
report into a diagnosis.
Write validate_all(text), taking the whole of stdin and returning a list of
output lines. Stdin holds <accession> <sequence> lines, then a line --,
then one variant per line. A variant is <accession>:<type>.<posedit> where
the type is g or n, the posedit is a single position or start_end range,
and the edit is either X>Y or del optionally followed by the deleted bases.
Positions index the stored sequence 1-based. Emit <variant> <status> for each
variant, where the status is the first of: parse_error if the string does not
match that shape or a substitution carries a range; no_data if the accession
is absent from the store; out_of_range if the interval falls outside the
sequence or ends before it starts; ref_mismatch if stated reference bases
disagree with the sequence; otherwise valid. A del with no bases spelled
out states no reference and so cannot mismatch. Finish with total=<n> and
then one <status>=<count> line per status that occurred, in alphabetical
order; total=0 alone is the whole output when no variants follow the
--.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | NC_000017.11 GATTACAGGCGTGAGCCACCGCGCCCGGCC NM_000546.6 ATGGAGGAGCCGCAGTCAGATCCTAGCGTC -- NC_000017.11:g.5A>C NM_000546.6:n.4G>A NM_000546.6:n.4C>A NM_000546.6:n.1_3del NM_000546.6:n.1_3delATG NM_000546.6:n.1_3delAAA NM_000546.6:n.99A>C NR_000001.1:n.1A>C NM_000546.6:c.4G>A NM_000546.6:n.4G | NC_000017.11:g.5A>C valid NM_000546.6:n.4G>A valid NM_000546.6:n.4C>A ref_mismatch NM_000546.6:n.1_3del valid NM_000546.6:n.1_3delATG valid NM_000546.6:n.1_3delAAA ref_mismatch NM_000546.6:n.99A>C out_of_range NR_000001.1:n.1A>C no_data NM_000546.6:c.4G>A parse_error NM_000546.6:n.4G parse_error total=10 no_data=1 out_of_range=1 parse_error=2 ref_mismatch=2 valid=4 |
| 2 | stdin | NC_000017.11 GATTACAGGCGTGAGCCACCG -- NC_000017.11:g.20_21del NC_000017.11:g.20_21delCG NC_000017.11:g.21_22del NC_000017.11:g.10_5del NC_000017.11:g.5_6A>C | NC_000017.11:g.20_21del valid NC_000017.11:g.20_21delCG valid NC_000017.11:g.21_22del out_of_range NC_000017.11:g.10_5del out_of_range NC_000017.11:g.5_6A>C parse_error total=5 out_of_range=2 parse_error=1 valid=2 |
| 3 | stdin | -- NC_000017.11:g.5A>C NM_000546.6:n.4G>A | total=0 |
| 4 | stdin | NC_000017.11 GATTACAGGC -- | total=0 |
Hints · 3
01Hint
Convert once at the boundary: the 1-based interval start..end is seq[start - 1:end] in Python.
02Hint
Handle the store as a dict of accession to sequence, built before you touch the variants.
03Hint
Count the statuses in a dict as you go, then emit them with sorted(counts).