# cvelistV5 delta.json and deltaLog.json: Sync CVE Changes

> The CVE Program's cvelistV5 repository publishes two small index files, cves/delta.json and cves/deltaLog.json, that tell you which CVE records changed and when. We read 30 days of deltaLog (2,870 entries, 122,761 changes) to show what each field means, what 'new' and 'updated' really cover, how often the feed moves, and a tested Python script that keeps a local mirror current from a checkpoint.

- URL: https://firewallsync.com/posts/cvelistv5-delta-json-deltalog-incremental-cve-sync/
- Site: FirewallSync (https://firewallsync.com/)
- Topic: Security Operations
- Tags: cve, cvelistv5, cve json 5, vulnerability management, data feeds, automation
- Published: 2026-11-08
- Author: John Marshall

## Key takeaways

- cves/delta.json is the most recent change batch only; cves/deltaLog.json is a newest-first array of those batches covering a rolling 30 days. Each batch has fetchTime, numberOfChanges, new, updated and error.
- Each change item carries cveId, cveOrgLink, githubLink (the raw JSON URL of the record) and dateUpdated. It does not say what changed: fetch the record.
- 'new' means a record file that did not exist in the repository before, not 'a newly disclosed vulnerability': 28 of 300 sampled IDs from the new queue are REJECTED records today.
- Most 'updated' entries are enrichment, not CNA edits: in 244 of 299 sampled updated records, the record's dateUpdated matches the CISA-ADP container's timestamp.
- Sync from a stored fetchTime checkpoint, keep the newest dateUpdated per cveId, and fall back to the midnight baseline zip when the checkpoint is older than the log's oldest entry.



If you keep your own copy of the CVE List, you do not need to re-download all 421,074 record files (the count on `main` on November 8, 2026) to stay current. The CVE Program's [cvelistV5](https://github.com/CVEProject/cvelistV5) repository publishes two index files, `cves/delta.json` and `cves/deltaLog.json`, that list which records were added or modified and when. Store the last `fetchTime` you processed, read every batch after it, fetch the newest version of each changed record, and fall back to a full baseline when your checkpoint ages out of the log's 30-day window.

This article explains the fields, what the two change queues really mean, and how the feed behaves in practice, based on the copy of `deltaLog.json` we downloaded on November 8, 2026 (2,870 batches, October 9 to November 8). It ends with a sync script we ran against that feed. Where a record's fields fit into a patch decision, see our [vulnerability prioritization workflow from CVE record to ticket](/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/).

## The two files and how they relate

| File | What it holds | Size on Nov 8, 2026 |
|---|---|---|
| `cves/delta.json` | The single most recent change batch | 684 bytes (2 changes) |
| `cves/deltaLog.json` | An array of change batches, newest first, for a rolling 30 days | 36.8 MB (2,870 batches) |

Both files sit on the `main` branch and are readable over `raw.githubusercontent.com` without authentication. On the morning we checked, `delta.json` was byte-for-byte the same object as the first element of `deltaLog.json`.

The 30-day window comes from two places. The repository README says the log "normally retains a rolling 30 days worth of CVE record modification history", and notes that it was temporarily cut to 15 days during a date-normalization exercise in February 2026. The repository's `update.yml` workflow sets `CVES_DEFAULT_DELTA_LOG_HISTORY_IN_DAYS: 30`. The window is a setting, so code that assumes exactly 30 days will eventually be wrong. Check the timestamp of the oldest entry instead.

## Fields in a delta batch

Each batch (the whole of `delta.json`, or one element of `deltaLog.json`) looks like this. The example is the real `delta.json` from 03:15 UTC on November 8, 2026, trimmed to one item:

```json
{
  "fetchTime": "2026-11-08T03:15:41.690Z",
  "numberOfChanges": 2,
  "new": [],
  "updated": [
    {
      "cveId": "CVE-2024-28834",
      "cveOrgLink": "https://www.cve.org/CVERecord?id=CVE-2024-28834",
      "githubLink": "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/2024/28xxx/CVE-2024-28834.json",
      "dateUpdated": "2026-11-08T03:06:52.175Z"
    }
  ],
  "error": []
}
```

| Field | Meaning | What to do with it |
|---|---|---|
| `fetchTime` | When the update job pulled changes from CVE Services (UTC) | Your checkpoint. Store the newest one you processed. |
| `numberOfChanges` | Count of items in `new` + `updated` + `error` | A sanity check; it matched the list lengths in all 2,870 batches we read. |
| `new` | Records whose JSON file was added to the repository in this batch | Fetch and insert. Do not assume they are newly disclosed vulnerabilities (see below). |
| `updated` | Records whose existing file was modified | Fetch and replace your copy. |
| `error` | Items the tooling could not classify | Log and alert. Empty in every batch we read. |
| `cveId` | The CVE ID | Your deduplication key. |
| `githubLink` | Raw URL of the record JSON on `main` | Fetch this to get the current record. |
| `cveOrgLink` | The record's page on cve.org | For humans and tickets. |
| `dateUpdated` | The record's `cveMetadata.dateUpdated` when the batch was built | Use it to keep only the newest change per CVE. |

Every one of the 122,761 change items we read had exactly those four keys. None of them tells you *what* changed in the record. If you need a diff, keep the previous version and compare.

## What "new" and "updated" actually mean

The labels come from git, not from the CVE lifecycle. The repository's bundled tooling (`.github/workflows/dist/index.js`) builds each delta from git status: files that were not in the repository go to the new queue, modified files go to updated. Two consequences matter for triage.

**A "new" record can be REJECTED.** We fetched 300 randomly sampled IDs from the new queue in the most recent 600 batches. 272 are PUBLISHED records today and 28 are REJECTED. A reserved ID that is rejected before publication still lands as a new file. If you feed "new" straight into an alerting channel, filter on `cveMetadata.state` first. Our [guide to CVE record states](/posts/cve-record-states-published-rejected-and-disputed-what-each-means-for-vulnerability-triage/) covers what each state means for a ticket.

**Most updates are enrichment.** We fetched 300 sampled IDs from the updated queue (299 parsed) and compared each record's `cveMetadata.dateUpdated` with the timestamps on its containers. In 244 records the closest match (within two minutes) was the CISA-ADP container, in 51 it was the CNA container and in 4 it was the CVE Program container. In other words, the typical update is CISA adding or revising SSVC, KEV or CVSS data rather than the vendor changing the description or affected versions. That is useful: an update can be the moment a CVE gains an SSVC `Exploitation: active` value or a KEV entry. See [how ADP containers work](/posts/cve-adp-containers-cisa-adp-cve-program-supplier-sadp/) and [how to read the SSVC values CISA adds](/posts/cisa-vulnrichment-how-to-read-ssvc-values-in-a-cve-record-and-turn-them-into-a-patch-decision/).

Both samples reflect the records as they stood when we fetched them on November 8, 2026, which can be later than the batch that listed them. Treat the proportions as indicative of one month, not a constant.

## How the feed behaves

We counted the whole log with Python (the counts below are recomputed from the file, not estimated).

| Measure (Oct 9 to Nov 8, 2026) | Value |
|---|---|
| Batches | 2,870 |
| Items in `new` | 17,699 |
| Items in `updated` | 105,062 |
| Items in `error` | 0 |
| Distinct CVE IDs touched | 31,848 |
| IDs that appear in more than one batch | 21,774 |
| Most appearances for one ID | 118 (two IDs, each updated roughly every 6 hours) |
| Median gap between batches | 10.2 minutes |
| 90th percentile gap | 28.9 minutes |
| Longest gap | 228.8 minutes |
| Largest single batch | 1,417 changes (Oct 13, 21:42 UTC) |

Three points follow for anyone building on the feed:

1. **The same CVE appears many times, and each appearance is a real change.** No `(cveId, dateUpdated)` pair repeated across the 30 days. If you process batches one by one you will fetch the same record repeatedly; collapse to the newest `dateUpdated` per ID first.
2. **Schedule is not cadence.** `update.yml` is set to run every five minutes, but the gaps above show batches arriving anywhere from 2 minutes to nearly 4 hours apart. Alert on "no new batch for several hours", not "no batch in 5 minutes".
3. **`dateUpdated` trails `fetchTime` slightly.** Across all items the median lag from a record's `dateUpdated` to the batch's `fetchTime` was 4.8 minutes and the maximum was about 102 minutes. That is fine for vulnerability management, but it is not a real-time feed.

## The other ways to sync

The README offers two download routes, and the delta files sit alongside them.

- **git.** Clone once, then `git pull`. Git works out what changed for you. A shallow, blob-less clone keeps the first download small, but a full working tree still means checking out more than 400,000 JSON files.
- **Release zips.** The README says a baseline named `YYYY-MM-DD_all_CVEs_at_midnight.zip` is "issued at the end of each day at midnight" and that hourly files named like `2024-04-04_delta_CVEs_at_0100Z.zip` "only [contain] the deltas since the baseline zip file". We downloaded `2026-11-08_delta_CVEs_at_0300Z.zip` from the release tagged `cve_2026-11-08_0300Z`: it held 194 records under `deltaCves/`, and all 194 IDs appear in `deltaLog.json` batches between midnight and 03:30 UTC. Note the hourly zip is cumulative since midnight, not just the last hour. In the release assets we fetched, the baseline file actually carried a doubled extension (`2026-11-08_all_CVEs_at_midnight.zip.zip`).
- **delta files over HTTPS.** Best when you only want the records that changed and do not want a git clone at all, for example in a serverless function or a scanner plugin.

## A tested sync script

The script below keeps a local folder of records current from a checkpoint. It uses only the Python standard library.

```python
#!/usr/bin/env python3
"""Incremental CVE record sync from cvelistV5's cves/deltaLog.json (Python 3.9+, stdlib only)."""
import json, pathlib, sys, urllib.request
from datetime import datetime

RAW = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/deltaLog.json"
STATE = pathlib.Path("state.json")      # remembers the last fetchTime we processed
MIRROR = pathlib.Path("records")        # one JSON file per CVE ID

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

def get_json(url):
    with urllib.request.urlopen(url, timeout=60) as r:
        return json.load(r)

def main():
    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    log = get_json(RAW)                              # newest entry first
    oldest = ts(log[-1]["fetchTime"])
    since = ts(state["lastFetchTime"]) if "lastFetchTime" in state else None
    if since is None or since < oldest:
        sys.exit("No checkpoint, or checkpoint older than the log window: "
                 "load a midnight baseline zip first, then set lastFetchTime.")

    # Collect every change after the checkpoint; keep the newest dateUpdated per CVE.
    todo = {}
    for entry in log:
        if ts(entry["fetchTime"]) <= since:
            break
        for queue in ("new", "updated"):
            for item in entry[queue]:
                cur = todo.get(item["cveId"])
                if cur is None or ts(item["dateUpdated"]) > ts(cur["dateUpdated"]):
                    todo[item["cveId"]] = item
        if entry["error"]:
            print("delta reported errors:", entry["error"], file=sys.stderr)

    MIRROR.mkdir(exist_ok=True)
    counts = {}
    for cve_id, item in todo.items():
        record = get_json(item["githubLink"])
        state_name = record["cveMetadata"]["state"]          # PUBLISHED or REJECTED
        counts[state_name] = counts.get(state_name, 0) + 1
        (MIRROR / f"{cve_id}.json").write_text(json.dumps(record))

    STATE.write_text(json.dumps({"lastFetchTime": log[0]["fetchTime"]}))
    print(f"{len(todo)} records refreshed since {since.isoformat()}: {counts}")

if __name__ == "__main__":
    main()
```

**How we tested it.** On November 8, 2026 we ran it with Python 3 on Linux, with a checkpoint set to the sixth-newest batch (`2026-11-08T01:47:15.999Z`). It downloaded the log, refreshed 29 records (all PUBLISHED) in about five seconds, and moved the checkpoint to `2026-11-08T03:15:41.690Z`. With the state file deleted it exited with the re-baseline message, as intended.

**Limits of the script.** It is a starting point, not a production sync:

- It downloads the whole 36.8 MB log on every run. For runs every few minutes, poll `delta.json` and only pull the log when you have missed batches.
- It fetches records one at a time and does not retry. Add parallelism, retries and backoff before you point it at a large backlog.
- `githubLink` points at `main`, so you get the record as it is when you fetch it, which may be newer than the batch. That is usually what you want, but it means `dateUpdated` in your copy can be later than the one in the delta.
- It does not handle the rare case of a record file being removed from the repository; the delta format has no "deleted" queue.

## Where this fits in a vulnerability workflow

A local, current mirror is what makes the rest of the prioritization stack cheap: join it to the [CISA KEV JSON feed](/posts/cisa-kev-json-feed-what-each-field-means-and-how-to-match-it-against-a-scanner-export/) and the [EPSS daily file](/posts/epss-data-feed-columns-percentile-vs-probability-and-how-to-join-it-to-a-scanner-export/), and re-run the decision only for the CVE IDs that the delta says changed. Because most updates are CISA-ADP enrichment, a changed record is worth re-scoring even when the vendor text did not move.

## Sources and verification

| Important claim | Source | Status |
|---|---|---|
| deltaLog retains a rolling 30 days; baseline and hourly zip naming; hourly zip holds deltas since the baseline | [cvelistV5 README](https://github.com/CVEProject/cvelistV5/blob/main/README.md) | Verified |
| Update job scheduled every 5 minutes; 30-day history setting | [cvelistV5 update.yml](https://github.com/CVEProject/cvelistV5/blob/main/.github/workflows/update.yml) | Verified |
| new = added file, updated = modified file | [cvelistV5 workflow tooling, dist/index.js](https://github.com/CVEProject/cvelistV5/tree/main/.github/workflows/dist) | Verified (bundled source read on Nov 8, 2026) |
| Field names, counts, gaps, largest batch | [cves/deltaLog.json](https://github.com/CVEProject/cvelistV5/blob/main/cves/deltaLog.json), downloaded Nov 8, 2026 | Verified (computed) |
| 28 of 300 sampled new IDs REJECTED; 244 of 299 sampled updates match CISA-ADP | Sampled record files from cvelistV5 `main`, fetched Nov 8, 2026 | Qualified (random sample, current record state) |
| Hourly zip contents (194 records, all in deltaLog) | [cvelistV5 releases](https://github.com/CVEProject/cvelistV5/releases), tag cve_2026-11-08_0300Z | Verified |

## Frequently asked questions

### What is the difference between delta.json and deltaLog.json in cvelistV5?

delta.json holds only the latest change batch written by the repository's update job. deltaLog.json is an array of those batches, newest first, retained for a rolling 30 days according to the repository README and the update workflow's CVES_DEFAULT_DELTA_LOG_HISTORY_IN_DAYS setting. On November 8, 2026, delta.json was identical to the first element of deltaLog.json.

### Does 'new' in cvelistV5 deltas mean a newly published CVE?

No. The repository tooling puts a CVE in the new queue when its JSON file is added to the repository, and in updated when an existing file is modified. A reserved ID that is rejected without ever being published still arrives as a new file. Check cveMetadata.state in the record itself.

### How often does cvelistV5 update?

The update workflow is scheduled every five minutes, but scheduled runs do not land that regularly. Across the 30 days of deltaLog we read, the median gap between batches was about 10 minutes, the 90th percentile about 29 minutes and the longest gap about 3 hours 49 minutes.

### What if my sync has been down for more than 30 days?

The log can no longer tell you everything that changed. Download the most recent midnight baseline zip from the repository's releases (or git pull a clone), replace your mirror, and restart the checkpoint from the newest fetchTime in deltaLog.json.


Image credit: Photo by [Jemima Whyles](https://unsplash.com/photos/a-bunch-of-cards-on-a-shelf-in-a-room-TfLwDQobhCA) on Unsplash
