# Ensembl REST API Cross References: Step-by-Step Guide

By the end of this guide you will be able to take any Ensembl stable identifier and retrieve its linked identifiers in HGNC, RefSeq, UniProt, Entrez Gene, OMIM, and other external databases using the Ensembl REST API. You will know how to filter results, batch requests politely, map a variant back to its gene, and verify every mapping in the [Ensembl genome browser](/blog/guides/ensembl-genome-browser) before you trust it in a pipeline.

Here is what you need on hand:

- A computer with `curl` available on the command line (macOS and Linux ship with it, Windows 10 and later include it in the terminal)
- Python 3.7 or later if you want the scripted workflow
- Optional but useful: `jq` for slicing JSON on the command line
- No API key, no installation, and no authentication for basic use

A concrete example of why this matters: a 2026 pharmacogenomic study profiling clinically actionable anticancer drug variants in Nigerian cohorts retrieved allele counts and frequencies for variants such as CYP2D6*4, CYP3A5*3, CYP3A5*6, CYP3A5*7, four DPYD variants, and TPMT*3C from the 1000 Genomes Project Phase 3 data using the Ensembl REST API and genome browser [1]. The researchers worked with 207 Nigerian samples, 108 Yoruba and 99 Esan individuals, and linked those variants to drug-gene pairs like CYP2D6 with tamoxifen and DPYD with 5-fluorouracil and capecitabine [1]. Every one of those variants and genes was tied together through identifier mapping of exactly the kind this guide teaches.

## What Cross References Are and Why They Matter

A cross reference (often shortened to xref) is a recorded link between an Ensembl database entry and the same or closely related entry in an external database. An **API** (application programming interface) is a machine-readable service that programs call over HTTP, and **REST** (representational state transfer) is the common web style where each resource lives at a stable URL and returns [structured data](/blog/data-analysis/what-is-structured-data), usually **JSON** (JavaScript Object Notation, a plain-text key-value format).

Ensembl, which is produced by EMBL-EBI and the Wellcome Sanger Institute, assigns every gene, transcript, and protein in its human annotation a **stable identifier**: ENSG for genes, ENST for transcripts, ENSP for proteins, followed by a number, for example ENSG00000100197. Other databases assign their own identifiers to the same biological entities:

- **HGNC** (HUGO Gene Nomenclature Committee) assigns official gene symbols and HGNC ID numbers
- **RefSeq**, maintained by NCBI (National Center for Biotechnology Information), assigns curated accession numbers like NM_000106 for transcripts
- **UniProt** maintains curated protein identifiers like P10635
- **Entrez Gene**, also from NCBI, assigns numeric gene IDs
- **OMIM** (Online [Mendelian Inheritance](/knowledge/molecular-biology/mendel-heredity-laws-of-inheritance-explained-simply) in Man) catalogs gene-disease relationships

Cross references are one-to-many. A single Ensembl gene typically links to one HGNC entry, several RefSeq transcripts, several UniProt entries, and various disease and drug records. Mappings also change. When annotation updates, a stable ID can be retired and merged into a new one, and external databases can add, remove, or rename records. Any pipeline that converts between identifier systems inherits these problems, which is why retrieval, filtering, and verification all matter.

## How the Ensembl REST API Is Organized

The vertebrate REST service lives at `https://rest.ensembl.org`. You build a URL from an endpoint path plus query parameters, send an HTTP GET or POST request, and receive JSON back. Two conventions matter from the first request onward:

1. Set the response format explicitly with the `content-type` query parameter, most often `content-type=application/json`. Some endpoints also accept `text/xml` or, for sequence endpoints, `text/x-fasta`.
2. Stay under the documented rate limit. Unauthenticated requests are limited to about 15 per second. The response headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` tell you where you stand, and a registered free Ensembl account raises the ceiling for heavier workloads.

Ensembl Genomes, which covers plants, fungi, bacteria, and non-vertebrate metazoa, runs a parallel service at `https://rest.ensemblgenomes.org` with the same endpoint patterns. If your species is a vertebrate, use `rest.ensembl.org`. If you request `rest.ensembl.org` with a plant species, you will get an error, and that confusion is one of the most common beginner failures.

The cross-reference family of endpoints is small and memorable:

| Endpoint | Direction | What it returns |
|--|--|--|
| `/xrefs/id/:species/:id` | Ensembl ID to external | All linked external database entries |
| `/xrefs/symbol/:species/:symbol` | Symbol to Ensembl objects | Genes and transcripts matching a symbol |
| `/xrefs/name/:species/:name` | Name to Ensembl objects | Matches by display name |
| `/xrefs/drug/:species/:symbol` | Gene symbol to drugs | Drug records linked to a gene |
| `/xrefs/disease/:species/:symbol` | Gene symbol to disease | Disease records linked to a gene |
| `/lookup/symbol/:species/:symbol` | Symbol to Ensembl object | Full Ensembl record for the gene |

## Quick Start: Your First Cross Reference Lookup in One Command

Open a terminal and request every external cross reference for the human CYP2D6 gene, a pharmacogene you will see again later in this guide:

```bash
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/ENSG00000100197?content-type=application/json"
```

You get back a JSON array. Abridged, it looks like this:

```json
[
  {
    "primary_id": "HGNC:2625",
    "display_id": "CYP2D6",
    "dbname": "HGNC",
    "info": {
      "synonyms": ["CYP2D6", "CYP2D", "CPD6"]
    }
  },
  {
    "primary_id": "1565",
    "display_id": "1565",
    "dbname": "EntrezGene"
  },
  {
    "primary_id": "P10635",
    "display_id": "P10635",
    "dbname": "Uniprot_gn"
  }
]
```

The three fields you will use most are `primary_id` (the identifier in the external database), `display_id` (the human-readable label), and `dbname` (which database the entry came from). If you have `jq` installed, you can extract just the RefSeq accessions in one line:

```bash
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/ENSG00000100197?content-type=application/json" | jq '.[] | select(.dbname | startswith("RefSeq")) | .primary_id'
```

That is the whole game. The rest of this guide turns it into a reliable workflow.

## Step-by-Step: The Complete Cross Reference Workflow

![Five step flow from symbol resolution through xref fetch, database filter, all=1 expansion and browser verification.](/article-images/illustrations/ensembl-rest-api-cross-references-xref-workflow.webp)

### Step 1: Resolve a Gene Symbol to a Stable ID

Most analyses start from a symbol, not a stable ID. Symbols are reused and redefined over time, so resolve yours to an Ensembl stable ID before anything else:

```bash
curl -s "https://rest.ensembl.org/lookup/symbol/homo_sapiens/CYP2D6?content-type=application/json"
```

Abridged output:

```json
{
  "id": "ENSG00000100197",
  "display_name": "CYP2D6",
  "biotype": "protein_coding",
  "assembly_name": "GRCh38",
  "seq_region_name": "22",
  "start": 42539395,
  "end": 42545886,
  "strand": -1,
  "species": "homo_sapiens",
  "object_type": "gene"
}
```

