Bio.Entrez
Bio.Entrez
Core Principle:
Bio.Entrezprovides programmatic access to NCBI databases via the E-utilities API, requiring authenticated client identification, rate-limit compliance, and systematic response caching.
Setup: not optional
from Bio import Entrez
Entrez.email = "you@institution.edu" # REQUIRED by NCBI's terms
Entrez.tool = "my_analysis_pipeline" # identifies your software
Entrez.api_key = "your_key_here" # optional; raises the rate limit
NCBI requires an email so they can contact you before blocking your IP if a script misbehaves. Without it Biopython warns and NCBI may throttle you.
Get an API key. It is free from your NCBI account and raises your limit from 3 to 10 requests per second. Store it in an environment variable, not in the script:
import os
Entrez.api_key = os.environ.get("NCBI_API_KEY")
The main calls
Entrez.esearch(db, term, retmax) # search → list of IDs
Entrez.efetch(db, id, rettype, retmode) # retrieve records
Entrez.esummary(db, id) # brief summaries
Entrez.elink(dbfrom, db, id) # cross-database links
Entrez.einfo(db) # what fields does this database have?
Entrez.epost(db, id) # upload IDs to the history server
Entrez.read(handle) # parse XML → nested dicts/lists
Always close the handle, or use a with block:
with Entrez.efetch(db="nucleotide", id="NM_000546.6",
rettype="gb", retmode="text") as h:
rec = SeqIO.read(h, "genbank")
Common patterns
# search then fetch
with Entrez.esearch(db="nucleotide", term="TP53[Gene] AND human[Organism]",
retmax=20) as h:
ids = Entrez.read(h)["IdList"]
with Entrez.efetch(db="nucleotide", id=",".join(ids),
rettype="gb", retmode="text") as h:
records = list(SeqIO.parse(h, "genbank"))
# one accession as GenBank
with Entrez.efetch(db="nucleotide", id="NM_000546.6",
rettype="gb", retmode="text") as h:
rec = SeqIO.read(h, "genbank")
# protein FASTA
with Entrez.efetch(db="protein", id="NP_000537.3",
rettype="fasta", retmode="text") as h:
prot = SeqIO.read(h, "fasta")
# PubMed abstracts
with Entrez.efetch(db="pubmed", id="12345678", rettype="abstract",
retmode="text") as h:
print(h.read())
# gene record as structured XML
with Entrez.efetch(db="gene", id="7157", retmode="xml") as h:
gene = Entrez.read(h)
# ClinVar for a gene
with Entrez.esearch(db="clinvar", term="TP53[gene] AND pathogenic",
retmax=100) as h:
ids = Entrez.read(h)["IdList"]
db="gene" with id="7157" uses the Entrez Gene ID, not the symbol. Use esearch on db="gene" to go from symbol to ID.
Batching
Never fetch one ID at a time in a loop.
def fetch_batched(ids, db="nucleotide", rettype="fasta", batch=200):
for i in range(0, len(ids), batch):
chunk = ids[i:i + batch]
with Entrez.efetch(db=db, id=",".join(chunk),
rettype=rettype, retmode="text") as h:
yield from SeqIO.parse(h, rettype)
time.sleep(0.15)
For very large sets, use the history server so you never send the ID list back:
with Entrez.esearch(db="nucleotide", term=query, usehistory="y") as h:
r = Entrez.read(h)
count, webenv, key = int(r["Count"]), r["WebEnv"], r["QueryKey"]
for start in range(0, count, 500):
with Entrez.efetch(db="nucleotide", rettype="fasta", retmode="text",
retstart=start, retmax=500,
webenv=webenv, query_key=key) as h:
yield from SeqIO.parse(h, "fasta")
Be a good citizen
- ≤3 requests/second without an API key, ≤10 with one. Biopython does some throttling; do not rely on it entirely.
- Bulk downloads outside US peak hours (weekends, or 21:00–06:00 Eastern).
- For anything genuinely large, use the FTP site (
ftp.ncbi.nlm.nih.gov) or the datasets CLI. E-utilities is an API for queries, not a bulk transfer mechanism. - Retry with backoff: the service returns transient 500s under load.
import time
from urllib.error import HTTPError
def robust_fetch(fn, *args, retries=4, **kw):
for attempt in range(retries):
try:
return fn(*args, **kw)
except HTTPError as e:
if attempt == retries - 1 or e.code < 500:
raise
time.sleep(2 ** attempt)
Cache aggressively
Network calls are slow, rate-limited, and not reproducible: the record you fetch today may differ from the one you fetch next year, because sequences get revised.
from pathlib import Path
def cached_genbank(acc, cache_dir="ncbi_cache"):
cache = Path(cache_dir); cache.mkdir(exist_ok=True)
path = cache / f"{acc}.gb"
if not path.exists():
with Entrez.efetch(db="nucleotide", id=acc,
rettype="gb", retmode="text") as h:
path.write_text(h.read())
return SeqIO.read(path, "genbank")
Cache the accession with its version (NM_000546.6, not NM_000546). An unversioned accession returns whatever is current, so your pipeline's behaviour changes silently when NCBI updates the record. This is the same reproducibility principle as pinning your UTA snapshot: see UTA and Data Providers.
Security note
Entrez.read parses XML. Biopython 1.87 fixed CVE-2025-68463 in Bio.Entrez.Parser. Keep Biopython up to date, especially if you parse XML from anywhere other than NCBI itself.
Bioinformatics examples
# all RefSeq transcripts for a gene
with Entrez.esearch(db="nucleotide",
term="TP53[Gene] AND human[Organism] AND refseq[Filter] AND mRNA[Filter]",
retmax=50) as h:
ids = Entrez.read(h)["IdList"]
# gene → protein via elink
with Entrez.elink(dbfrom="gene", db="protein", id="7157") as h:
links = Entrez.read(h)
protein_ids = [l["Id"] for l in links[0]["LinkSetDb"][0]["Link"]]
# taxonomy lineage
with Entrez.efetch(db="taxonomy", id="9606", retmode="xml") as h:
tax = Entrez.read(h)
print(tax[0]["Lineage"])
# assembly summary
with Entrez.esummary(db="assembly", id="GCF_000001405.40") as h:
print(Entrez.read(h))
# build a local reference set for a gene panel
for gene in PANEL:
with Entrez.esearch(db="nucleotide",
term=f"{gene}[Gene] AND human[Organism] AND refseq_select[Filter]") as h:
ids = Entrez.read(h)["IdList"]
if ids:
rec = cached_genbank(ids[0])
SeqIO.write(rec, f"refs/{gene}.gb", "genbank")
time.sleep(0.15)
refseq_select[Filter] restricts to the single representative transcript per gene, which is usually what you want for a panel.
Alternatives
| Need | Better tool |
|---|---|
| Bulk genome/annotation download | NCBI datasets CLI, or FTP |
| Ensembl data | Ensembl REST API, or pyensembl |
| UniProt | UniProt REST API |
| Variant annotation | Ensembl VEP, ClinVar VCF downloads |
| Transcript–genome alignments | UTA: see UTA and Data Providers |
| Sequences by accession | SeqRepo: see SeqRepo |
For variant work specifically, SeqRepo and UTA are far better than Entrez: local, versioned, fast, and reproducible.
Common mistakes
- Not setting
Entrez.email. - No API key, so you are limited to 3 req/s.
- One request per ID in a loop.
- No caching, making the pipeline slow and irreproducible.
- Unversioned accessions.
- No retry logic for transient 500s.
- Bulk downloading through E-utilities instead of FTP.
- Not closing handles.
- Hardcoding the API key in a committed script.
See also
SeqIO · SeqRecord · Bio.Blast · SeqRepo · UTA and Data Providers · Applied - Reproducible Environment
Exercise
Entrez.read turns an E-utilities XML response into nested
dictionaries, and efetch then wants those ids sent back in batches rather
than one at a time. Biopython is not installed here, so parse the response with
xml.etree.ElementTree from the standard library and build the batches
yourself: the network is never touched.
Write id_batches(xml_text, batch), taking an eSearchResult document and a
batch size, and returning the list of output lines. Read the reported total
from the Count element, using 0 if it is absent. Collect the ids from
Id elements inside IdList only: an E-utilities response can carry
other Id elements elsewhere in the tree, and a naive full-tree walk picks
those up too. Drop duplicates while preserving first-seen order. Print
count=<reported> unique=<n>, then one line per batch as
batch<k>=<comma-joined ids> numbered from 1. When IdList is empty, print
the no ids line instead of any batch line. The batch size arrives on the
first line of input and the XML document follows.
Test cases · 3
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 2 <eSearchResult><Count>5</Count><RetMax>5</RetMax><IdList><Id>1519311456</Id><Id>371502114</Id><Id>187830920</Id><Id>120407068</Id><Id>34395849</Id></IdList></eSearchResult> | count=5 unique=5 batch1=1519311456,371502114 batch2=187830920,120407068 batch3=34395849 |
| 2 | stdin | 3 <eSearchResult> <Count>4</Count> <IdList> <Id>7157</Id> <Id>672</Id> <Id>7157</Id> <Id>1956</Id> <Id>3845</Id> </IdList> <TranslationStack><TermSet><Id>999999</Id></TermSet></TranslationStack> </eSearchResult> | count=4 unique=4 batch1=7157,672,1956 batch2=3845 |
| 3 | stdin | 10 <eSearchResult><Count>0</Count><IdList></IdList><WarningList><OutputMessage>No items found.</OutputMessage></WarningList></eSearchResult> | count=0 unique=0 no ids |
Hints · 3
01Hint
root.findall("./IdList/Id") restricts the search to the right subtree.
02Hint
ElementTree gives you None for a missing element, so test `is not None` before reading .text.
03Hint
range(0, len(ids), batch) with a slice produces the batches.