To join KEV to EPSS, use the CVE ID as the key, keep KEV on the left so no entry disappears, and report every unmatched row. In the files fetched on 19 October 2026, 1,747 of 1,748 KEV entries had an EPSS row, and the one that did not should be stated, not dropped.
Why the join deserves a methodology note
Most posts that mention both datasets report a statistic about KEV entries' EPSS scores. That statistic is only as honest as the join. An inner join silently drops anything without a match, so the sample shrinks and nobody is told. A left join keeps all KEV rows and leaves a blank where EPSS has nothing.
Steps
- Load KEV and read cveID for each entry.
- Load the EPSS file, skipping the comment line that starts with a hash.
- Normalize keys: trim whitespace and uppercase. Both sources use the form CVE-YYYY-NNNN, but a stray space breaks a match.
- Left join KEV to EPSS.
- Count matched and unmatched rows and look at each unmatched ID by hand.
- Record both snapshot dates, since the two files update on different schedules.
import json, csv
kev = [v["cveID"].strip().upper() for v in json.load(open("kev.json"))["vulnerabilities"]]
epss = {r["cve"].strip().upper(): float(r["epss"])
for r in csv.DictReader(l for l in open("epss.csv") if not l.startswith("#"))}
missing = [c for c in kev if c not in epss]
print(len(kev), len(missing), missing)
What the 19 October 2026 files returned
KEV release 2026.10.16 had 1,748 entries and 1,748 unique CVE IDs. The EPSS file (score date 2026-10-18, 390,807 rows) matched 1,747 of them. The unmatched entry was CVE-2026-21589.
The author did not investigate why that ID was missing, and a post should not guess. Plausible explanations such as timing differences between the two publishers are hypotheses, not findings. The right wording is "one entry had no EPSS row on this date."
Reporting the result
| Item | Value |
|---|---|
| KEV entries | 1,748 |
| Matched to EPSS | 1,747 |
| Unmatched | 1 |
| EPSS score date | 2026-10-18 |
| KEV catalogVersion | 2026.10.16 |
If you then compute a median or a share over the matched rows, the denominator is 1,747, and you should say so. Rounding the denominator to 1,748 in prose makes your table and your text disagree.
Other traps
- Do not assume every EPSS row is a vulnerability you care about. The file lists every scored CVE ID, which is 390,807 rows in this fetch.
- The two snapshots may be a day or more apart. Say it.
- A high EPSS score for a KEV entry does not validate either dataset. See what the spread of EPSS scores among KEV entries shows.
A worked example, row by row
Take the matched population first. KEV had 1,748 entries and the EPSS file had 390,807 rows. The join found 1,747 matches, leaving one unmatched entry, CVE-2026-21589. Your table of results should show all three numbers, because each answers a different question.
| Question | Number |
|---|---|
| How many KEV entries are there | 1,748 |
| How many have an EPSS score | 1,747 |
| What share of KEV entries have a score | 99.9 percent (1,747 of 1,748) |
Now suppose you compute the share of KEV entries with an EPSS of 0.1 or higher. The denominator is 1,747, since the unmatched entry has no score to test. If you divide by 1,748 you implicitly treat the missing score as below 0.1, which is exactly the folding error described in Unknown is not No.
Sanity checks before you trust the join
- Both inputs have the row counts you expect: 1,748 and 390,807.
- The key column has no blanks and no duplicates in KEV.
- The number of matched plus unmatched equals the number of KEV rows.
- A few matched rows look right when you open them by hand.
- The EPSS comment line was skipped and not parsed as a row.
If any of these fail, stop and fix the loader before you compute anything else.
Dates
KEV release 2026.10.16 is dated 16 October. The EPSS scores are from 18 October. The two snapshots are two days apart. That matters little for a count of matches but can matter for an entry added to KEV on 16 October that EPSS had not yet scored in an earlier file. The author did not test that explanation for the unmatched entry, so state only the observation. If you want to understand it, look the ID up in the NVD record and in the next day's EPSS file.
What a good results paragraph looks like
"We joined KEV release 2026.10.16 (1,748 entries) to the EPSS file scored on 2026-10-18 (390,807 rows) on the CVE ID. 1,747 entries matched. One entry, CVE-2026-21589, had no EPSS row and is excluded from score statistics. Shares below use 1,747 as the denominator."
That paragraph is five sentences, and it answers every question a careful reader will ask. For how to present such numbers consistently, see show the denominator.
Try it yourself
Click through it at your own pace, or open it full screen. See how a finding list changes when you sort by exploitation evidence.
Quick check
Why a left join and not an inner join?
A left join keeps every KEV entry, so the sample does not shrink silently. Unmatched rows stay visible.
Can I fill a missing EPSS score with zero?
No. Zero is a value. A missing row is no value. Leave it blank and report the count.
Should I look up the unmatched entry by hand?
Yes, once. Record what you found. Do not generalize from one row.
Sources
- CISA KEV JSON feed, catalogVersion 2026.10.16, https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (fetched 19 October 2026).
- EPSS daily scores file, https://epss.empiricalsecurity.com/epss_scores-current.csv.gz (fetched 19 October 2026; join computed by the author).
Last reviewed: 19 October 2026.