Every field here is useful. `id` is the stable identifier you feed to the xref endpoints. `assembly_name` tells you the reference assembly, GRCh38 for current human annotation, so you can interpret coordinates consistently across your project. `strand` of -1 means the gene runs on the reverse strand. `biotype` distinguishes protein-coding genes from pseudogenes and non-coding RNAs, which matters because CYP2D6 has known pseudogene copies in the same region.

If the symbol exists in more than one species, add `?all_species=1` to see every match and pick deliberately.

### Step 2: Fetch All Cross References for the Stable ID

```bash
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/ENSG00000100197?content-type=application/json"
```

Read the result with a plan. Each element is one external record. Expect a mix of HGNC, EntrezGene, RefSeq, UniProt, OMIM, and CCDS entries. The full list for a well-annotated gene runs from a handful to dozens of records. Do not assume any single element is "the" identifier for the gene. The relationship is one-to-many by design.

### Step 3: Filter by External Database

Unfiltered output buries you in records you may not need. The `external_db` parameter filters to one database, using Ensembl's internal database name:

```bash
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/ENSG00000100197?external_db=HGNC&content-type=application/json"
```

Expected output for this example is a single record:

```json
[
  {
    "primary_id": "HGNC:2625",
    "display_id": "CYP2D6",
    "dbname": "HGNC"
  }
]
```

The database name must match Ensembl's naming exactly. Common values include `HGNC`, `EntrezGene`, `Uniprot_gn`, `RefSeq_mRNA`, `OMIM`, and `CCDS`. A misspelled name does not produce an error. It returns an empty array and you walk away thinking the mapping does not exist. This silent failure mode deserves its own place in the mistakes section below, because it has consumed more debugging hours than any API outage ever will.

### Step 4: Pull Transcript and Protein Cross References with all=1

By default, `/xrefs/id` returns cross references attached directly to the object you named. Many RefSeq transcript and UniProt protein mappings attach to Ensembl transcripts and proteins instead of the gene record. To collect those, add `all=1`:

```bash
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/ENSG00000100197?all=1&content-type=application/json"
```

The result set grows, now including transcript-level RefSeq accessions and protein-level UniProt entries associated with the gene's transcripts. Compare the counts with and without `all=1` for your gene of interest. If you are building a gene-to-protein mapping table, `all=1` is not optional. If you only want the canonical gene-level identifiers, the default is cleaner.

### Step 5: Batch Lookups Without Breaking the Rate Limit

Real projects never map one gene. For dozens or hundreds of identifiers, write a throttled loop rather than a bare `for` loop that fires requests as fast as the CPU allows:

```python
import json
import time
import requests

ENSEMBL = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}

def get_xrefs(ensembl_id, external_db=None):
    url = f"{ENSEMBL}/xrefs/id/homo_sapiens/{ensembl_id}"
    params = {}
    if external_db:
        params["external_db"] = external_db
    r = requests.get(url, headers=HEADERS, params=params, timeout=30)
    time.sleep(0.1)          # 10 requests per second, well under the cap
    if r.status_code == 200:
        return r.json()
    if r.status_code == 429:
        retry = int(r.headers.get("Retry-After", "1"))
        time.sleep(retry)
        return get_xrefs(ensembl_id, external_db)
    return {"error": r.status_code}

ids = ["ENSG00000100197", "ENSG00000146648", "ENSG00000012048"]
results = {}
for stable_id in ids:
    results[stable_id] = get_xrefs(stable_id, external_db="HGNC")

with open("xrefs.json", "w") as f:
    json.dump(results, f, indent=2)
```

This script fetches HGNC mappings for CYP2D6, EGFR (ENSG00000146648), and BRCA1 (ENSG00000012048), sleeps a tenth of a second between requests, honors the `Retry-After` header when it hits a 429 (too many requests) response, and writes the dictionary to `xrefs.json`. The Ensembl API also accepts batched POST requests for some endpoints, including `/lookup/id`, which is worth reading up on in the official documentation once your identifier lists grow into the thousands.

Check the output file before trusting it:

```bash
jq 'keys' xrefs.json
jq '.["ENSG00000146648"][] | select(.dbname == "HGNC") | .display_id' xrefs.json
```

The second command should print `EGFR`, which is your first cross-check that the mapping resolved correctly.

### Step 6: Look Up Drug and Disease Cross References

Two specialized endpoints return therapeutic and disease links for a gene symbol, which is precisely the framing pharmacogenomic work needs:

```bash
curl -s "https://rest.ensembl.org/xrefs/drug/homo_sapiens/CYP2D6?content-type=application/json"
curl -s "https://rest.ensembl.org/xrefs/disease/homo_sapiens/CYP2D6?content-type=application/json"
```

The drug endpoint returns records for drugs associated with the gene product, and for CYP2D6 that list includes substrates and modulators such as codeine and tamoxifen. Each entry carries `display_id` for the drug or disease name and a `dbname` field identifying the source. Treat these as pointers to investigate, not as complete clinical evidence. Dose decisions and drug selection belong to clinical decision support resources and treating clinicians, not to a cross-reference dump.

### Step 7: Map a Variant Back to Its Gene

Cross references run in both directions, and variant-level work is where pharmacogenomics lives. The rs3892097 variant defines the CYP2D6*4 allele, the best-known poor-function CYP2D6 allele relevant to tamoxifen response [1]. To connect a variant ID to its gene and consequence, use the Variant Effect Predictor (VEP) REST endpoint:

```bash
curl -s -H "Content-Type: application/json" -d '{"variants":["rs3892097"]}' "https://rest.ensembl.org/vep/homo_sapiens/id?content-type=application/json"
```

The response includes, for each variant, the transcripts overlapped, the consequence terms (for example, a stop-gained or missense annotation), the affected Ensembl gene and transcript IDs, and protein-level changes. From the gene ID in that response, the `/xrefs/id` workflow from Step 2 takes over and lands you on the gene's external identifiers. This symbol-to-gene-to-xref chain, with a variant at the front, is exactly the chain the Nigerian pharmacogenomic profiling study ran across its variant panel [1].

### Step 8: Verify in the Ensembl Genome Browser

Never let a script be your only source of truth. The Ensembl genome browser is the fastest independent check, and this short walkthrough doubles as a general "how to use Ensembl" orientation:

1. Go to `https://www.ensembl.org` and type your symbol or stable ID into the search box. For `ENSG00000100197` the site redirects straight to the gene page.
2. On the gene page, find the left-hand navigation menu and open the **External references** section. This is the browser's rendering of the same cross references the REST endpoint returns.
3. Confirm the identifiers your script produced actually appear there. For CYP2D6 you should see the HGNC entry, RefSeq accessions, UniProt, and OMIM links.
4. Note the assembly label on the page. If your pipeline coordinates came from GRCh37 and the browser is showing GRCh38, coordinate-based reasoning needs a liftover step before you compare them.
5. For bulk checks, BioMart (`https://www.ensembl.org/biomart`) exports attribute tables including external references, which makes a good second opinion on mapping counts.

