Safeguard
Industry Analysis•

How do you join KEV to EPSS without losing rows?

Join KEV to EPSS on the CVE ID, keep a left join, and report unmatched rows. In the 19 October 2026 files, 1,747 of 1,748 KEV entries matched.

Safeguard Security Team
6 min read

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

  1. Load KEV and read cveID for each entry.
  2. Load the EPSS file, skipping the comment line that starts with a hash.
  3. Normalize keys: trim whitespace and uppercase. Both sources use the form CVE-YYYY-NNNN, but a stray space breaks a match.
  4. Left join KEV to EPSS.
  5. Count matched and unmatched rows and look at each unmatched ID by hand.
  6. 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

ItemValue
KEV entries1,748
Matched to EPSS1,747
Unmatched1
EPSS score date2026-10-18
KEV catalogVersion2026.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.

QuestionNumber
How many KEV entries are there1,748
How many have an EPSS score1,747
What share of KEV entries have a score99.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

  1. Both inputs have the row counts you expect: 1,748 and 390,807.
  2. The key column has no blanks and no duplicates in KEV.
  3. The number of matched plus unmatched equals the number of KEV rows.
  4. A few matched rows look right when you open them by hand.
  5. 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

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.