ensembl

confirming a variant coordinate: clinvar, ensembl, ucsc, crispor

when working with the human genome, it is useful to confirm all your information using the many public APIs that researchers trust. These are the APIs i use to build custom scripts and programs that help me get information quickly and reliably. worked example throughout is ABCC8 c.3989-9G>A.

the picture, if this is all new: your dna is a book about 3 billion letters long, written in a four-letter alphabet, A C G T. every cell carries a copy. a "variant" is a typo, one letter swapped for another, and some typos cause disease. to fix a typo you have to know exactly which letter, and "the 9th letter before chapter 33" only helps if everyone agrees where the chapters start. they don't always. so the whole job below is turning a vague, edition-dependent address into one exact physical address, then checking it from a few directions so you know it's right before you go cutting.

1. clinvar: variant to genome coordinate

clinvar is a big public logbook of known typos and whether each one is harmless or causes disease. you look yours up and it hands back the typo's exact physical address on the chromosome plus what's known about it. the address style you want is called SPDI, and it is exact on purpose: which chromosome, which position, the letter that's there, the letter it became. one thing that trips everyone up: the gene is written along the chromosome backwards, on the opposite strand, so the same typo reads as G>A when you read the gene but C>T when you read the chromosome. same event, seen from two directions.

pull the record (id 9088 = VCV000009088):

eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=clinvar&id=9088&retmode=json

take the SPDI, NC_000011.10:17397054:C:T. it is 0-based, so that position is chr11:17,397,055 1-based. it reads C>T, not G>A, because ABCC8 is on the minus strand. also grab the classification (pathogenic) and the transcript clinvar anchored to, NM_000352.6.

docs: e-utilities · clinvar help · spdi / variation services

2. ensembl rest: confirm transcript and exon positions

before the cell uses a gene it cuts out chunks and splices the rest together. the kept chunks are exons, the tossed ones are introns. "c.3989-9" means "9 letters before exon 33 starts," which only means something if you and the cell agree on which chunk is exon 33, and there is more than one way to slice a gene. MANE Select is the single slicing that the two big databases, refseq and ensembl, agreed to treat as canonical. lock onto it and your exon numbers match everyone else's. use a different one and "exon 33" might be a different piece of dna and your whole design slides sideways. worth knowing here: this typo sits in an intron right at the seam where it meets an exon. the cell finds that seam by a two-letter flag, AG, and this typo makes a second fake AG next door, so the cell splices in the wrong place and the protein comes out broken. that's the disease.

rest.ensembl.org/lookup/id/ENST00000389817?expand=1

ENST00000389817 is NM_000352.6, and it is MANE Select. the response gives the strand (minus) and every exon's coordinates, which places c.3989-9 nine bases into intron 32, in front of the exon 33 acceptor AG. some genes also carry a MANE Plus Clinical isoform that numbers exons differently, so check for one.

docs: ensembl rest · lookup endpoint · mane

3. ucsc: pull the reference, rebuild the sequence

ucsc just hands you the actual letters of the genome at any address you ask for. this is you reading the neighborhood with your own eyes instead of trusting a diagram someone drew. two counting traps live here. one: computers usually count starting at 0 and stop one short of the end, humans count from 1 and include both ends, and mixing the two is the classic off-by-one. two: because the gene runs backwards, the letters ucsc gives you (always the forward strand) have to be flipped and mirror-imaged, called reverse-complement, to read the way the gene reads.

quick vocab, because the next parts need it. to edit one spot, the machine uses a 20-letter "guide," a homing address that matches your target so the machine knows where to land. and right next to that stretch there has to be a short landing pad, three letters, called a PAM, or the machine can't grip. so a workable edit is a 20-letter window that both contains your typo and has a valid landing pad next to it.

api.genome.ucsc.edu/getData/sequence?genome=hg38;chrom=chr11;start=17397020;end=17397075

that returns the raw dna for the window. reverse-complement it (minus strand) and read off the protospacer AGCCCAGCCCCCAGCACCAT at chr11:17,397,041-060, PAM CGC at 17,397,038-040, target A at protospacer position 6 = chr11:17,397,055. that last number has to equal the clinvar coordinate from step 1.

docs: ucsc rest api

4. crispor: score the guide for off-targets

your 20-letter guide is an address, but the genome is 3 billion letters and your address might partly match other spots too. the machine tolerates a few wrong letters when it homes in, so a guide can weakly grab similar-but-wrong places and edit them by accident. those are "off-targets," and they're the main safety worry. crispor takes your sequence, hunts the whole genome for every near-match, and scores how specific your guide is (few off-targets is good) and how efficient it is likely to be. you want high specificity so you're editing your spot and basically nowhere else.

the first three apis answer instantly. crispor actually runs a compute job (searching 3 billion letters takes a minute), so it is three moves. first, submit the sequence and set the PAM to match your enzyme (NGG for classic SpCas9, NG for SpCas9-NG):

crispor.gi.ucsc.edu/crispor.py?seq=YOUR_SEQUENCE&org=hg38&pam=NGG

the page that comes back contains a batchId, a ticket for the job. the job sits in a queue and runs, and while it runs the download url below returns a server error, that is normal, keep polling. once it finishes, download the scored guides as a tsv:

crispor.gi.ucsc.edu/crispor.py?batchId=YOUR_BATCHID&download=guides&format=tsv