If the browser and the API agree, your mapping is almost certainly correct. If they disagree, the API result usually reflects a more recent release, and the discrepancy itself is a finding worth recording.

### Parameter Reference

The parameters below cover nearly every decision you will make against the xref endpoints:

| Parameter | Applies to | Effect | Example value |
|--|--|--|--|
| `content-type` | All endpoints | Sets response format | `application/json` |
| `external_db` | `/xrefs/id`, `/xrefs/symbol` | Restricts results to one external database | `HGNC` |
| `all` | `/xrefs/id` | Includes transcript and protein level cross references | `1` |
| `expand` | `/lookup/symbol`, `/xrefs/symbol` | Includes transcripts, exons, and translations in the response | `1` |
| `all_species` | `/xrefs/symbol` | Returns matching symbols across species | `1` |

One rule of thumb covers filtering: request the unfiltered list once while developing, inspect the `dbname` values Ensembl actually uses for your gene, then pin your pipeline to those exact strings.

## A Worked Example from Population Pharmacogenomics

To see the whole workflow carrying real scientific weight, consider the study by Ukpeh and colleagues profiled earlier in this guide. The researchers needed population allele frequencies for anticancer pharmacogenomic variants in Nigerian cohorts, so they retrieved allele counts and frequencies from the 1000 Genomes Project Phase 3 data through the Ensembl REST API and genome browser [1]. Their variant panel covered Level 1A evidence pharmacogenes from the Clinical Pharmacogenetics database: CYP2D6*4 for tamoxifen, CYP3A5*3, CYP3A5*6, and CYP3A5*7 for tacrolimus, four DPYD variants for 5-fluorouracil and capecitabine, and TPMT*3C for thiopurines [1]. The populations of interest were 207 Nigerian individuals in the 1000 Genomes Project, 108 Yoruba and 99 Esan, with Hardy-Weinberg equilibrium checks and cross-population comparisons built on top of the retrieved frequencies [1].

Reproduce the first link in that chain for yourself. Resolve CYP2D6, pull its cross references, then interrogate the *4 allele:

```bash
# Gene lookup
curl -s "https://rest.ensembl.org/lookup/symbol/homo_sapiens/CYP2D6?content-type=application/json"

# HGNC cross reference
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/ENSG00000100197?external_db=HGNC&content-type=application/json"

# Variant annotation
curl -s "https://rest.ensembl.org/vep/homo_sapiens/id/rs3892097?content-type=application/json"
```

The VEP response confirms rs3892097 maps to CYP2D6 on chromosome 22 and reports its transcript-level consequence. From there, population-level frequency retrieval, the step the cited study performed for its cohort of 207 Nigerian samples [1], follows with the Ensembl variation and LD endpoints once you know the variant's coordinates. The pattern generalizes: symbol to stable ID, stable ID to external identifiers, variant to gene to consequence, every link independently verifiable in the browser.

## Practical Tips for Reliable Cross Reference Data

These tips come straight from the failure modes that show up repeatedly in production ID-mapping pipelines.

1. Pin the assembly. Record whether every mapping you retrieve was computed on GRCh38 or GRCh37. Mixing assemblies is the leading cause of coordinates that look wrong but are actually correct on a different reference.
2. Record the Ensembl release. Cross reference content changes between releases. Store the release number alongside your mappings, which the response headers and the `latest_assembly` endpoint make easy.
3. Extract the plain accession. HGNC `primary_id` values carry the `HGNC:` prefix while `display_id` gives the bare symbol. Decide once, per pipeline, which form your downstream tools expect and normalize immediately.
4. Expect multiple UniProt entries. One Ensembl protein can map to several UniProt records (reviewed and unreviewed). Filter deliberately rather than taking the first hit.
5. Log empty results. An empty array is data. Keep a manifest of identifiers that returned no cross references so you can revisit them after annotation updates.
6. Use `jq` during development and a typed parser in production. Interactive JSON slicing catches schema surprises in minutes.
7. Throttle by default. Put the `time.sleep` in the script on day one, not after your first 429 error.
8. Verify a sample in the browser. For any new dataset, manually check at least a handful of mappings on the gene pages before you commit.

## Common Mistakes and Limitations

**Misspelling the external database name.** `external_db=entrez_gene` and `external_db=EntrezGene` are not the same request. The wrong spelling returns HTTP 200 with an empty array, an error that looks exactly like "this gene has no Entrez ID." Copy database names from an unfiltered real response rather than from memory.

**Forgetting `all=1`.** Puzzling over why a gene has no UniProt protein mapping, when the mapping attaches to its transcripts and proteins, is solved by one query parameter. Gene-level default results are not the complete picture.

**Assuming cross references are stable history.** Mappings reflect the current annotation release. If you re-run a mapping a year later and get different results, both runs can be correct for their moment. Version and date-stamp your mappings.

**Treating one-to-many mappings as one-to-one.** Code that assumes a single RefSeq accession per Ensembl gene will silently drop records. Store mappings as lists from the start.

**Using the wrong REST host for the wrong clade.** Plants, fungi, bacteria, and non-vertebrate metazoa live on `rest.ensemblgenomes.org`. A vertebrate-only URL habit produces confusing 404s when your project expands to rice or yeast.

**Ignoring HTTP status codes.** A 404 means the identifier or species was not found, 400 means a malformed request, and 429 means you hit the rate limit. Scripts that treat every non-200 as "no data" corrupt their output tables. Fail loudly, then decide.

**Over-reading drug and disease xrefs.** These are curated pointers, not comprehensive clinical evidence. A gene-drug link in the xref dump is a starting point for literature work, not a treatment recommendation. Clinical decisions belong with treating clinicians and dedicated clinical resources.

**Expecting reverse lookups to be symmetric.** Going from an Ensembl ID to external IDs and going from an external ID back to Ensembl are related but distinct operations with different endpoints. Confirm the direction you need before writing the query.

One broader limitation applies to everything here: cross references describe database relationships, and their accuracy inherits the annotation and curation quality of both sides of each link. For individual clinical questions, including any question about a pharmacogenomic variant in a specific patient, those variant interpretations need qualified clinical oversight, not an API response.

## Choosing Between the REST API, BioMart, and the Genome Browser

Ensembl gives you three overlapping routes to identifier mappings, and picking the wrong one for the job is a quiet time sink. The REST API, BioMart, and the genome browser all draw on the same underlying mapping data, but they behave very differently in practice, and knowing where each one wins saves hours.

