metabolights
dataFind and retrieve public metabolomics studies from EMBL-EBI MetaboLights — search by disease, tissue or organism, read the ISA-Tab study design and factors, and download the Metabolite Assignment File holding identified metabolites and their per-sample intensities. Covers LC-MS, GC-MS and NMR studies across human, mouse and rat.
MetaboLights
EMBL-EBI's repository for metabolomics — 3,292 public studies at the time of writing, spanning LC-MS, GC-MS and NMR. This skill finds studies, reads what was actually measured and under what design, and gets the numbers onto disk.
Nothing here needs an account or a key.
Two things shape everything below, and both surprise people:
- The MetaboLights API has no search.
/studiesreturns 3,292 bare accessions and nothing else. Search lives in a different service entirely. - The measured values are not in the JSON. The API describes the study; the numbers live in a tab-separated file you fetch from a different host. And whether that file contains per-sample values at all varies by submitter — see The MAF is the data, which is the one section to read if you read only one.
Finding studies — use EBI Search, not the MetaboLights API
The obvious guess fails: /studies/search?query=… returns HTTP 400. MetaboLights is
indexed as a domain in EBI Search instead.
import json, urllib.parse, urllib.request
def search(query, size=10):
"""Study accessions matching a free-text query, via EBI Search."""
url = ("https://www.ebi.ac.uk/ebisearch/ws/rest/metabolights?"
+ urllib.parse.urlencode({
"query": query, "format": "json", "size": size,
"fields": "name,description,organism,study_design",
}))
d = json.loads(urllib.request.urlopen(url, timeout=45).read())
out = []
for e in d["entries"]:
f = e.get("fields", {})
first = lambda k: (f.get(k) or [""])[0]
out.append({"accession": e["id"], "name": first("name"),
"organism": first("organism")})
return d["hitCount"], out
total, hits = search("sarcopenia")
print(total, "studies")
for h in hits[:4]:
print(f" {h['accession']:<11} {h['organism'][:18]:<18} {h['name'][:52]}")
hitCount is the real total; size caps what comes back, so page with start= rather than
assuming the first response is everything.
An accession you find elsewhere may not be public. /studies lists exactly the 3,292 that
are. Fetching one that is not returns HTTP 401 — which reads like a problem with your
credentials and is not: the study exists but is still embargoed or in curation. A genuinely
nonexistent accession returns 400. So 401 means come back later, 400 means wrong
identifier, and neither is anything to do with authentication.
Query terms are OR-ed and ranked, so a two-word query returns more than either word alone,
sorted by relevance. Narrow with the organism and study_design fields rather than by
adding words.
Reading a study
def study(acc):
url = f"https://www.ebi.ac.uk/metabolights/ws/studies/{acc}"
return json.loads(urllib.request.urlopen(url, timeout=45).read())
d = study("MTBLS3341")
inv = d["isaInvestigation"]
st = inv["studies"][0]
print("title :", st["title"][:70])
print("factors :", [f["factorName"] for f in st["factors"]])
print("design :", [x["annotationValue"] for x in st["studyDesignDescriptors"]][:4])
print("assays :", [(a["measurementType"]["annotationValue"],
a["technologyType"]["annotationValue"]) for a in st["assays"]])
print("publication:", [(p.get("pubMedID"), p.get("title", "")[:40]) for p in st["publications"]])
print("validation :", d["validation"])
Three fields carry most of the triage value:
factorsis the experimental design — what was varied. A study with no factors is descriptive, not comparative, and will not support the contrast you probably want.studyDesignDescriptorscarries the free-text subject terms —sarcopenia,untargeted metabolites, the instrument class.validationis MetaboLights' own completeness check, shaped{"errors": [], "warnings": []}. A study with errors is not necessarily unusable, but it is worth looking at before building on it.
assays[i].dataFiles is empty even when the study has data. Do not read that as "no data" —
the ISA JSON describes structure, and the file inventory is a separate call.
What files exist
def files(acc):
url = f"https://www.ebi.ac.uk/metabolights/ws/studies/{acc}/files"
d = json.loads(urllib.request.urlopen(url, timeout=45).read())
return [(f["file"], f["type"]) for f in d["study"]]
for name, kind in files("MTBLS3341"):
print(f" {kind:<24} {name}")
Four ISA-Tab kinds, and the type tells you which is which without parsing names:
| type | file | what it is |
|---|---|---|
metadata_investigation |
i_Investigation.txt |
study-level metadata |
metadata_sample |
s_<ACC>.txt |
one row per sample, with its characteristics |
metadata_assay |
a_<ACC>_*.txt |
one row per assay run, linking sample to raw file |
metadata_maf |
m_<ACC>_*_maf.tsv |
the measured values |
Kinds, not files — a type repeats, and the MAFs partition your samples between them.
MTBLS3341 ships six files (positive and negative ionisation, so two assays and two MAFs);
MTBLS12833 ships ten, with four MAFs. Two consequences, and the second is the expensive one:
{f["type"]: f["file"] for f in ...}is the natural thing to write and it silently keeps whichever came last, discarding whole assays.- Different MAFs carry different samples.
MTBLS1452has three MAFs holding 12, 12 and 60 sample columns; their union is all 84 declared samples, but reading the first alone gives you 12 of 84 — 14% of the study, with nothing to indicate the rest exists. Measured also onMTBLS12036(567/568/568, union 568) andMTBLS11748(48/49, union 49).
So read every MAF and take the union. Group into lists first:
from collections import defaultdict
def files_by_type(acc):
url = f"https://www.ebi.ac.uk/metabolights/ws/studies/{acc}/files"
d = json.loads(urllib.request.urlopen(url, timeout=45).read())
out = defaultdict(list)
for f in d["study"]:
out[f["type"]].append(f["file"])
return dict(out)
by_type = files_by_type("MTBLS3341")
print({k: len(v) for k, v in by_type.items()})
print("MAFs:", by_type["metadata_maf"])
Get the files
Downloads come from the FTP tree over HTTPS, not from the web service. Both plausible web-service paths fail, and one of them fails misleadingly:
| attempt | result |
|---|---|
ws/studies/<ACC>/download/<file> |
400 "There is no study." — the study exists |
ws/studies/<ACC>/files/<file> |
404 |
ftp.ebi.ac.uk/pub/databases/metabolights/studies/public/<ACC>/<file> |
200 |
import os, urllib.request
FTP = "https://ftp.ebi.ac.uk/pub/databases/metabolights/studies/public"
def fetch(acc, outdir="Data/metabolights"):
"""Every ISA-Tab file onto disk — both ionisation modes if the study has them."""
os.makedirs(outdir, exist_ok=True)
manifest = []
for name, kind in files(acc):
dest = os.path.join(outdir, f"{acc}_{name}")
urllib.request.urlretrieve(f"{FTP}/{acc}/{name}", dest)
manifest.append({"file": name, "type": kind, "path": dest,
"bytes": os.path.getsize(dest)})
print(f" {kind:<24} {manifest[-1]['bytes']:>9,} B {name[:44]}")
with open(os.path.join(outdir, f"{acc}_manifest.json"), "w") as fh:
json.dump({"accession": acc, "source": f"{FTP}/{acc}/", "files": manifest}, fh, indent=2)
return manifest
fetch("MTBLS3341")
The same tree also holds FILES/ (raw instrument data — often tens of GB), HASHES/,
METADATA_REVISIONS/, and a <ACC>.mhd.json. Check a raw file's size with a HEAD request
before fetching it; the metadata above is a few kilobytes, the raw data is not.
The MAF is the data — and its shape is not guaranteed
The Metabolite Assignment File is what you came for: one row per identified metabolite, with
identifiers (database_identifier, chemical_formula, smiles, inchi), analytical context
(mass_to_charge, retention_time), and then — sometimes — one column per sample.
Sometimes. This is the trap, and it is silent. Measured across three studies:
| study | MAF columns | samples declared | per-sample columns present |
|---|---|---|---|
MTBLS1 |
150 | 132 | 132 |
MTBLS3341 |
40 | 19 | 19 |
MTBLS2679 |
25 | 133 | 0 |
MTBLS2679 declares 133 samples and publishes none of them individually — its MAF ends at
smallmolecule_abundance_sub, padj, FoldChangeLog2. It reports a summary, not a matrix.
Code that assumes "the columns after the annotation block are samples" reads padj as a
measurement and produces a table that looks right.
So do not guess by position. Intersect with the sample sheet, which is the authority on what a sample is called:
import csv, io
def maf_matrix(acc):
"""Every MAF, unioned, with honest coverage against what the study declares."""
by_type = files_by_type(acc)
grab = lambda n: urllib.request.urlopen(f"{FTP}/{acc}/{n}", timeout=60).read().decode("utf-8", "replace")
samples = {r["Sample Name"].strip() for r in
csv.DictReader(io.StringIO(grab(by_type["metadata_sample"][0])), delimiter="\t")
if r.get("Sample Name")}
tables, covered = [], set()
for name in by_type["metadata_maf"]:
rows = list(csv.DictReader(io.StringIO(grab(name)), delimiter="\t"))
cols = [c for c in (rows[0] if rows else {}) if c.strip() in samples]
tables.append({"maf": name, "metabolites": len(rows), "samples": cols})
covered |= {c.strip() for c in cols}
print(f" {name[:52]:<52} {len(rows):>5} metabolites x {len(cols):>3} samples")
print(f" {acc}: {len(covered)}/{len(samples)} declared samples appear in some MAF")
if not covered:
print(" -> summary statistics only; no per-sample values anywhere in this study")
elif len(covered) < len(samples):
print(" -> partial: the rest were run but their per-sample values are not published")
return tables, samples, covered
maf_matrix("MTBLS3341")
Metabolite naming is the other obstacle to combining studies. Prefer database_identifier
(ChEBI, HMDB) over metabolite_identification, which is free text and inconsistent between
submitters.
Related repositories
Metabolomics is split across two archives that do not mirror each other, so a negative result
in one is not a negative result overall. The NIH Metabolomics Workbench is the other, and its
refmet service is the better tool for reconciling metabolite names across studies. Searching
both is normal practice.
Try it
A self-contained check. Public data, no account, no key. Fetches a few hundred kilobytes.
Data — MTBLS3341, "Reduced uremic metabolites are prominent feature of sarcopenia", a
human LC-MS study:
https://www.ebi.ac.uk/metabolights/ws/studies/MTBLS3341
Openly accessible with no account or licence acceptance. EMBL-EBI's licensing policy states CC0 is preferred across its resources; MetaboLights is not named individually on that page, so confirm per-study terms before redistributing. Last confirmed reachable 2026-08-19.
import csv, io, json, urllib.parse, urllib.request
from collections import defaultdict
WS = "https://www.ebi.ac.uk/metabolights/ws"
FTP = "https://ftp.ebi.ac.uk/pub/databases/metabolights/studies/public"
ACC = "MTBLS3341"
get = lambda u: urllib.request.urlopen(u, timeout=60).read()
# 1. The API has no search — the obvious path is a 400, and EBI Search is the way in.
try:
get(f"{WS}/studies/search?query=sarcopenia")
searched = "200 (unexpected)"
except urllib.error.HTTPError as e:
searched = f"{e.code}"
ebi = json.loads(get("https://www.ebi.ac.uk/ebisearch/ws/rest/metabolights?"
+ urllib.parse.urlencode({"query": "sarcopenia", "format": "json", "size": 5})))
# 2. Study shape.
d = json.loads(get(f"{WS}/studies/{ACC}"))
st = d["isaInvestigation"]["studies"][0]
# 3. File inventory, keyed by type rather than by filename.
inv = defaultdict(list)
for f in json.loads(get(f"{WS}/studies/{ACC}/files"))["study"]:
inv[f["type"]].append(f["file"])
# 4. The MAF, with sample columns identified by intersection, not by position.
maf = list(csv.DictReader(io.StringIO(get(f"{FTP}/{ACC}/{inv['metadata_maf'][0]}").decode("utf-8", "replace")), delimiter="\t"))
smp = {r["Sample Name"].strip() for r in
csv.DictReader(io.StringIO(get(f"{FTP}/{ACC}/{inv['metadata_sample'][0]}").decode("utf-8", "replace")), delimiter="\t")
if r.get("Sample Name")}
cols = [c for c in maf[0] if c.strip() in smp]
# 5. The same intersection on a study whose MAF publishes no per-sample values at all.
inv2 = defaultdict(list)
for f in json.loads(get(f"{WS}/studies/MTBLS2679/files"))["study"]:
inv2[f["type"]].append(f["file"])
maf2 = list(csv.DictReader(io.StringIO(get(f"{FTP}/MTBLS2679/{inv2['metadata_maf'][0]}").decode("utf-8", "replace")), delimiter="\t"))
smp2 = {r["Sample Name"].strip() for r in
csv.DictReader(io.StringIO(get(f"{FTP}/MTBLS2679/{inv2['metadata_sample'][0]}").decode("utf-8", "replace")), delimiter="\t")
if r.get("Sample Name")}
cols2 = [c for c in maf2[0] if c.strip() in smp2]
# 6. Multi-MAF studies partition their samples: the first MAF alone is a subset.
inv3 = defaultdict(list)
for f in json.loads(get(f"{WS}/studies/MTBLS1452/files"))["study"]:
inv3[f["type"]].append(f["file"])
smp3 = {r["Sample Name"].strip() for r in
csv.DictReader(io.StringIO(get(f"{FTP}/MTBLS1452/{inv3['metadata_sample'][0]}").decode("utf-8","replace")), delimiter="\t")
if r.get("Sample Name")}
per_maf, union3 = [], set()
for m in inv3["metadata_maf"]:
rows = list(csv.DictReader(io.StringIO(get(f"{FTP}/MTBLS1452/{m}").decode("utf-8","replace")), delimiter="\t"))
cs = {c.strip() for c in (rows[0] if rows else {}) if c.strip() in smp3}
per_maf.append(len(cs)); union3 |= cs
assert searched == "400", "the MetaboLights API gained a search endpoint"
assert per_maf[0] < len(union3), "reading one MAF should undercount a multi-MAF study"
assert set(d) == {"mtblsStudy", "isaInvestigation", "validation"}, sorted(d)
assert {"metadata_maf", "metadata_sample", "metadata_assay", "metadata_investigation"} <= set(inv)
assert len(inv["metadata_maf"]) == 2, "MTBLS3341 has positive and negative ionisation modes"
assert cols, "MTBLS3341's MAF should carry per-sample columns"
assert not cols2, "MTBLS2679's MAF is expected to carry none"
print(f"search endpoint on the WS API : HTTP {searched}")
print(f"EBI Search, 'sarcopenia' : {ebi['hitCount']} studies")
print(f"study payload keys : {sorted(d)}")
print(f"factors : {[f['factorName'] for f in st['factors']]}")
print(f"assay : {st['assays'][0]['technologyType']['annotationValue']}")
print(f"validation : {d['validation']}")
print(f"{ACC} MAF : {len(maf)} metabolites x {len(cols)} samples of {len(smp)} declared")
print(f"MTBLS2679 MAF : {len(maf2)} metabolites x {len(cols2)} samples of {len(smp2)} declared")
print(f"MTBLS1452 MAFs : {per_maf} samples each, union {len(union3)} of {len(smp3)} declared")
Expect
Invariants — these hold regardless of release, and a failure means the skill is wrong:
- The MetaboLights web service still has no search:
/studies/searchis a 400. If it ever returns 200, this skill's first section is obsolete rather than merely stale. - A study payload is exactly
mtblsStudy,isaInvestigation,validation— three keys. - The file inventory carries all four ISA-Tab types, so keying on
typeis safe and parsing filenames is unnecessary. MTBLS3341has per-sample columns andMTBLS2679has none, from identical code. That asymmetry is the whole point of intersecting with the sample sheet, and the two assertions are what stop the technique silently degrading into positional guessing.MTBLS1452's first MAF holds strictly fewer samples than its three MAFs together. The assertion isper_maf[0] < len(union3), not a fixed count, so it keeps holding if the study is revised. This is the check that stops anyone reintroducing "just read the first MAF", which would have handed a reader 12 of 84 samples with nothing to signal the loss.
Observed 2026-08-19 — these move when a submitter revises a study, so treat a mismatch as drift to investigate rather than a failure:
search endpoint on the WS API : HTTP 400
EBI Search, 'sarcopenia' : 9 studies
study payload keys : ['isaInvestigation', 'mtblsStudy', 'validation']
factors : ['Disease', 'Age', 'Gender', 'Frail scale']
assay : mass spectrometry
validation : {'errors': [], 'warnings': []}
MTBLS3341 MAF : 57 metabolites x 19 samples of 19 declared
MTBLS2679 MAF : 28 metabolites x 0 samples of 133 declared
MTBLS1452 MAFs : [12, 12, 60] samples each, union 84 of 84 declared
Sources
- MetaboLights — https://www.ebi.ac.uk/metabolights/
- Web service — https://www.ebi.ac.uk/metabolights/ws
- EBI Search — https://www.ebi.ac.uk/ebisearch/
- ISA-Tab specification — https://isa-specs.readthedocs.io/
- EMBL-EBI licensing policy — https://www.ebi.ac.uk/licencing