columns are guideId, targetSeq, mitSpecScore, cfdSpecScore, offtargetCount, targetGenomeGeneLocus then efficiency scores. off-targets get their own file with &download=offtargets. one note: the public server is meant for light, interactive use, and the authors ask you to run the standalone command-line or docker version for real batch work. that version is crispor.py hg38 input.fa guides.tsv, with -p/--pam and -o/--offtargets FILE, and you can pass the org as noGenome to get efficiency scores only, which skips the genome download and the off-target search.

docs: crispor · crispor source / cli

the check

the target coordinate you rebuilt in ucsc has to equal the position clinvar gave you. here both are chr11:17,397,055. if they do not match, stop and find out why before you order anything.

pulling it into one script

stdlib only, no keys, no pip. change the three config values at the top for your own variant. this is the exact script i ran to write this post, it prints the coordinate, the strand, the reference window, and the scored guides:

#!/usr/bin/env python3
# confirm a variant's genome coordinate and pull crispr guides for it, using only
# public web apis and the python standard library. no api keys, no pip installs.
# change the three CONFIG values to point at your own variant.

import json, re, time, urllib.parse, urllib.request, urllib.error

# ---- config: the only three things you change per variant ----
CLINVAR_ID = "9088"             # numeric id of the clinvar record (VCV000009088)
TRANSCRIPT = "ENST00000389817"  # ensembl transcript (= refseq NM_000352.6, MANE Select)
CHROM      = "chr11"            # ucsc chromosome name for this gene

def get_json(url):              # fetch a url and parse the json body
    with urllib.request.urlopen(url, timeout=30) as r:
        return json.load(r)

def get_text(url, timeout=90):  # fetch a url as plain text
    with urllib.request.urlopen(url, timeout=timeout) as r:
        return r.read().decode("utf-8", "replace")

# 1. CLINVAR: turn the variant into one exact, absolute coordinate.
#    esummary hands back a record; the field we want is the canonical SPDI,
#    which is chromosome:0-based-position:reference-letter:new-letter.
rec = get_json("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
               f"?db=clinvar&id={CLINVAR_ID}&retmode=json")["result"][CLINVAR_ID]
spdi = rec["variation_set"][0]["canonical_spdi"]      # e.g. NC_000011.10:17397054:C:T
_acc, pos0, ref, alt = spdi.split(":")
pos1 = int(pos0) + 1                                  # SPDI is 0-based; +1 to human 1-based
verdict = rec["germline_classification"]["description"]
print(f"[clinvar]  {spdi}  ->  {CHROM}:{pos1} {ref}>{alt}  ({verdict})")

# 2. ENSEMBL: confirm the transcript and get its strand + exon layout, so the
#    c. numbering actually lines up. strand -1 means the gene reads backwards.
tx = get_json(f"https://rest.ensembl.org/lookup/id/{TRANSCRIPT}"
              "?expand=1;content-type=application/json")
strand = tx["strand"]
print(f"[ensembl]  {tx['display_name']}  strand={strand}  exons={len(tx['Exon'])}")

# 3. UCSC: pull the actual reference letters around the variant and orient them.
#    the api is 0-based half-open. if the gene is on the minus strand, flip the
#    letters (reverse-complement) so they read the way the gene / guide reads.
start0, end0 = pos1 - 61, pos1 + 39                   # 0-based window around the variant
plus = get_json("https://api.genome.ucsc.edu/getData/sequence"
                f"?genome=hg38;chrom={CHROM};start={start0};end={end0}")["dna"].upper()
def revcomp(s): return s.translate(str.maketrans("ACGT", "TGCA"))[::-1]
oriented = plus if strand == 1 else revcomp(plus)
print(f"[ucsc]     + strand: {plus}")

# 4. CRISPOR: score guides in this window for genome-wide off-targets. unlike the
#    first three (instant answers), this runs a compute job, so it is three moves:
#    submit -> get a ticket (batchId) -> poll -> download results as tsv.
pam = "NGG"                                           # match your enzyme (NG for SpCas9-NG, etc.)
sub = get_text("https://crispor.gi.ucsc.edu/crispor.py"
               f"?seq={urllib.parse.quote(plus)}&org=hg38&pam={pam}")
batch = re.search(r"batchId=([A-Za-z0-9]+)", sub).group(1)
print(f"[crispor]  batchId={batch}  (waiting on the genome-wide off-target search)")
dl = f"https://crispor.gi.ucsc.edu/crispor.py?batchId={batch}&download=guides&format=tsv"
tsv = ""
for _ in range(30):                                   # poll up to ~10 minutes
    try:
        tsv = get_text(dl)
    except urllib.error.HTTPError:                     # job not finished -> server 500s; keep waiting
        time.sleep(20); continue
    if tsv.lstrip().startswith(("#guideId", "guideId")):
        break
    time.sleep(20)
rows = [r for r in tsv.splitlines() if r and not r.startswith("#")]
print(f"[crispor]  {len(rows)} guides scored")
print(f"[crispor]  columns: guideId targetSeq mitSpecScore cfdSpecScore offtargetCount locus ...")
print(f"[crispor]  first guide: {rows[0].split(chr(9))[:4]}")

running it on the example prints the clinvar coordinate as pathogenic, ensembl strand -1 with 39 exons, the ucsc reference window, and roughly 20 crispor-scored guides. the whole point: several independent sources agree on one coordinate before you design anything, and crispor tells you whether your guide hits that spot and mostly nowhere else.

last updated 07/08/26