| Criterion | REST API | BioMart | Genome browser |
|-|-|-|-|
| Best for | Programmatic, per-identifier lookups inside scripts | Bulk attribute exports of thousands of genes at once | Visual confirmation and human context |
| Output format | JSON | TSV, CSV, or HTML tables | Web pages with links |
| Speed for one gene | Sub-second | Slower, form-driven | Depends on page loads |
| Speed for 50,000 genes | Impractical one at a time | Excellent | Not realistic |
| Filter control | Per-request parameters (`external_db`, `all`) | Attribute checkboxes | Fixed page layout |
| Rate limits | Yes, documented and header-reported | Separate service policies | N/A |
| Reproducibility | High, every call is a recorded command | Moderate, capture the export | Low unless you screenshot |

Use the REST API when identifiers arrive mid-pipeline: you are annotating a differential expression table, mapping a VCF's gene list, or wiring an identifier step into an automated workflow. The API wins because every request is a single reproducible command that you can version control, log, and re-run. The pharmacogenomic [cohort study](/blog/guides/cohort-studies-a-practical-guide-to-design-conduct-and-interpretation) cited throughout this guide illustrates the pattern: variant-level data for hundreds of individuals was pulled through the Ensembl REST API and genome browser as part of a scripted, repeatable retrieval process [1]. Nobody manually clicked through 207 samples' worth of records.

Use BioMart when you need wide, flat tables for many genes at once, for example an export of every gene on a chromosome along with its HGNC symbol, RefSeq accession, UniProt accession, and OMIM link as columns. BioMart's filter and attribute interface handles bulk joins that would otherwise require hundreds of individual API calls, and the exported table is easy to drop into R or a spreadsheet. A sensible habit is to use BioMart for the bulk export and the API for the targeted checks and gap filling.

Use the genome browser for verification and interpretation. When a mapping looks suspicious, when you want to see which transcripts carry a RefSeq match, or when you need to sanity-check coordinates against the gene model, the browser's gene pages give you the same cross references the API returns, rendered with visual context. Verification is not optional overhead. The cited study combined API retrieval with the genome browser rather than treating them as alternatives [1], and that combined approach is exactly what this guide recommends.

One practical rule: never mix the three sources as co-equal sources of truth within a single pipeline column. Pick one as primary, snapshot it, and use the others for validation. Records can differ in timeliness between services at a given moment, and a pipeline that mixes sources without labeling them produces columns you cannot reconcile later.

## The Anatomy of a Cross Reference Record: Every Field Explained

Most guides show you a cross reference response and move on. Slow down and read one record properly, because every field carries information you will eventually need.

```json
{
  "primary_id": "HGNC:2625",
  "display_id": "CYP2D6",
  "dbname": "HGNC",
  "info": {
    "synonyms": ["CYP2D6", "CYP2D", "CPD6"]
  }
}
```

**`primary_id`** is the canonical identifier inside the external database. This is the value you join on. For HGNC records it carries the `HGNC:` prefix followed by a number. For Entrez Gene it is the bare numeric ID. For UniProt it is the accession. The formatting is inconsistent across databases by nature, which is why Tip 3 in the earlier tips section tells you to normalize the accession format once, per pipeline, and immediately.

**`display_id`** is the human-readable label Ensembl uses for the external record. For HGNC it is the official gene symbol. For RefSeq entries it is typically the accession itself. Do not assume `display_id` and `primary_id` are interchangeable. For HGNC they are very different things: `HGNC:2625` is a permanent numeric identity that survives symbol changes, while `CYP2D6` is a symbol that can, in principle, be renamed or reassigned by the nomenclature committee. If your downstream analysis tracks genes across years of annotation updates, the numeric form is the more stable join key.

**`dbname`** is Ensembl's internal name for the source database. This is the exact string you must supply to the `external_db` filter, and it is the string you should group by when organizing results. Case matters, and the naming is not always the external database's own branding, as the `Uniprot_gn` spelling shows.

**`info`** is an optional object whose contents vary by database. The synonyms list shown above is the most common extra field, and it is genuinely useful: when a symbol lookup fails, searching your collected synonyms often recovers the gene. Do not write code that assumes `info` is always present. Some database records come back with only the three core fields, so treat `info` and its subkeys as optional in every parser.

A parsing rule that has held up across many projects: extract `primary_id`, `display_id`, and `dbname` into fixed columns, keep the entire raw JSON record in a fourth column or a side file, and defer everything else. The raw record is your insurance policy. If a colleague later asks whether a mapping had synonyms attached or which subtype of RefSeq record matched, you answer from the snapshot rather than by re-querying an annotation release that may have moved on.

## Reverse Mapping: From Symbols and External Identifiers Back to Ensembl

The `/xrefs/id` endpoint runs in one direction only, from an Ensembl stable ID outward. Real projects constantly need the reverse: you have a symbol from a paper, a gene list from a collaborator, or a column of HGNC symbols in a clinical dataset, and you need Ensembl identifiers. The `/xrefs/symbol` endpoint exists for exactly this, and it behaves differently from `/xrefs/id` in ways that matter.

```bash
curl -s "https://rest.ensembl.org/xrefs/symbol/homo_sapiens/CYP2D6?content-type=application/json"
```

Expect a list of Ensembl objects, not external records. For a clean symbol like CYP2D6 you will typically get the gene, and possibly transcripts, each tagged with `type` and `id` fields. Filter to the object type you actually want:

```bash
curl -s "https://rest.ensembl.org/xrefs/symbol/homo_sapiens/CYP2D6?content-type=application/json" \
  | jq '.[] | select(.type == "gene") | .id'
```

Three behaviors to plan for:

**Symbols are not unique across object types.** The same string can match a gene and one or more transcripts, and in some species it can match multiple loci. Always filter by `type` rather than taking the first element of the array. Taking the first hit is a correct-by-luck pattern that eventually maps the wrong locus.

**Symbols are not unique across species.** Without further qualification, the species path segment (`homo_sapiens` here) scopes the search, which is what you want. If you are unsure which species a symbol belongs to, the `all_species=1` parameter widens the search, and you then choose deliberately rather than by accident.

**Symbols drift.** A symbol in an old publication may have been renamed, or worse, reassigned to a different gene. If a symbol lookup returns nothing, or returns something biologically implausible for your context, resolve through HGNC records instead: fetch the gene's cross references from a candidate stable ID and inspect the synonyms field. The reverse-direction workflow in the cited pharmacogenomic study implicitly relied on this kind of resolution, because gene-drug pairs like CYP2D6 with tamoxifen and DPYD with fluoropyrimidines were tied to specific variant panels through consistent identifier resolution [1].

What about external identifiers that are not symbols, such as a RefSeq accession like the `NM_` accessions described earlier in this guide, or a UniProt accession? The xref family does not offer a general external-ID-to-Ensembl search endpoint, so use one of these honest strategies. First, the Ensembl genome browser's search box accepts many external accession formats and lands you on the corresponding Ensembl feature, which you can then confirm through the API. Second, BioMart lets you filter input data on RefSeq or UniProt attribute values and retrieve the matching Ensembl IDs in bulk, which is the right tool when you have a long list. Third, for a small number of cases, fetch the gene's full cross reference set with `all=1` from a candidate you already suspect, and confirm your external accession appears among the results. Record which strategy you used, because each has different blind spots.

