dbSNP — reference variant identifiers (rsID) and the COMMON flag¶
dbSNP157/GRCh38 is NCBI's catalog of short genetic variation — every
submitted, deduplicated SNP/indel gets a stable rs# accession. iris reads
it as a single bgzipped, tabix-indexed VCF, GCF_000001405.40.gz, at
/mnt/cephfs/hot_nvme/dbsnp/dbSNP157_hg38/ (29.55 GB / 27.52 GiB, .tbi
index ~3.0 MB). Unlike every other tabix source iris reads, this file is
one file for the whole genome, not split per chromosome, and its
#CHROM values are NCBI RefSeq accessions (NC_000001.11, …) rather
than chr1/1.
This page documents the file itself — contigs, INFO schema, a couple of real
parsing gotchas discovered while building the adapter — and shows how iris
reads it via DbSnpAnnotator
(src/iris/adapters/driven/annotators/dbsnp.py). Every number and example
below ran against the real file.
Scale¶
698 contigs total, but only 25 are usable by iris (see
Contigs below). Full-genome
record counts weren't exhaustively tallied (that's a multi-minute scan even
with an index), but real per-contig counts, fetched end-to-end:
| Contig | Records | Fetch time |
|---|---|---|
NC_000001.11 (chr1, 248.9 Mb) |
93,124,941 | 59.8s |
NC_000021.9 (chr21, 46.7 Mb) |
15,323,112 | 10.1s |
NC_000024.10 (chrY, 57.2 Mb) |
3,047,929 | 1.8s |
NC_012920.1 (chrMT, 16.6 kb) |
9,229 | <0.1s |
Density varies a lot by contig — chr1 and chr21 are both ~0.33–0.37 records/bp, chrY drops to ~0.053 records/bp (poorly callable, repetitive), and MT is ~0.56 records/bp (mitochondrial hypervariability). Don't extrapolate a genome-wide total from one contig's density.
Contigs: only 25/698 are reachable from iris¶
NC_ (chromosomes) 25 NC_000001.11 … NC_000022.11, NC_000023.11 (X),
NC_000024.10 (Y), NC_012920.1 (MT)
NW_ (unplaced scaffolds) 323
NT_ (alt loci / patches) 350
DbSnpAnnotator resolves contigs via resolve_human_chromosome(), which
only knows the 25 primary NC_* accessions (see
HUMAN_CHROMOSOMES). The 673 NW_/NT_
scaffold contigs are silently skipped — any dbSNP record that exists only
on an alt haplotype or unplaced scaffold is unreachable through this
annotator. This mirrors iris' general policy of working against the
primary assembly; it's not a bug, but worth knowing before assuming "no
match" means "not in dbSNP" for a coordinate on a patch contig.
INFO schema¶
31 INFO fields, grouped by what DbSnpAnnotator actually consumes vs. not:
Consumed by DbSnpAnnotator¶
| Field | Meaning |
|---|---|
ID (column, not INFO) |
The rs# accession itself — this is dbsnp_id |
COMMON |
Flag: ≥1% minor allele frequency in ≥1 1000Genomes population, with ≥2 founders contributing to that frequency (dbSNP's own definition, from the VCF header) |
Present but not consumed¶
| Field | Meaning |
|---|---|
RS |
The rs# as an integer (redundant with ID) |
GENEINFO |
symbol:gene_id pairs, \|-delimited for multiple genes, e.g. HES4:57801 |
PSEUDOGENEINFO |
Same format, for overlapping pseudogenes |
dbSNPBuildID |
First dbSNP build this rs# appeared in — a rough recency/provenance signal |
SAO |
Allele origin: 0 unspecified, 1 germline, 2 somatic, 3 both |
SSR |
Suspect reason bitmask: paralog, byEST, oldAlign, Para_EST, 1kg_failed, other |
VC |
Variant class — observed values: SNV, INDEL, INS, DEL, MNV |
PM, NSF, NSM, NSN, SYN, U3, U5, ASS, DSS, INT, R3, R5, GNO, PUB |
Boolean flags for publication status and coarse functional class (non-synonymous frameshift/missense/nonsense, synonymous, UTR, splice site, intron, gene-flanking, genotypes-available) — all superseded by GencodeVariantAnnotator/VepFlightAnnotator's actual transcript-consequence calls |
FREQ |
Per-study allele frequencies — see below, not currently extracted into AlleleFrequency |
CLNHGVS, CLNVI, CLNORIGIN, CLNSIG, CLNDISDB, CLNDN, CLNREVSTAT, CLNACC |
Legacy embedded ClinVar mirror — see below |
The FREQ field: a pysam parsing gotcha¶
FREQ packs a two-level structure into one string: studies separated by
|, each as STUDY:freq_ref,freq_alt1,freq_alt2,…:
Its header declares Number=. (variable-length), which tells pysam "split
on commas" — pysam has no idea the string also uses | and : internally,
so it mangles the pipe-delimited studies into meaningless comma fragments:
>>> record.info["FREQ"]
('KOREAN:0.9891', '0.0109', '.|SGDP_PRJ:0', '1', '.|dbGaP_PopFreq:1', '.', '0')
The correct reconstruction rejoins with , before splitting on |:
>>> ",".join(record.info["FREQ"]).split("|")
['KOREAN:0.9891,0.0109,.', 'SGDP_PRJ:0,1,.', 'dbGaP_PopFreq:1,.,0']
Sampling ~140K records across 4 chromosomes found 32 distinct study cohorts (not an exhaustive count — dbSNP157 draws from more across the whole genome):
1000Genomes, 1000Genomes_30X, ALSPAC, Chileans, Daghestan, Estonian, ExAC,
FINRISK, GENOGRAPHIC, GENOME_DK, GnomAD_exomes, GnomAD_genomes, GoESP, GoNL,
HGDP_Stanford, HapMap, KOREAN, Korea1K, Korea4K, MGP, NorthernSweden,
PAGE_STUDY, PRJEB36033, PRJEB37584, Qatari, SGDP_PRJ, Siberian, TOMMO,
TOPMED, TWINSUK, Vietnamese, dbGaP_PopFreq
DbSnpAnnotator doesn't extract any of these into AlleleFrequency — they're
per-study cohorts of wildly varying size and ancestry composition, not the
standardized population buckets AlleleFrequency.population expects
elsewhere (compare MCPS, which has exactly that: consistent
labeled populations). If a future need calls for gnomAD- or TOPMed-specific
frequencies from dbSNP's FREQ, use the reconstruction above — GnomAD_exomes,
GnomAD_genomes, and TOPMED are all present as study labels.
The legacy CLN* block: stale, not empty¶
DbSnpAnnotator's docstring used to claim these fields were "no longer
populated by recent builds" — that turned out to be wrong. In a 286K-record
sample, CLNSIG was populated on 4,337 records (~1.5%):
NC_000001.11 1013466 rs3841266 CLNSIG=(.,2|2) CLNDN=(.,Mendelian_susceptibility_to_mycobacterial_diseases_due_to_complete_ISG15_deficiency|not_specified)
NC_000001.11 1013549 rs553176213 CLNSIG=(.,3,.,.,.) CLNDN=(.,ISG15-related_disorder,.,.,.)
The catch: CLNSIG here uses dbSNP's own numeric code table (from this
file's own header — 2 = Benign, 3 = Likely benign, 5 = Pathogenic, …),
not ClinVar's current string vocabulary ("Pathogenic",
"Likely_benign", …) that
ClinvarAnnotator parses
from the authoritative ClinVar TSV. It's a snapshot embedded at some
earlier ClinVar release and not kept in sync with dbSNP's own build cadence
— a stale, differently-encoded duplicate, which is why DbSnpAnnotator
deliberately doesn't parse it. Also positionally aligned to ALT (.
placeholder per allele with no data, |-joined when an allele has multiple
ClinVar submissions) rather than a single scalar — another reason to prefer
ClinvarAnnotator's purpose-built parsing over reimplementing this.
Access patterns from iris¶
Registering the source¶
# ~/.iris/config.toml
[sources.dbsnp]
host = "local"
path = "/mnt/cephfs/hot_nvme/dbsnp/dbSNP157_hg38/GCF_000001405.40.gz"
from iris.adapters.driven.annotators.dbsnp import DbSnpAnnotator
annotator = DbSnpAnnotator.from_registry() # resolves [sources.dbsnp]
annotator = DbSnpAnnotator.from_registry(workers=8) # sets the default workers
annotate() — point-lookup enrichment¶
Matches by Variant.as_key(), the same (chrom, pos, ref, alt) contract
every VariantAnnotator uses. Live run against two real, COMMON-flagged
records on different chromosomes:
from iris.domain.genomics.entities import Variant, VariantAnnotation
from iris.domain.genomics.objects import Allele, GenomicPosition, VariantNomenclature, VariantType
def va(chrom, pos, ref, alt):
key = (chrom, pos, ref, alt)
return VariantAnnotation(variant=Variant(
variant_type=VariantType.SNV,
nomenclature=VariantNomenclature.from_key(key),
alleles=Allele(reference=ref, alternate=(alt,)),
position=GenomicPosition(chromosome=chrom, start=pos, end=pos),
))
items = [va("1", 10_001, "T", "A"), va("2", 10_026, "A", "G")]
result = annotator.annotate(items)
1:10001:T:A → dbsnp_id=rs1570391677 dbsnp_common score=1.0 (version=157)
2:10026:A:G → dbsnp_id=rs1366167113 dbsnp_common score=1.0 (version=157)
Both are real NC_000001.11/NC_000002.12 records, resolved transparently
from the canonical "1"/"2" chromosome names the caller used — the
RefSeq-accession translation described above is invisible to callers.
workers — chromosome-partitioned parallel fetch¶
300 real targets spread across 5 chromosomes, same file, same process:
workers=1: 0.37s, 300/300 rsIDs assigned
workers=8: 0.19s, 300/300 rsIDs assigned (n_workers = min(8, 5 chroms) = 5 effective)
Both produce identical results — workers only changes how many
pysam.VariantFile handles run concurrently (one per chromosome partition,
mirroring TabixReader._query's pattern), not what gets matched. The gap
above is small because these positions were already warm in page cache from
prior exploration; the real-world win is much larger under cold,
network-filesystem-latency-bound access. A production staged-annotation run
(projects/vcfannotation/annotate_staged.py, chained with mcps/clinvar/
dbnsfp) against 29,269 rare variants took ~7–8 minutes with
sequential, single-handle fetching, before DbSnpAnnotator had
chromosome-partitioned workers support at all. After (a) restructuring the
pipeline so dbSNP — which doesn't feed any CANDIDATES_FILTER rule — only
runs on the small post-filter candidate set, and (b) adding this
chromosome-partitioned workers support, the same
sample's full three-stage pipeline (mcps → clinvar/dbnsfp → dbsnp) completed
in ~15 seconds, byte-identical output.
Design notes & caveats¶
- One file for the whole genome, not per-chromosome. Every other tabix
source
irisreads (dbNSFP, CADD's TSV, MCPS, RGC-ME) is split into per-chromosome files opened on demand. dbSNP is a single 27.52 GiB VCF —DbSnpAnnotatoropens it once per sequential call, or once per chromosome-partition worker thread whenworkers>1. - Only the 25 primary
NC_*contigs resolve. Records on the 673NW_/NT_scaffold/patch contigs are unreachable — see Contigs. FREQneeds manual reconstruction if you ever parse it directly with pysam — see the gotcha above. Not extracted intoAlleleFrequencytoday.CLNSIG/CLNDN/etc. are populated but stale and numerically encoded differently from ClinVar's own vocabulary — useClinvarAnnotatorfor clinical significance, not these fields. See above.COMMONis a derived boolean, not a frequency value.DbSnpAnnotatorrepresents it as aVariantScore(name="dbsnp_common",value=1.0, only emitted when the flag is present — never0.0) and merges dbSNP's exact >=1%/1000Genomes/founder-count criteria intoVariantAnnotation.metadataviaMetadata.merge()(non-destructive — won't clobber metadata from an earlier source in the pipeline), so the threshold definition travels with the data instead of living only in code comments.dbsnp_idis always overwritten on a hit, not just filled when missing — dbSNP157 is treated as the authoritative current rs# for that position, even if the input already carried a (possibly stale/merged) rsID from its source VCF's ownIDcolumn.
Reference¶
iris.adapters.driven.annotators.dbsnp
¶
dbSNP rsID and COMMON-flag annotator via VCF random-access.
dbSNP ships as a single bgzipped, tabix-indexed VCF (e.g. GCF_000001405.40.gz
for dbSNP157/GRCh38) whose contigs are NCBI RefSeq accessions
(NC_000001.11, …) rather than UCSC/plain chromosome names.
VcfReader._fetch resolves contigs via resolve_chromosome, which only
toggles the chr prefix and can't bridge RefSeq accessions, so this
annotator does its own contig resolution via resolve_human_chromosome
before fetching.
Only the ID column (rs#) and the COMMON INFO flag are consumed. dbSNP
also embeds a legacy CLN* block (CLNSIG etc.) using dbSNP's own
numeric significance codes from an older ClinVar snapshot — still populated
on a minority of records, but a stale, differently-encoded duplicate of what
ClinvarAnnotator already provides from the authoritative ClinVar source,
so it's deliberately not parsed here.
DbSnpAnnotator
¶
Bases: VariantAnnotator
Annotates variants with dbSNP rsIDs and the COMMON flag.
Expects the standard dbSNP VCF shipped by NCBI (bgzipped, tabix-indexed),
whose #CHROM values are RefSeq accessions such as NC_000001.11.
version is stamped on the emitted COMMON score and should match the
dbSNP build (e.g. "157").
COMMON is a boolean derived by dbSNP itself (>=1% MAF in >=1 1000Genomes
population, >=2 founders — see _COMMON_CRITERIA), not a frequency
value, so it's represented two ways: a VariantScore (name
"dbsnp_common", value=1.0) for filtering via score_for(), and
the criteria text merged into VariantAnnotation.metadata so the
threshold travels with the data instead of living only in code comments.
workers
class-attribute
instance-attribute
¶
Default parallel per-chromosome workers, used when annotate() omits an override.
from_registry
classmethod
¶
Build a DbSnpAnnotator resolving source from the iris DataRegistry.
Source code in src/iris/adapters/driven/annotators/dbsnp.py
annotate
¶
Annotate variants with dbSNP rsIDs and the COMMON flag.
| PARAMETER | DESCRIPTION |
|---|---|
items
|
Variants to annotate.
TYPE:
|
workers
|
Parallel per-chromosome workers for this call; defaults
to
TYPE:
|