How do I use the OSV API in Python to check packages for vulnerabilities (query, batch, pagination)?
Short answer: OSV.dev has a free JSON API with no key and, per its docs, no rate limits. In Python, POST one package and exact version to https://api.osv.dev/v1/query to get full advisories, or send many at once to /v1/querybatch, which returns only advisory IDs, then fetch details from GET /v1/vulns/{id}. Handle three traps: follow next_page_token, de-duplicate aliases (one CVE often appears as both a GHSA- and a PYSEC- record), and decide which severity field you trust. The standard-library script below does all of it. If you'd rather get finished rows with computed CVSS scores, a severity filter and a recommended upgrade, the Fairquery OSV Dependency Vulnerability Check costs $0.0005 per package and $0.0015 per vulnerability row.
The three OSV endpoints you need
| Endpoint | Returns | Use it for |
|---|---|---|
POST /v1/query | Full advisory objects for one package version (or one commit) | A single lookup |
POST /v1/querybatch | Only id and modified per advisory, results in the same order as your queries | Many packages in one request |
GET /v1/vulns/{id} | One full advisory: summary, aliases, severity, affected ranges and fixed versions | Details after a batch query |
Facts from the OSV API docs worth knowing before you write code:
- Rate limits: "Currently there are no limits on the API." Responses are capped at 32 MiB over HTTP/1.1 (no cap over HTTP/2). Be polite anyway: a few parallel requests are plenty.
- Exact versions only. Send
"version": "2.25.0", not">=2.0". OSV does not resolve ranges, so a range gives you a wrong answer, not an error. Read versions from a lockfile orpip freeze. - Case-sensitive ecosystems:
PyPI,npm,Go,Maven,crates.io,RubyGemsand so on.pypidoesn't match. You can use a package URL instead ({"package": {"purl": "pkg:pypi/jinja2@2.11.2"}}), but don't put the version in both the purl andversion; that returns 400. - Pagination:
/v1/querypages after 1,000 results or 20 seconds;/v1/querybatchpages when one query has more than 1,000 results or the whole batch more than 3,000. Only the affected queries get anext_page_token, and you re-send just those.
Option 1 (free): one package with /v1/query
import json, urllib.request
def osv(path, payload=None):
req = urllib.request.Request(
"https://api.osv.dev/v1/" + path,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
res = osv("query", {"package": {"ecosystem": "PyPI", "name": "jinja2"}, "version": "2.11.2"})
for v in res.get("vulns", []):
print(v["id"], v.get("aliases", []), v.get("summary", "")[:60])
Real output (October 18, 2026, about 0.2 seconds):
GHSA-cpwx-vrp4-4pq7 ['CVE-2025-27516', 'PYSEC-2026-1471'] Jinja2 vulnerable to sandbox breakout through attr filter se
GHSA-g3rq-g295-4j3m ['CVE-2020-28493', 'PYSEC-2021-66', 'SNYK-PYTHON-JINJA2-1012994'] Regular Expression Denial of Service (ReDoS) in Jinja2
GHSA-h5c8-rqwp-cp95 ['CVE-2024-22195', 'PYSEC-2026-1473'] Jinja vulnerable to HTML attribute injection when passing us
GHSA-h75v-3vvj-5mfj ['CVE-2024-34064', 'PYSEC-2026-1474'] Jinja vulnerable to HTML attribute injection when passing us
GHSA-q2x7-8rv6-6q7h ['CVE-2024-56326', 'PYSEC-2026-1475'] Jinja has a sandbox breakout through indirect reference to f
PYSEC-2021-66 ['CVE-2020-28493', 'GHSA-g3rq-g295-4j3m', 'SNYK-PYTHON-JINJA2-1012994']
PYSEC-2026-1471 ['CVE-2025-27516', 'GHSA-cpwx-vrp4-4pq7'] Jinja2 vulnerable to sandbox breakout through attr filter se
PYSEC-2026-1473 ['CVE-2024-22195', 'GHSA-h5c8-rqwp-cp95'] Jinja vulnerable to HTML attribute injection when passing us
PYSEC-2026-1474 ['CVE-2024-34064', 'GHSA-h75v-3vvj-5mfj'] Jinja vulnerable to HTML attribute injection when passing us
PYSEC-2026-1475 ['CVE-2024-56326', 'GHSA-q2x7-8rv6-6q7h'] Jinja has a sandbox breakout through indirect reference to f
Ten records, but only five vulnerabilities: each CVE appears once as a GitHub advisory (GHSA-…) and once as a PyPA advisory (PYSEC-…), and each lists the other in aliases. Count records naively and you double your findings.
Option 1b (free): a whole requirements file with querybatch
This script reads exact pins from requirements.txt, sends them to querybatch in chunks of 1,000, follows next_page_token per query, fetches each unique advisory once with 8 parallel requests, collapses aliases (preferring the GHSA record), prints the CVE, the GHSA severity label and the fixed versions, and exits with code 1 if anything was found, so you can use it as a CI gate:
"""Check pinned packages against OSV.dev: querybatch + pagination + details + alias de-dup.
Standard library only (Python 3.9+). Usage: python3 osv_batch.py requirements.txt"""
import json, sys, urllib.request
from concurrent.futures import ThreadPoolExecutor
API = "https://api.osv.dev/v1/"
def osv(path, payload=None):
req = urllib.request.Request(API + path, headers={"Content-Type": "application/json"},
data=json.dumps(payload).encode() if payload is not None else None)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
def read_pins(path, ecosystem="PyPI"):
pins = []
for line in open(path):
line = line.split("#")[0].strip()
if "==" in line: # only exact pins: OSV needs a concrete version
name, version = line.split("==", 1)
pins.append({"package": {"ecosystem": ecosystem, "name": name.strip()}, "version": version.strip()})
elif line:
print(f"skipped (not pinned with ==): {line}", file=sys.stderr)
return pins
def query_batch(queries):
"""Return one list of vuln IDs per query, following next_page_token where present."""
ids = [[] for _ in queries]
for start in range(0, len(queries), 1000): # keep batches at 1,000 queries
todo = {i: dict(q) for i, q in enumerate(queries[start:start + 1000], start)}
while todo:
order = list(todo)
res = osv("querybatch", {"queries": [todo[i] for i in order]})["results"]
nxt = {}
for i, r in zip(order, res):
ids[i] += [v["id"] for v in r.get("vulns", [])]
if r.get("next_page_token"):
nxt[i] = {**todo[i], "page_token": r["next_page_token"]}
todo = nxt
return ids
def fixed_versions(vuln, name):
out = set()
for aff in vuln.get("affected", []):
if aff.get("package", {}).get("name", "").lower() != name.lower():
continue
for rng in aff.get("ranges", []):
out.update(e["fixed"] for e in rng.get("events", []) if "fixed" in e)
return sorted(out)
def main(path):
pins = read_pins(path)
ids = query_batch(pins)
unique = sorted({i for row in ids for i in row})
with ThreadPoolExecutor(max_workers=8) as pool: # be polite: a few parallel requests
details = dict(zip(unique, pool.map(lambda i: osv("vulns/" + i), unique)))
findings = 0
for pin, row in zip(pins, ids):
name, version = pin["package"]["name"], pin["version"]
seen, groups = set(), []
for vid in sorted(row, key=lambda x: (not x.startswith("GHSA-"), x)): # prefer GHSA records
v = details[vid]
group = {vid, *v.get("aliases", [])}
if group & seen:
continue # same advisory under another ID (e.g. PYSEC-... vs GHSA-...)
seen |= group
groups.append(v)
print(f"{name}=={version}: {len(groups)} advisories ({len(row)} OSV IDs)")
for v in groups:
cve = next((a for a in v.get("aliases", []) if a.startswith("CVE-")), "-")
sev = v.get("database_specific", {}).get("severity", "n/a")
fix = ", ".join(fixed_versions(v, name)) or "none listed"
print(f" {v['id']:<20} {cve:<15} {sev:<9} fixed: {fix}")
findings += len(groups)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
Input file:
requests==2.25.0
jinja2==2.11.2
urllib3==2.2.3
flask>=2.0
Real output (October 18, 2026; 1 batch request plus 34 detail requests, under 1 second end to end in our runs):
skipped (not pinned with ==): flask>=2.0
requests==2.25.0: 4 advisories (8 OSV IDs)
GHSA-9hjg-9r4m-mvj7 CVE-2024-47081 MODERATE fixed: 2.32.4
GHSA-9wx4-h78v-vm56 CVE-2024-35195 MODERATE fixed: 2.32.0
GHSA-gc5v-m9x4-r6x2 CVE-2026-25645 MODERATE fixed: 2.33.0
GHSA-j8r2-6x86-q33q CVE-2023-32681 MODERATE fixed: 2.31.0
jinja2==2.11.2: 5 advisories (10 OSV IDs)
GHSA-cpwx-vrp4-4pq7 CVE-2025-27516 MODERATE fixed: 3.1.6
GHSA-g3rq-g295-4j3m CVE-2020-28493 MODERATE fixed: 2.11.3
GHSA-h5c8-rqwp-cp95 CVE-2024-22195 MODERATE fixed: 3.1.3
GHSA-h75v-3vvj-5mfj CVE-2024-34064 MODERATE fixed: 3.1.4
GHSA-q2x7-8rv6-6q7h CVE-2024-56326 MODERATE fixed: 3.1.5
urllib3==2.2.3: 8 advisories (16 OSV IDs)
GHSA-2xpw-w6gg-jr37 CVE-2025-66471 HIGH fixed: 2.6.0
GHSA-38jv-5279-wg99 CVE-2026-21441 HIGH fixed: 2.6.3
GHSA-48p4-8xcf-vxj5 CVE-2025-50182 MODERATE fixed: 2.5.0
GHSA-8988-9cw3-xx77 CVE-2026-97687 HIGH fixed: 2.8.0
GHSA-gm62-xv2j-4w53 CVE-2025-66418 HIGH fixed: 2.6.0
GHSA-pq67-6m6q-mj2v CVE-2025-50181 MODERATE fixed: 2.5.0
GHSA-qccp-gfcp-xxvc CVE-2026-44431 HIGH fixed: 2.7.0
GHSA-vxq7-64xx-v4gw CVE-2026-97689 HIGH fixed: 2.8.0
exit=1
Three things to read from it. The unpinned flask>=2.0 line is skipped with a warning rather than guessed. Raw OSV IDs (34) collapse to 17 distinct advisories. And "fixed" is a list per advisory: for packages with several release lines (Django, for example, lists fixes such as 4.2.28, 5.2.11, 6.0.2) pick the fix on your own line, or the highest one if you can upgrade freely.
Trap: which severity do you trust?
OSV records can carry several severity signals: a severity array with CVSS vectors (CVSS_V3, CVSS_V4) and, for GitHub advisories, a database_specific.severity label. They don't always agree. In the run above, GitHub labels GHSA-q2x7-8rv6-6q7h (Jinja sandbox breakout) as MODERATE, but its CVSS 3.1 vector AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H computes to a base score of 7.8, which is HIGH. For GHSA-qccp-gfcp-xxvc (urllib3) it's the other way round: the label is HIGH while its CVSS 3.1 vector scores 5.3 (MEDIUM). OSV gives you vectors, not scores, so computing a number needs a CVSS calculator (for example the cvss package on PyPI).
Pick one rule, document it, and apply it consistently. A CI gate on "HIGH or above" will pass or fail different advisories depending on the field you read.
Option 2 (API, MCP or CI): finished rows
The Fairquery OSV Dependency Vulnerability Check (fairquery/osv-dependency-vulnerability-check) wraps the same endpoints. It parses requirements.txt, package.json, package-lock.json (v1 to v3) and go.mod, computes the CVSS v3 base score when a v3 vector exists and otherwise falls back to the database label, filters by minSeverity, de-duplicates aliases and recommends an upgrade version. Input for the same three pinned packages:
{"manifestText": "requests==2.25.0\njinja2==2.11.2\nurllib3==2.2.3\n", "minSeverity": "HIGH"}
We ran the Actor's published source locally on October 18, 2026. It made 35 OSV requests and returned 3 package rows and 6 vulnerability rows (17 alias duplicates removed, 11 advisories below HIGH filtered out). One vulnerability row, abridged:
{
"type": "vulnerability",
"id": "GHSA-q2x7-8rv6-6q7h",
"ecosystem": "PyPI",
"package_name": "jinja2",
"package_version": "2.11.2",
"aliases": [
"CVE-2024-56326",
"PYSEC-2026-1475"
],
"cve_ids": [
"CVE-2024-56326"
],
"summary": "Jinja has a sandbox breakout through indirect reference to format method",
"severity": "HIGH",
"severity_source": "cvss",
"cvss_score": 7.8,
"cvss_vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"cvss_version": "3.1",
"fixed_versions": [
"3.1.5"
],
"osv_url": "https://osv.dev/vulnerability/GHSA-q2x7-8rv6-6q7h"
}
Note severity_source: "cvss": this row is HIGH because of the computed 7.8, even though GitHub's label says MODERATE, which is exactly the disagreement described above. The same run would cost 3 × $0.0005 + 6 × $0.0015 = $0.0105, plus Apify's $0.00005 Actor-start fee. For a GitHub Actions job that fails the build on HIGH or CRITICAL, and for the free osv_query_packages MCP tool, see how to check npm and PyPI dependencies for vulnerabilities in CI with OSV.
Which option should you use?
| Option | Good for | Trade-offs |
|---|---|---|
| Your own Python against the OSV API (above) | Free, no dependencies, full control over severity rules | You maintain manifest parsing, alias handling, CVSS scoring and pagination |
| Google's osv-scanner CLI | Free, official, scans lockfiles, SBOMs and container images locally | A binary to install in CI; not a hosted API or MCP tool |
| Fairquery OSV Actor | One API or MCP call from agents, n8n or CI; manifest parsing, computed CVSS, severity filter, recommended version as JSON rows, pay per row | Paid per package and vulnerability; needs an Apify account; same OSV data, no reachability analysis |
FAQ
Is the OSV API free and does it have rate limits?
It is free and needs no API key. The OSV docs state that there are currently no limits on the API; responses are capped at 32 MiB over HTTP/1.1 and uncapped over HTTP/2. Keep parallel requests modest anyway.
What is the difference between /v1/query and /v1/querybatch?
/v1/query returns full advisory objects for one package version or commit. /v1/querybatch takes many queries and returns only the advisory id and modified time for each, in input order; fetch details with GET /v1/vulns/{id}.
How does OSV pagination work?
A response that has more results includes next_page_token. Re-send the same query with page_token set to it until no token comes back. In querybatch only some queries may be paginated, so re-send just those.
Why does OSV return the same vulnerability twice?
Several databases publish the same issue: a GitHub advisory (GHSA-...) and a PyPA advisory (PYSEC-...) for one CVE, each listing the other in aliases. Group records by their aliases before counting.
Can I query OSV with a version range like >=2.0?
No. OSV matches exact versions. Send concrete versions from a lockfile or pip freeze; a range will not give a correct answer.
How much does the Fairquery OSV Actor cost?
$0.0005 per package checked plus $0.0015 per vulnerability row emitted after the severity filter and alias de-duplication, plus Apify's $0.00005 Actor-start fee. Error and warning rows are free. The Python scripts in this guide are free.
No result is not proof of safety. An empty response means OSV has no matching advisory for that exact version. No database is complete, and none of these options checks whether the vulnerable code is reachable in your application.
Source and docs: github.com/fairquery/osv-dependency-vulnerability-check (MIT) · Apify Store · OSV API docs · all Fairquery tools