## Transcript and Protein Level Mapping in Depth

The `all=1` parameter deserves a section of its own because the level at which a mapping attaches is the single most misunderstood property of Ensembl cross references. Ensembl models biology as a hierarchy: a gene (ENSG) contains transcripts (ENST), and protein-coding transcripts produce translations (ENSP). External database records attach at whatever level they describe.

A RefSeq mRNA accession describes a transcript, so its cross reference naturally attaches to an Ensembl transcript. A UniProt record describes a protein, so it attaches to an Ensembl translation, represented by an ENSP identifier. The HGNC record describes the gene, so it attaches at gene level. When you call `/xrefs/id` on a gene without `all=1`, you get only the gene-level attachments. With `all=1`, Ensembl walks down the hierarchy and collects the transcript-level and protein-level mappings too, returning them under the gene's umbrella.

This explains a confusion that derails many first attempts at building a gene-to-protein table: the gene-level query shows no UniProt hits, the researcher concludes the protein is uncharacterized, and the actual protein mapping was one parameter away. The reverse error also occurs. Someone adds `all=1` everywhere, then wonders why their gene-to-HGNC table now contains apparent duplicates, which are usually the same HGNC record inherited through multiple code paths in the expanded result set.

For building an analysis-ready gene-to-protein mapping, a two-step approach is cleaner than one expanded query. First fetch the gene's transcripts with the lookup endpoint:

```bash
curl -s "https://rest.ensembl.org/lookup/id/homo_sapiens/ENSG00000100197?expand=1;content-type=application/json" \
  | jq -r '.Transcripts[] | [.id, .biotype] | @tsv'
```

This gives you the transcript inventory with biotypes, so you can separate protein-coding transcripts from the rest. Then, for the transcripts that matter, fetch their own cross references directly, which returns protein-level mappings at the level where they actually attach. Keeping the hierarchy explicit in your code mirrors the biology and prevents the duplicate-collapse bugs that flattened schemas invite.

When filtering expanded results, pay attention to the `dbname` families. The `RefSeq_mRNA` name shown in the parameter reference earlier covers transcript-level RefSeq records. UniProt mappings likewise arrive as distinct database entries rather than one blended category. Inspect an unfiltered expanded response once per project, note which `dbname` values actually appear for your organism, and pin your filters to those exact strings, as the rule of thumb in the parameter reference advises.

One more caution: an expanded result set can be large for genes with many transcripts. If you are running batch scripts against thousands of genes, fetch gene-level mappings for all genes first, and only expand transcripts for the subset where protein-level detail genuinely feeds an analysis. This keeps request volume and response sizes proportional to the question you are answering.

## A Production-Grade Mapping Script

The throttled loop in the step-by-step section is the right starting skeleton. Real production runs need four more things: input from a file, resumability after interruptions, structured logging of failures and empties, and output in a form that downstream tools ingest without transformation. Here is a version that adds all four.

```python
import csv
import json
import sys
import time
import requests

ENSEMBL = "https://rest.ensembl.org"
HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}
INPUT_FILE = "ensembl_ids.txt"
OUTPUT_FILE = "xref_mappings.csv"
CHECKPOINT_FILE = "xref_checkpoint.json"
LOG_FILE = "xref_run.log"

def log(message):
    stamp = time.strftime("%Y-%m-%d %H:%M:%S")
    with open(LOG_FILE, "a") as f:
        f.write(f"{stamp}\t{message}\n")

def load_checkpoint():
    try:
        with open(CHECKPOINT_FILE) as f:
            return json.load(f)
    except FileNotFoundError:
        return {}

def save_checkpoint(done):
    with open(CHECKPOINT_FILE, "w") as f:
        json.dump(done, f)

def fetch_with_retry(url, params):
    backoff = 1
    for attempt in range(5):
        r = requests.get(url, headers=HEADERS, params=params, timeout=30)
        time.sleep(0.1)  # keep well under the unauthenticated rate cap
        if r.status_code == 200:
            return r.json()
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", "1"))
            log(f"429 on {url}, waiting {wait}s")
            time.sleep(wait)
            continue
        if r.status_code >= 500:
            log(f"server error {r.status_code} on {url}, attempt {attempt + 1}")
            time.sleep(backoff)
            backoff *= 2
            continue
        # 400 or 404: the request itself is wrong, do not retry
        log(f"fatal status {r.status_code} on {url}")
        return None
    log(f"giving up on {url} after repeated failures")
    return None

def main():
    with open(INPUT_FILE) as f:
        ids = [line.strip() for line in f if line.strip()]

    done = load_checkpoint()
    remaining = [i for i in ids if i not in done]
    log(f"starting run, {len(ids)} total, {len(remaining)} remaining")

    with open(OUTPUT_FILE, "a", newline="") as out:
        writer = csv.writer(out, delimiter="\t")
        for stable_id in remaining:
            result = fetch_with_retry(
                f"{ENSEMBL}/xrefs/id/homo_sapiens/{stable_id}",
                {"external_db": "HGNC", "content-type": "application/json"},
            )
            if result is None:
                writer.writerow([stable_id, "", "", "FETCH_FAILED"])
            elif len(result) == 0:
                writer.writerow([stable_id, "", "", "EMPTY"])
                log(f"empty result for {stable_id}")
            else:
                for record in result:
                    writer.writerow([
                        stable_id,
                        record["dbname"],
                        record["primary_id"],
                        record.get("display_id", ""),
                    ])
            done[stable_id] = True
            if len(done) % 50 == 0:
                save_checkpoint(done)
        save_checkpoint(done)

if __name__ == "__main__":
    sys.exit(main())
```

The design decisions each earn their place. The checkpoint file lets you kill and restart a long run without re-fetching or duplicating work, and saving it every fifty identifiers bounds your loss. Client errors (400 and 404) are treated as fatal for that identifier and logged, because retrying a malformed request is wasted traffic and hides real input problems, while server errors (5xx) get exponential backoff because they are usually transient. Every empty result is written to the output with an `EMPTY` marker and logged, honoring the earlier principle that an empty array is data. The output is long format, one row per mapping, which survives the one-to-many nature of the data without any collapsing logic.

Run it, then inspect before trusting:

```bash
cut -f4 xref_mappings.csv | sort | uniq -c
grep FETCH_FAILED xref_mappings.csv
```

The first command summarizes outcome types across your identifier list. A run where most rows are `EMPTY` almost always means a systematic problem, such as a misspelled `external_db` filter or identifiers from a different species, rather than hundreds of genes genuinely lacking HGNC mappings.

## Troubleshooting Field Guide: Diagnosing Empty, Wrong, and Weird Results

![Decision path checking filter spelling, identifier type and REST host before accepting an empty cross reference result.](/article-images/illustrations/ensembl-rest-api-cross-references-empty-result-triage.webp)

