HGVS Parser
hgvs Parser
Core Principle:
HGVSParseruses a formal Parsing Expression Grammar (PEG) to parse HGVS strings into typedSequenceVariantobject trees without requiring external database connections.
Basic use
'NM_000546.6:c.215C>G'
Construct the parser once and reuse it. Parser() compiles a PEG grammar, which takes on the order of a second. Doing it inside a loop over 10,000 variants is a self-inflicted 3-hour job.
Why not regex
A regex like r"c\.(\d+)([ACGT])>([ACGT])" handles the simplest case and fails on everything else:
c.215C>G substitution
c.76_78del range deletion
c.76_77insT insertion between bases
c.-24+5G>A intronic, in the 5'UTR
c.*12-8del intronic, in the 3'UTR
c.76TG[8] a tandem repeat
c.[215C>G;847C>T] two variants in cis
p.(Arg175His) a predicted protein change
p.Arg175ProfsTer13 a frameshift with a downstream stop
g.7676154_7676156delinsAC a delins
The HGVS grammar handles all of these. It also rejects malformed input with a HGVSParseError rather than silently matching a prefix: which is the property that actually matters in a pipeline. A regex that half-matches gives you wrong data with no error.
Error handling
This code completed without producing output.
HGVSParseError means the string is not valid HGVS syntax. It says nothing about whether the variant is real: that is hgvs Validator's job. A parse succeeding tells you the string is well-formed, not that base 215 of that transcript is actually a C.
Parsing thousands of clinician-entered strings, expect a 5–20% failure rate from legacy notation, typos, and Excel damage. Log the failures; do not discard them silently.
Parsing sub-components
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/hgvs/parser.py", line 127, in rule_fxn
return self._grammar(s).__getattr__(rule_name)()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/parsley.py", line 98, in invokeRule
raise err
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/ometa/runtime.py", line 554, in many
v, _ = fn()
~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/hgvs/generated/hgvs_grammar.py", line 3250, in _G_many1_894
_G_apply_895, lastError = self._apply(self.rule_digit, "digit", [])
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/ometa/runtime.py", line 495, in _apply
[rule(), self.input])
~~~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/ometa/runtime.py", line 790, in digit
x, e = self.rule_anything()
~~~~~~~~~~~~~~~~~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/ometa/runtime.py", line 813, in rule_anything
h, p = self.input.head()
~~~~~~~~~~~~~~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/ometa/runtime.py", line 237, in head
raise EOFError(data, self.position + 1)
ometa.runtime.EOFError:
c.215
^
Parse error at line 2, column 0: end of input. trail: [p_hgvs_position hgvs_position]
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 128, in run_cell_capture
value = eval(compile(tail, "<cell>", "eval"), namespace)
File "<cell>", line 6, in <module>
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/hgvs/parser.py", line 129, in rule_fxn
raise HGVSParseError(
...<3 lines>...
)
hgvs.exceptions.HGVSParseError: c.215: char 6: end of input
Useful when you have the accession in one column and the change in another: a common shape for spreadsheets from clinical labs.
Round-tripping and normalising formatting
'NM_000546.6:c.215C>G'
Parsing then re-stringifying gives you canonical formatting: consistent spacing, consistent representation. It does not give you canonical coordinates; that requires hgvs Normalizer and sequence data.
This code completed without producing output.
The config controls how variants are rendered: for example whether c.76_78delCTT prints the deleted bases or just c.76_78del.
Batch parsing
import pandas as pd
from hgvs.exceptions import HGVSParseError
hp = hgvs.parser.Parser()
def parse_column(series):
parsed, errors = [], []
for s in series:
try:
parsed.append(hp.parse_hgvs_variant(s))
errors.append(None)
except HGVSParseError as e:
parsed.append(None)
errors.append(str(e))
return pd.DataFrame({"variant": parsed, "parse_error": errors},
index=series.index)
result = df.join(parse_column(df["hgvs"]))
print(f"{result['parse_error'].notna().sum()} of {len(result)} failed to parse")
print(result.loc[result["parse_error"].notna(), "hgvs"].head(20).tolist())
Printing the failures is the important line: the failure patterns usually reveal one systematic problem (a missing accession prefix, a legacy IVS notation) that you can fix in bulk.
Cleaning before parsing
Common fixable problems in real-world data:
def tidy(s):
s = str(s).strip()
s = s.replace("–", "-").replace("—", "-") # en/em dash → hyphen
s = s.replace(">", ">") # HTML-escaped >
s = re.sub(r"\s+", "", s) # internal whitespace
if ":" not in s and default_accession:
s = f"{default_accession}:{s}" # add a missing accession
return s
Word processors substituting en-dashes for hyphens is a genuinely common cause of parse failures in variant lists pasted from documents.
Do not "fix" the semantics. Repairing whitespace and dashes is safe. Rewriting c.76_77insG to c.76dup is a normalization decision that belongs to hgvs Normalizer with sequence data behind it, not to a cleanup regex.
Performance
Parsing is pure computation: no network, no database: and runs at roughly 1,000–10,000 variants per second depending on complexity. It will not be your bottleneck. Projection and normalization, which hit UTA and Data Providers, will be.
Common mistakes
- Constructing
Parser()in a loop. - Regex instead of the parser.
- Assuming a successful parse means a valid variant. Use hgvs Validator.
- Discarding parse failures instead of logging them.
- Stripping the accession version during cleanup.
- Catching bare
Exceptionand masking real bugs. CatchHGVSParseError. - Rewriting semantics during cleanup.
- Expecting round-tripping to normalize coordinates. It normalizes formatting only.
See also
SequenceVariant · HGVS Nomenclature · hgvs Normalizer · hgvs Validator · hgvs easy · String Accessor
Exercise
The hgvs package is not available in this sandbox, so
instead of calling Parser.parse_hgvs_variant you will build the small,
strict regex parser the lesson argues against: and discover exactly where it
stops. The point is not that regex parsing is a good idea; it is that you can
only judge the grammar once you have written its cheap imitation.
Write parse_variants(lines), taking the list of input lines and returning a
list of output lines. Ignore blank lines. A line is accepted only if it matches
<accession>:<type>.<posedit> in full, where the accession is two letters, an
underscore, digits, a dot and a version number; the type is one of g, c,
n, m; the position is a single position or start_end; and the edit is one
of del, dup, ins, inv, delins (each optionally carrying bases) or a
substitution X>Y. A substitution must carry a single position and an
insertion must carry a range: reject either otherwise. Emit
accession|type|start|end|edit_type for an accepted line, using the start
position again as the end when only one was given and reporting delins rather
than del for a delins. Emit <line>|parse_error for anything else, which
includes tandem repeats and phased alleles: they are valid HGVS that this
grammar cannot reach. Finish with a line parsed=<n> failed=<m>, which is the
only output when the input is empty.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | NM_000546.6:c.215C>G NM_000546.6:c.76_78del NM_000546.6:c.76_77insT NM_000546.6:c.76_78delinsAC NC_000017.11:g.7676154G>C NM_000546.6:c.-24+5G>A NM_000546.6:c.*12-8del NM_000546.6:c.76_78dup NM_000546.6:c.76_78inv | NM_000546.6|c|215|215|sub NM_000546.6|c|76|78|del NM_000546.6|c|76|77|ins NM_000546.6|c|76|78|delins NC_000017.11|g|7676154|7676154|sub NM_000546.6|c|-24+5|-24+5|sub NM_000546.6|c|*12-8|*12-8|del NM_000546.6|c|76|78|dup NM_000546.6|c|76|78|inv parsed=9 failed=0 |
| 2 | stdin | c.215C>G NM_000546:c.215C>G NM_000546.6:x.215C>G NM_000546.6:c.215C>G>T NM_000546.6:c.215X>G NM_000546.6:c.215_216C>G NM_000546.6:c.76insT NM_000546.6:c.76TG[8] NM_000546.6:c.[215C>G;847C>T] NM_000546.6:c.215 | c.215C>G|parse_error NM_000546:c.215C>G|parse_error NM_000546.6:x.215C>G|parse_error NM_000546.6:c.215C>G>T|parse_error NM_000546.6:c.215X>G|parse_error NM_000546.6:c.215_216C>G|parse_error NM_000546.6:c.76insT|parse_error NM_000546.6:c.76TG[8]|parse_error NM_000546.6:c.[215C>G;847C>T]|parse_error NM_000546.6:c.215|parse_error parsed=0 failed=10 |
| 3 | stdin | NM_000546.6:c.76_78delinsAC NM_000546.6:c.76_78delCTT | NM_000546.6|c|76|78|delins NM_000546.6|c|76|78|del parsed=2 failed=0 |
| 4 | stdin | parsed=0 failed=0 |
Hints · 3
01Hint
Anchor the match with ^...$ or use re.fullmatch, so a half-match is a rejection rather than a truncated parse.
02Hint
A position is \*?-?\d+(?:[+-]\d+)? — that covers 215, -24, *12, 87+3 and -24+5.
03Hint
Test the edit patterns in order with delins before del, or every delins parses as a deletion.