Download PubMed abstracts, load them into a DuckDB database (keeping full version history), and export the latest version of every abstract to JSON (for ingest into Node Annotator / ElasticSearch) and to Parquet (for downloadable queries).
The JSON export follows the field names of the NCATS Translator
DocumentMetadataAPI,
plus three fields of our own: id, identifiers
and pub_date. The database itself uses PubMed's
own field names.
Downloading and XML parsing reuse pubmed-downloader
(bulk download of the PubMed baseline + daily update files, plus the rich Article
data model). On top of it, pubmed2db adds:
- MD5 tracking — each
<file>.xml.gz.md5sidecar is fetched and the checksum stored; a new or changed checksum triggers a (re)load. PubMed files are normally immutable, so this mostly just lets new files be discovered incrementally. - Full version history — every loaded file's rows are tagged with their
source_fileprovenance, so an abstract revised across several update files is kept as multiple rows. Thelatest_articleview selects the newest non-deleted version of each PMID (honoring<DeleteCitation>removals). - Faithful publication dates — raw
PubDatecomponents (Year/Month/Season/Day/MedlineDate) are preserved rather than collapsed to a single date.<Season>shares thepub_monthcolumn with<Month>— the DTD makes the two mutually exclusive — so a Parquet consumer readingarticle.pub_monthseesWinterorSep-Decas readily asMar. - Journal names — the journal title and abbreviations come from the NLM Catalog
(
uv run pubmed2db journals) and are joined onnlm_catalog_id.
Clone the repository and let uv build the
environment; there is no need to install pubmed2db itself — every command below
is run from the repo root with uv run, which syncs dependencies on demand.
git clone https://github.com/TranslatorSRI/pubmed2db.git
cd pubmed2db
uv syncIf uv's default cache directory (~/.cache/uv) is not writable or is on a small
quota — common on HPC login nodes — point it somewhere else once per shell (or
in your ~/.bashrc / job script):
export UV_CACHE_DIR=/path/to/writable/uv-cacheAll commands share --data-dir (default data/), which sets the root for
downloaded PubMed files and the database. pubmed-downloader keeps its cache in
a pubmed/ subdirectory inside it (data/pubmed/baseline/,
data/pubmed/updates/), so the layout under data/ is managed automatically.
Budget disk accordingly: the 2026 files alone are 51 GB of baseline and 13 GB of updates, before the DuckDB database and any export. A new baseline year adds that much again — see Re-running after a gap.
# Download the baseline + update files to data/ (MD5-checked, incremental).
uv run pubmed2db --data-dir data download
# Refresh the journal dimension from the NLM Catalog.
uv run pubmed2db --data-dir data journals
# Parse downloaded files into the database (full history).
uv run pubmed2db --data-dir data load
# Export the latest abstract of every PMID as sharded NDJSON (DocumentMetadataAPI fields).
uv run pubmed2db --data-dir data export --format json --out data/json --shards 16
# Export the database to Parquet (latest version per table, or --all for full history).
# Note: unlike the JSON export, this has not yet been run against the full corpus.
uv run pubmed2db --data-dir data export --format parquet --out data/parquet
# Download + journals + load in one step (for scheduled runs).
uv run pubmed2db --data-dir data update
# Report what's been downloaded, loaded, and is ready to export (read-only).
uv run pubmed2db --data-dir data status
# Sanity-check a finished export and write validation_report.json alongside it.
# (Uses the DB when present; --offline skips the Entrez API cross-checks.)
uv run pubmed2db --data-dir data validate data/json --email you@example.com--data-dir data is the default, so omitting it gives the same result.
The DuckDB database is stored as <data-dir>/pubmed.duckdb; override with --db.
DuckDB writes the NDJSON itself — a single COPY ... (FORMAT JSON) whose
projection is built from export._JSON_FIELDS, rather than Python serializing
row by row. The serialization therefore runs in C++ across every thread (3x
faster on a 2M-document benchmark), and two things follow:
--shards Nis a maximum, not a count. Output is one file per writer thread, so--shardscaps that statement's threads; a small dataset can be written by fewer. Omit it to use DuckDB's own thread count.- Records are not in PMID order. The export no longer sorts — that sort cost
~3 minutes and most of the peak memory of a full run. Nothing downstream needs
the order;
validatebuilds its own sorted PMID manifest. - Shards are gzipped (
pubmed_metadata_0.ndjson.gz) unless you pass--no-gzip. NDJSON compresses ~4-5x — a full corpus goes from ~52 GiB to ~12 — and each shard is compressed as it is written, so there is no second pass.validate <dir>reads either form with no flag, andzcatstill works line by line.
Re-running an export first deletes the pubmed_metadata_* files already in the
output directory, so a shorter run cannot leave a previous run's shards behind.
The three parsed date fields are conveniences, and no arrangement of them
represents every PubDate PubMed publishes: a cross-year range like
<MedlineDate>1998 Dec-1999 Jan</MedlineDate> (PMID:10188493) has no single
month, so splitting it leaves a year inside pub_month. So every record also
carries pub_date, PubMed's own date string verbatim — the same thing NCBI
esummary ships as pubdate:
{
"id": "PMID:10188493",
"pub_date": "1998 Dec-1999 Jan",
"pub_year": "1998",
"pub_month": "Dec-1999 Jan",
"pub_day": "",
"...": "..."
}Render citations from pub_date; sort and filter on pub_year. Neither has to
parse the other. Records with an ordinary date get the same treatment —
"2019 Mar 15" — so a consumer never needs to know which shape it is looking at.
pub_year takes the leading year of a cross-year range, matching what NCBI's
own sortpubdate does. A consumer that needs the trailing year reads it out of
pub_date.
pub_dateis new, and a re-export is required to ship it. An export made before this field existed has eleven fields, not twelve.
Alongside the DocumentMetadataAPI fields, each JSON record carries an
identifiers array of CURIEs — the PMID, plus the DOI and PMCID when PubMed
published them:
{
"id": "PMID:30690000",
"identifiers": ["PMID:30690000", "PMCID:PMC6423490", "doi:10.1016/j.ejphar.2019.01.030"],
"journal_name": "European journal of pharmacology",
"...": "..."
}PMID and the lowercase doi deliberately match Babel's src/prefixes.py, so
those CURIEs join directly against the Babel publication compendium. PubMed's
PMCID values already begin with PMC, hence the doubled PMCID:PMC6423490. A
record with neither a DOI nor a PMCID still gets its own ["PMID:<id>"]; the
array is never empty and never null.
The PMCID prefix may still change. Babel uses
PMC, this export usesPMCID, and neither the Core Components specification nor the DocumentMetadataAPI README has a PMC example to settle it; the production endpoint does not resolve PMCIDs under either prefix. See #33.
Consumers must match identifiers case-insensitively. Values are stored and exported exactly as PubMed published them, with no case normalization (Babel does the same). DOIs are case-insensitive by specification and PubMed is not internally consistent about them, so
doi:10.1234/ABCanddoi:10.1234/abccan both occur and refer to the same article.
Only DOIs and PMCIDs are promoted into this field. Any other ArticleId type
PubMed supplies (pii, mid, …) is still loaded and is available in the
article_id table and the Parquet export.
Databases loaded before 2026-08-04 need rebuilding before this field is trustworthy.
identifiersreads thearticle_idtable, and until "Extract cited PMIDs and article IDs ourselves" that table was populated by an upstream parser that also swept up every cited reference's DOI and PMCID (seeCLAUDE.md). A database loaded after that is unaffected — this feature changed nothing about howarticle_idis written. Nothing detects the stale rows automatically, because the source files have not changed.load --forcere-parses the whole corpus and does replace those rows, but it cannot undo a table that left the schema — a database that old still carries a populatedreference_citation, which no reload clears (seeload --forceor a fresh database?). Load into a fresh one:rm data/pubmed.duckdb uv run pubmed2db --data-dir data load
Three more group-level options tune DuckDB itself, which matters on a cluster:
--threads N(PUBMED2DB_THREADS) caps the thread pool. Rarely needed on Slurm: DuckDB reads the cgroup's CPU quota and already sizes the pool from--cpus-per-task(it uses the machine's core count only off a cluster).--memory-limit SIZE(PUBMED2DB_DUCKDB_MEMORY_LIMIT, e.g.48GB) caps the buffer pool. DuckDB does read the Slurm cgroup, defaulting to ~76% of--mem(measured on duckdb 1.5.4), so this is not about rescuing it from a node-sized default — it is about leaving room for the memory DuckDB's limit does not cover: the lxml tree, the parsed records and the Arrow batch all live in the same cgroup.--temp-dir PATH(PUBMED2DB_DUCKDB_TEMP_DIR) sets where DuckDB spills when a query exceeds its memory budget.
All three go before the subcommand. See slurm/README.md.
The steps are independent and incremental (re-running download revalidates
existing files rather than refetching them), and each checks its prerequisites
against the database's own state: load errors if nothing has been downloaded,
and export errors if nothing has been loaded, warns if some downloaded files
have not been loaded yet, and warns if the journal dimension is empty (journal
names would be blank). When in doubt, uv run pubmed2db update runs the whole pipeline
(download → journals → load) in order.
uv run pubmed2db validate <dir> inspects a finished JSON export and writes a
validation_report.json (pretty-printed, archivable) whose leading
errors/warnings arrays are empty when the export looks good. It confirms
every record parses and has the expected fields, compares the exported count to
both the live PubMed total and the local database, re-fetches a random sample
from NCBI Entrez to confirm field values match, and confirms a sample of dropped
PMIDs are genuinely gone. It exits non-zero on errors (--fail-on-warn also
fails on warnings) so it can gate an HPC run; pass --offline to skip the
network checks. Set --email (or NCBI_EMAIL) and optionally --api-key (or
NCBI_API_KEY, which raises the rate limit) for the API checks.
The STRUCTURE heading also carries the share of records that came out with each
identifier type (identifier_coverage in the report). Nothing gates on it — one
export in isolation cannot say whether 96% is right and 6% is not — but a rate
that collapsed between two runs is invisible without the number.
A long run narrates itself: one start line naming the shards and confirming whether the database and an API key were picked up (never the key itself), a progress line with an ETA once a minute while the shards are read, and a line per phase after that — so a run waiting on NCBI is distinguishable from one still reading. Peak RSS is logged at the end and stored in the report.
Stdout is a test report: every check is listed with what it expected and what it saw, so a reviewer can tell what was verified, what was skipped, and what is not covered at all. Abridged, from a full-corpus run:
Validation WARN: 40,901,984 record(s) in 16 shard(s) of data/json
database available · Entrez online, no API key (3 req/s; set NCBI_API_KEY for 10/s)
ran in 10m 51s, peak RSS 5.177 GiB
COVERAGE
[pass] vs-entrez within [95%, 105%] of the live PubMed total 40,901,984 of 40,944,369 (99.896%)
[pass] vs-database matches the database's latest_article count exact match (40,901,984)
[skip] vs-previous coverage within 10% of the previous report's no --previous-report
FIELD ACCURACY (240 records sampled: 15/shard x 16 shards, seed 0)
[WARN] core-fields <20% of compared fields differ from Entrez 20 of 1,600 differ (1.25%)
by field: 18x pub_year, 1x article_title, 1x issue
0 exported a different value (incorrect data)
20 exported blank where Entrez has a value (missing data)
e.g. PMID:152567 pub_year exported "" vs. Entrez "1978"
NOT CHECKED
- compared strictly against Entrez: article_title, volume, issue, pub_year, ...
- compared but never fails the run (assigned upstream after our last update file): identifiers
- MeSH terms, authors, affiliations and grants are stored in the DB, never exported
The values_differ count is printed even when it is zero, because that zero
is the decision a reviewer has to make: fields left blank are a completeness gap
that is safe to pass downstream, whereas a field exported with the wrong value
is a correctness bug. [skip] means a check could have run with another flag or
a network; [n/a ] means there was nothing to verify (no deletions to sample).
Every line of that output is rendered from validation_report.json, which also
gains a checks_run array — the same list, machine-readable, with structured
mismatch tallies.
Coverage counts alone cannot tell you that two exports of the same size hold the
same records, so validate can write a sorted PMID manifest and diff against
an earlier one:
# Archive this export's PMID set (gzipped, one PMID per line). Date-stamp it,
# and keep it outside the export directory: one shared name means the next run
# overwrites the manifest it is diffing against.
uv run pubmed2db validate data/json --manifest data/manifests/pmids-20260820.txt.gz
# Next month: report which PMIDs disappeared since that export.
uv run pubmed2db validate data/json-new \
--previous-manifest data/manifests/pmids-20260820.txt.gz \
--manifest data/manifests/pmids-20260921.txt.gzA dropped PMID is fine if the database recorded a DeleteCitation for it; one
that vanished without a recorded deletion is an error, since it means
records were lost rather than retired. With no database available the drops can't
be attributed, so they downgrade to a warning to review.
The coverage check expects the export to be within ±5% of the live PubMed total
(the 2026-07-30 full run came in at 99.90% of it). A partial export — from a
--limit test download — is legitimately far below that, so pass something like
--entrez-low 0.001 when validating one, or --offline to skip the comparison.
Use --limit N on download/update to fetch only the newest N files when testing.
Note: The file layout under
data/differs from Babel's PubMed download, so the two cannot share a download cache at this time.
Running download and then load again is safe: load picks up only the files
that are new or changed, and it cannot introduce duplicate data.
- Only new/changed files are loaded.
downloadbumps a file'sdownloaded_atonly when its published MD5 is new or differs from the stored one, andloadskips every file whoseprocessed_atis at or after itsdownloaded_at. Unchanged files are no-ops. - Reloading a file replaces it. Before inserting,
loaddeletes all rows previously loaded from thatsource_file, in the same transaction, so even a forced reload (load --force) is idempotent. - Several versions of a PMID are the design, not duplication. A row's
identity is
(pmid, source_file); thelatest_articleview returns the version with the highestfile_order_keyper PMID, and drops the PMID if a later file recorded a<DeleteCitation>for it. Exports therefore see one row per PMID no matter how many versions are stored.
A normal incremental run only re-parses files whose published checksum moved, so neither a parser fix nor a schema change reaches data that is already loaded. Two ways to apply one, and they are not interchangeable:
load --forcere-parses every local file and replaces its rows, so it applies a parsing change to the whole corpus: roughly a baseline's worth of time (~2–3 h), no re-download, and the database keeps its history.- A fresh database (delete
<data-dir>/pubmed.duckdb, thenload) is the answer whenever the schema changed, and the safer default if you are unsure.
The asymmetry is that schema.sql only ever adds: CREATE TABLE IF NOT EXISTS
plus explicit ALTER TABLE ... ADD COLUMN IF NOT EXISTS migrations. Nothing
drops a table or a column, so a forced reload refreshes the rows of tables that
still exist while a table removed from the schema keeps its rows forever —
reference_citation, dropped during development, is exactly that case. A forced
reload also leaves any pre-existing wrong rows in place for files it re-parses
identically.
Rebuilding costs a full load and nothing else — the downloaded files are not
touched — so unless the corpus is already loaded on a machine where 2–3 hours of
load is cheaper than the disk churn, prefer the rebuild. At a new baseline year
the question is moot: a fresh database is the recommended path anyway, since
loading the new year into the old database stores a second version of every PMID
(see above).
Watch for a new baseline year. Around November–December PubMed publishes a
fresh baseline for the coming year (pubmed26n*.xml.gz lands in late 2025,
after pubmed25n*.xml.gz), which is a complete re-issue of the corpus, not an
increment. Downloading it makes ~1,300 files new at once, so the following
load re-parses everything and the article table ends up holding a second
full copy of every PMID. The result is still correct — file_order_key puts the
newer year first, so latest_article resolves to it — but the database roughly
doubles in size and the load takes as long as the original one. status says so
directly once a second baseline year is on disk, and pending_files in the
thousands rather than the dozens is the same signal.
Prefer a fresh database at the year boundary, for a reason beyond size: a
grown database is additive. It can gain PMIDs but never lose them. A PMID that
is in last year's baseline and absent from this year's, without a
<DeleteCitation> ever reaching us — a missed updatefile, or a record PubMed
drops quietly — keeps last year's row as its newest version and stays in every
export from then on. A database built from the new baseline simply doesn't
contain it.
Either way, delete the previous year's files yourself:
rm data/pubmed/baseline/pubmed25n*.xml.gz data/pubmed/updates/pubmed25n*.xml.gzdownload never removes anything. Once PubMed drops a file from its listing we
stop hearing about it, so a full extra copy of the corpus sits in data/ until
you clear it — for 2026 that is 51 GB of baseline plus 13 GB of updates.
Verification is on by default, but only hashes files that are new or whose
published checksum changed — re-running download over an unchanged baseline
costs no local I/O. Corruption happens at download time, which stays covered.
--no-verify skips the hashing entirely; either way the .md5 sidecars are
still fetched, so a changed published checksum is always detected.
- We reuse
pubmed-downloaderas-is for downloading and XML parsing, but work around three bugs in it (≤ 0.0.14), all tracked inFUTURE.md:catalog.process_journal_overview()requiresstart_year/end_yearfields the realJ_Entrez.txtdoes not contain, so it raises on live data; its reference extraction looks underMedlineCitationfor a<ReferenceList>that PubMed puts under<PubmedData>, so it never finds one; and its article-ID extraction descends into that<ReferenceList>, attributing every cited reference's DOI to the citing article. We parse the journal file and the article IDs ourselves; the reference bug is moot here because we deliberately do not store the citation graph (one article carries ~444 references, and nothing consumes them). - This tool is intended to eventually replace the PubMed download in
Babel (
createcompendia/publications.py).
See AGENTS.md for a map of the source and the design decisions
behind it, and FUTURE.md for known limitations and planned work.
uv run pubmed2db loadtakes a long time to run, and it might be beneficial to parallelize this: transform each PubMed file into a separate DuckDB file, then use a query that spans multiple files to either load everything into one file or to simply export it from the multiple files (using PMIDs to group related queries might not take very long?). Running it with 64G of memory seems sufficient.uv run pubmed2db export --format jsonis fast but memory-hungry: 40,901,984 documents in ~23 minutes (≈30k documents/s) at a peak RSS of 201.1 GiB, run with--mem 256G(an earlier run peaked at 199.6 GiB). Unlikeload, its memory scales with the whole database rather than the largest input file — seeslurm/README.mdfor why, and for what to request on a cluster.exportpublishes in place, not atomically. Both formats write straight into--out: the JSON export deletes the shards of the previous run before DuckDB writes the new ones, and the Parquet export replaces one table file at a time (sweeping any left by a table that has since left the schema). A run that dies partway — OOM, full disk — therefore leaves a half-written dataset that looks complete to anything globbing the directory. Export into a fresh directory and swap it into place yourself if consumers read the output while exports run.export --format parquetis untested at full scale. Only the JSON export has been run against the whole corpus. Parquet should be the lighter of the two (each table is written by a DuckDBCOPY ... TOrather than pulled through Python), but that is reasoning, not a measurement: request the same 256 GB the first time and check the logged peak RSS.
uv sync --extra dev
uv run pytestTests gzip the readable XML fixtures under tests/fixtures/ into temporary
pubmedNNnNNNN.xml.gz files; scratch downloads and databases go under ./data
(gitignored).
Helper scripts live in scripts/ and document themselves — run one with
--help for its usage and options. benchmark_load.py times parsing against
insertion for a given file, which is where slurm/README.md's
memory and wall-time figures come from.