When a cross reference workflow misbehaves, the symptom usually falls into one of five categories. Work through them in order, because the cheap checks come first.

**Symptom: empty array for a gene you know is well annotated.** The most common cause by far is a misspelled `external_db` filter, which returns HTTP 200 with an empty list, as described in the mistakes section. Diagnosis: remove the filter entirely and call the endpoint bare. If the unfiltered response is non-empty, scan the `dbname` values with `jq` and copy the exact spelling into your filter. If the unfiltered response is also empty, the identifier itself is the suspect. Check for typos, check the species, and check that you are not passing a transcript ID where the workflow expects a gene ID. A stable ID with the wrong prefix, for example an ENST submitted to a gene-level workflow, will fail silently in exactly this way.

**Symptom: HTTP 404 on a request you are sure is correctly formed.** The species name in the path is the usual culprit. Species are spelled in lowercase with an underscore: the format is two words joined by an underscore, and any other spelling 404s. The other frequent cause is a vertebrate identifier sent to the wrong host. Plants, fungi, bacteria, and non-vertebrate metazoa live on the Ensembl Genomes REST service at its own host, as described earlier, and a vertebrate host habit produces 404s the moment a project touches rice, yeast, or an insect. Ask yourself which clade your organism belongs to before debugging anything else.

**Symptom: HTTP 400.** The request is malformed. Look first at the URL you actually sent, which is rarely the URL you think you sent. An unencoded ampersand in a shell command can truncate the query string so that `content-type` never reaches the server. Quote your URLs in shell commands, always. A missing `content-type` parameter on endpoints that require an explicit format also lands here.

**Symptom: HTTP 429 mid-run.** You exceeded the rate limit, which sits around 15 requests per second for unauthenticated use, and the earlier script section shows the correct response: read the `Retry-After` header, sleep that long, and resume. If 429s recur despite throttling, your effective rate is higher than you think, usually because parallel threads or subprocesses each carry their own sleep. Throttle globally, not per worker.

**Symptom: results disagree with the genome browser.** First confirm you are comparing the same assembly. A mapping retrieved on GRCh38 checked against a page displaying GRCh37 coordinates will look wrong while being right, and the browser walkthrough earlier in this guide flags exactly this trap. Second, confirm the release mismatch direction: the API usually reflects a more recent release, so a fresh API result missing from the browser is more often newness than error. Third, compare like with like. The browser's external references section on a gene page corresponds to gene-level mappings, so transcript-level RefSeq records visible through `all=1` may legitimately not appear in the same form there.

**Symptom: duplicated mappings in your output.** Usually one of three things: expanded results inheriting the same external record through multiple transcripts, a genuinely one-to-many mapping that a downstream one-to-one assumption flagged as a duplicate, or an input file containing the same identifier twice. Diagnose by keeping the raw record alongside each mapping so you can see whether duplicate rows share identical `primary_id` values or merely similar ones.

## Worked Case Study: Mapping the Full Pharmacogene Panel

The step-by-step section showed the first link of the pharmacogenomic chain with CYP2D6. This section completes the chain across the remaining genes in the cited study's panel, which covered CYP3A5 variants for tacrolimus dosing, four DPYD variants for fluoropyrimidine therapy, and TPMT*3C for thiopurines [1]. The point is not the specific genes. The point is a pattern you can apply to any panel: resolve symbols to stable IDs automatically, fetch cross references from the captured IDs, and never hand-copy a stable ID between steps.

Shell scripting with command substitution chains the steps so the stable ID is captured, not transcribed:

```bash
# Resolve DPYD and capture its stable ID in one line
DPYD_ID=$(curl -s "https://rest.ensembl.org/lookup/symbol/homo_sapiens/DPYD?content-type=application/json" | jq -r '.id')
echo "DPYD stable ID: ${DPYD_ID}"

# Fetch HGNC and OMIM mappings using the captured ID
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/${DPYD_ID}?external_db=HGNC&content-type=application/json"
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/${DPYD_ID}?external_db=OMIM&content-type=application/json"

# Repeat the pattern for CYP3A5
CYP3A5_ID=$(curl -s "https://rest.ensembl.org/lookup/symbol/homo_sapiens/CYP3A5?content-type=application/json" | jq -r '.id')
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/${CYP3A5_ID}?external_db=HGNC&content-type=application/json"
```

Note what this eliminates. No copy-pasted stable IDs, which are the classic [transcription error](/knowledge/molecular-biology/transcription-error) in identifier workflows. No assumptions that the symbol resolves to the gene you expect, because echoing the captured ID lets you confirm the lookup succeeded before anything downstream runs. If the symbol lookup ever fails, `jq -r '.id'` emits a null rather than a plausible-looking string, and the subsequent requests fail loudly rather than mapping against garbage.

The variant-to-gene leg generalizes the same way. The study's panel included rs3892097, the CYP2D6*4 defining variant, and analogous functional variants across CYP3A5, DPYD, and TPMT, all tied back to their genes and consequences [1]. The VEP endpoint takes a variant identifier and returns transcript-level consequences including the affected gene. Extract the gene identifier mechanically and feed it straight into the xref workflow:

```bash
# Annotate the variant and capture the affected gene ID
GENE_ID=$(curl -s -H "Content-Type: application/json" \
  -d '{"variants":["rs3892097"]}' \
  "https://rest.ensembl.org/vep/homo_sapiens/id?content-type=application/json" \
  | jq -r '..transcript_consequences.gene_id')

# Cross reference the gene the variant lives in
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/${GENE_ID}?external_db=HGNC&content-type=application/json"
```

Before relying on that `jq` extraction, run the VEP request without the pipe and inspect the full response once, so you understand its structure rather than trusting a one-liner you did not verify. VEP responses can contain multiple transcript consequences per variant, and a variant that overlaps more than one transcript yields more than one entry, so the first-element extraction shown here is a convenience for a single-gene variant, not a general rule.

Completing the picture, the population layer of the cited work retrieved allele counts and frequencies from 1000 Genomes Project Phase 3 data for 207 Nigerian individuals, 108 Yoruba and 99 Esan, and subjected those frequencies to Hardy-Weinberg equilibrium checks and cross-population comparison [1]. Every one of those downstream statistics depended on the identifier chain resolved here: variant identifiers anchored to genes, genes anchored to standardized external identifiers, and the whole structure reproducible because each link was an API call rather than a manual lookup. When you extend the same panel, or apply it to a different cohort, the chain re-runs unchanged, which is the practical definition of a successful identifier workflow.

## Common Misconceptions About Cross References

Beyond the concrete mistakes listed earlier, several conceptual misunderstandings shape bad pipeline design. Each one below is stated the way people usually phrase it, then corrected.

**"An Ensembl gene has one official ID everywhere."** The stable ID is stable within Ensembl's versioning scheme, but external databases do not adopt it. HGNC maintains its own numeric IDs, NCBI maintains Entrez Gene numbers, and UniProt maintains accessions. The cross reference table is the bridge, and it must be maintained, snapshot, and versioned like any other data asset. Treating the Ensembl ID as a universal key guarantees joins that break the moment an external database curates a record.

**"Cross references are symmetric."** If gene X references database record Y, people assume record Y's page references gene X in the same terms. Directionality and completeness differ on each side of the link, and the endpoints reflect this: xref endpoints take Ensembl IDs, while recovering an Ensembl object from an external label means symbol lookups, browser search, or BioMart filters, each with its own semantics. Design your data model with two explicit directions rather than one undirected edge.

**"The API returns the same result forever."** Mappings track the current annotation release, so a script that returns a given output today can return different output after the next release. Both runs can be correct for their moment. The remedy is not to freeze the API but to freeze your snapshot: record the release, the date, and the exact request for every retrieval, then analyze the snapshot rather than re-querying live.

**"If the API returns it, the biology is settled."** A cross reference asserts that two database records are related, and the strength of that assertion depends on both sides' curation. Reviewed and unreviewed UniProt entries both appear as mappings, and transcript-level RefSeq matches vary in how directly they correspond to the Ensembl model. A mapping is a strong lead, not a biological conclusion, and drug and disease cross references are pointers for investigation rather than clinical statements, as the earlier over-reading warning emphasizes.

**"One gene, one protein, so one UniProt mapping."** [Alternative splicing](/blog/guides/alternative-splicing) produces multiple protein products, some genes produce none, and protein databases may represent the same product with multiple records of different curation status. The honest representation is a list of mappings with the level of attachment recorded, which is precisely what the transcript and protein section of this guide builds.

**"Batching means sending everything in one giant request."** Batching in the Ensembl API means grouping multiple identifiers into a single POST for endpoints that support it, which the step-by-step section notes is available for lookup-style endpoints. It does not mean removing throttling. Large batch requests still count against service load, and the polite pattern remains: batch where supported, throttle what remains, honor 429 responses, and keep request sizes moderate.

**"Empty means the gene does not exist."** An empty result has at least four distinct causes covered above: a bad filter spelling, a wrong host for the clade, a malformed request, and a gene that genuinely has no mappings in the requested database. Only the last is a biological statement. Good pipelines distinguish these outcomes explicitly, which the production script in this guide implements with separate `EMPTY` and `FETCH_FAILED` markers.

## Testing and Validation Strategy for Identifier Mapping Pipelines

Identifier mappings feed downstream statistics, so they deserve the same testing discipline as any other pipeline stage. Four layers of testing, from cheapest to most expensive, cover the realistic failure modes.

**Layer 1: structural tests on recorded responses.** Save a handful of real API responses as fixture files and write tests that parse them. Assert that every record has `primary_id`, `display_id`, and `dbname`. Assert that your filter logic selects the expected subset from a known fixture. These tests run offline, finish in milliseconds, and catch the majority of parser bugs, especially the kind introduced when someone refactors and silently changes a field name.

**Layer 2: known-answer tests against a small live set.** Keep a short list of genes whose correct mappings you have verified manually, and assert that the pipeline reproduces them. CYP2D6 resolving to its HGNC record, EGFR to its symbol, and BRCA1 to its mappings, drawn from the examples used throughout this guide, make a reasonable starter set. These tests require network access and tolerate release drift by asserting membership rather than exact equality: the HGNC mapping should be present, even if other records have changed around it.

**Layer 3: round-trip consistency checks.** For a sample of identifiers, go forward and back: Ensembl ID to symbol via cross references, symbol back to Ensembl ID via the symbol endpoint, and confirm you land where you started. Round-trip failures expose symbol ambiguity, object-type mismatches, and mapping drift, and they are cheap to automate. Run them on a fixed sample each time you refresh a snapshot, and investigate any gene that fails twice in a row.

**Layer 4: independent-source spot checks.** Compare a sample of your API-derived mappings against a BioMart export covering the same genes, and against the External references section of gene pages in the browser for a handful of cases. Because both alternative sources draw on the same underlying mapping data, as noted in the browser verification walkthrough, agreement is strong validation and disagreement is always worth chasing. For the kind of cohort-scale work in the cited pharmacogenomic study, where variant-to-gene-to-drug chains underpin population analyses across 207 individuals [1], this layer catches systematic errors that per-record checks miss, such as an entire batch of lookups silently run against the wrong species.

A practical addition: log the outcome distribution every run. A pipeline run where the empty-result rate jumps from a familiar baseline to something several times higher is telling you about a filter typo, a host mismatch, or a release change, long before any human notices a missing gene in a results table. Distribution monitoring is the cheapest alarm you will ever install.

## Reproducibility: Snapshotting, Caching, and Documenting Your Mappings

An identifier mapping is only as good as your ability to explain, months later, exactly how it was produced. Three habits make that explanation automatic.

**Snapshot the response, not just the extracted values.** When a script filters and transforms API responses on the fly, the discarded fields are gone. Instead, have every fetch write the raw JSON body to a cache directory keyed by request, for example a file named for the endpoint, identifier, and date, and perform all filtering downstream from the cache. The storage cost is trivial at research scale, and the payoff is that any later question about what the API actually returned is answerable from disk rather than from memory. Re-running against a changed annotation release produces a new cache file while the old one persists, which converts mapping drift from a mystery into a diff.

**Record the provenance triple.** Every mapping table should carry, per run or per row, three facts: the Ensembl release number, the retrieval date, and the exact request that produced the row. The release number and retrieval date can live in a manifest file alongside the table. The exact request is best captured by writing your curl commands or request URLs into the run log, since the URL is a complete, unambiguous specification of the call. With those three facts, a colleague can reproduce your mapping or determine why theirs differs from yours, and the earlier advice that mappings reflect the current release becomes actionable rather than merely cautionary.

**Separate retrieval from interpretation.** A cache of raw responses is a fact. The table you join, filter, and normalize from that cache is an interpretation. Keeping these as distinct files with a documented transformation between them means that when an interpretation bug surfaces, and in identifier workflows they usually do, you re-run the transformation against the unchanged cache instead of re-hitting the API and hoping the annotations have not moved. This separation also makes assembly changes visible: if a project spans the GRCh37 to GRCh38 transition, which the browser verification section flags as the leading coordinate confusion, cached responses record which assembly each retrieval ran against, and the manifest makes the boundary impossible to miss.

A minimal manifest worth adopting on day one looks like this, written once per run:

```text
run_id: 2026-03-14_pharmacogene_panel
ensembl_release: <record the release from your retrieval>
retrieval_date: 2026-03-14
input_file: ensembl_ids.txt (n = 312)
endpoints_used: /lookup/symbol, /xrefs/id
filters_used: external_db=HGNC
empty_results: 4 (see xref_run.log)
notes: identifiers from collaborator table v2
```

Fill in every field truthfully, including the empties. The manifest costs two minutes per run and answers the questions that otherwise arrive by email six months later: which version, which date, which filter, and why are four genes missing.

## Turning Cross Reference Output into Analysis-Ready Tables

The final step in any real workflow is shaping the API's one-to-many JSON into a table your statistics environment can join against. The output format decisions you make here determine whether the downstream merge is a ten-second operation or an afternoon of debugging.

Start from long format, one row per mapping, as the production script above already writes. Long format is the honest representation of one-to-many data, and every language you are likely to use handles it well. From `xref_mappings.csv`, the `jq` and shell path to a tidy table is short:

```bash
curl -s "https://rest.ensembl.org/xrefs/id/homo_sapiens/ENSG00000100197?all=1&content-type=application/json" \
  | jq -r '.[] | [.dbname, .primary_id, .display_id] | @tsv'
```

In Python, the same shape arrives naturally if you fetch into a list of dictionaries and hand it to `pandas`:

```python
import pandas as pd

rows = []
for stable_id, records in results.items():
    for rec in records:
        rows.append({
            "ensembl_id": stable_id,
            "dbname": rec["dbname"],
            "primary_id": rec["primary_id"],
            "display_id": rec.get("display_id", ""),
        })
df = pd.DataFrame(rows)
```

Three normalization decisions to make deliberately. First, accession format: strip or keep the `HGNC:` prefix according to what your joining table expects, and apply the rule at ingestion so every later step sees consistent values. Second, gene-level versus expanded results: keep them in separate tables or add an explicit `level` column, because mixing levels and then dropping duplicates produces tables whose provenance nobody can reconstruct. Third, empty handling: genes with no mappings should appear in the table with null external identifiers, or live in a documented companion list, rather than vanishing. Silent row loss during joins is the most expensive bug class in identifier work because the resulting statistics are quietly computed on the wrong denominator, and nothing errors.

For the pivot to wide format, when a downstream tool genuinely needs one row per gene, aggregate within genes after the join rather than before. A gene's multiple RefSeq accessions belong in one delimited cell or a count column, not in duplicated gene rows that will double-count your expression values. The direction matters: collapse after merging, never before, so the many-to-one step is visible in your code rather than invisible in your data.

Finally, validate the assembled table the way the earlier testing section prescribes: row counts against your input list, mapping-rate distribution against your baseline, and a sample of records against the browser. The study that opened this guide turned a variant panel and a cohort of 207 individuals into population [frequency tables](/blog/data-analysis/frequency-table-definition-and-examples) through exactly this kind of disciplined chain of API retrieval, normalization, and verification [1], and the same discipline scales down to a five-gene pilot just as well as it scales up to a genome-wide panel.

## Frequently Asked Questions

### What exactly are Ensembl REST API cross references?

They are machine-readable links between Ensembl stable identifiers (genes, transcripts, proteins) and the corresponding records in external databases such as HGNC, RefSeq, UniProt, Entrez Gene, and OMIM, served as JSON by the Ensembl REST endpoints.

### How do I get the HGNC, RefSeq, and UniProt IDs for an Ensembl gene?

Call `GET /xrefs/id/:species/:ensembl_id` with `content-type=application/json`, and add `all=1` if you need transcript- and protein-level mappings, then filter the returned `dbname` values for the databases you care about.

### Why does my cross reference request return an empty array?

The most common cause is a misspelled `external_db` value or an identifier that has no current mappings, because a bad filter returns a successful empty response rather than an error, so verify the database name against an unfiltered result first.

### What is the rate limit for the Ensembl REST API?

Unauthenticated requests are limited to about 15 per second, the response headers report your remaining allowance, and registering a free Ensembl account raises the limit for heavier workloads.

### Can I convert an Ensembl gene ID to a gene symbol without the API?

Yes, through the Ensembl genome browser's gene page or BioMart exports, though for programmatic and batch work the `/xrefs/id` endpoint with the HGNC filter is faster and reproducible.

### Do cross references work for plants, fungi, and bacteria?

Yes, through the parallel Ensembl Genomes service at `rest.ensemblgenomes.org`, which mirrors the same endpoint structure for non-vertebrate species.

### How current are cross references, and can mappings change?

Mappings reflect the current annotation release and do change when Ensembl or the external databases update their records, so record the release and date whenever you snapshot mappings for a project.

### How do I check that a cross reference mapping is correct?

Cross-check a sample of your API results against the External references section of the gene's page in the Ensembl genome browser, since both views draw on the same underlying mapping data and agreement between them is strong validation.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "What exactly are Ensembl REST API cross references?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "They are machine-readable links between Ensembl stable identifiers (genes, transcripts, proteins) and corresponding records in external databases such as HGNC, RefSeq, UniProt, Entrez Gene, and OMIM, served as JSON by the Ensembl REST endpoints."
      }
    },
    {
      "@type": "Question",
      "name": "How do I get the HGNC, RefSeq, and UniProt IDs for an Ensembl gene?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Call GET /xrefs/id/:species/:ensembl_id with content-type=application/json, and add all=1 if you need transcript- and protein-level mappings, then filter the returned dbname values for the databases you care about."
      }
    },
    {
      "@type": "Question",
      "name": "Why does my cross reference request return an empty array?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "The most common cause is a misspelled external_db value or an identifier that has no current mappings, because a bad filter returns a successful empty response rather than an error, so verify the database name against an unfiltered result first."
      }
    },
    {
      "@type": "Question",
      "name": "What is the rate limit for the Ensembl REST API?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Unauthenticated requests are limited to about 15 per second, the response headers report your remaining allowance, and registering a free Ensembl account raises the limit for heavier workloads."
      }
    },
    {
      "@type": "Question",
      "name": "Can I convert an Ensembl gene ID to a gene symbol without the API?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes, through the Ensembl genome browser's gene page or BioMart exports, though for programmatic and batch work the /xrefs/id endpoint with the HGNC filter is faster and reproducible."
      }
    },
    {
      "@type": "Question",
      "name": "Do cross references work for plants, fungi, and bacteria?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes, through the parallel Ensembl Genomes service at rest.ensemblgenomes.org, which mirrors the same endpoint structure for non-vertebrate species."
      }
    },
    {
      "@type": "Question",
      "name": "How current are cross references, and can mappings change?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Mappings reflect the current annotation release and do change when Ensembl or the external databases update their records, so record the release and date whenever you snapshot mappings for a project."
      }
    },
    {
      "@type": "Question",
      "name": "How do I check that a cross reference mapping is correct?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Cross-check a sample of your API results against the External references section of the gene's page in the Ensembl genome browser, since both views draw on the same underlying mapping data and agreement between them is strong validation."
      }
    }
  ]
}
</script>

## Sources

1. [Clinically actionable pharmacogenomic variants for anticancer therapy in Nigeria: first comprehensive variant profiling in underrepresented Nigerian cohorts.](https://pubmed.ncbi.nlm.nih.gov/41664142/)