An SBOM diff shows changes with no code change because some fields are meant to differ on every run, such as serial numbers and timestamps, and because the generator, its settings or the build environment may have shifted. Normalise volatile fields, pin the generator, then re-diff.
Fields that are supposed to change
The CycloneDX specification says every generated BOM should have a unique serial number, even if the contents have not changed. It also records a creation timestamp in the metadata. SPDX has the same idea: a required Created field, and a document namespace that must be a new unique URI when the document is updated.
So a raw text diff of two files will always show lines that differ. A component level diff, such as the one in the CycloneDX command line tool, compares components rather than lines, and avoids most of this. If you diff with a generic text tool, strip these fields first.
Causes that are real changes
| Symptom | Likely cause | How to confirm |
|---|---|---|
| Many components added or removed at once | Base image or build environment changed | Compare the image digest used by both builds |
| Many versions bumped | An unpinned dependency resolved to newer versions | Check whether a lockfile exists and is committed |
| Components appear only in one file | Generator version or settings differ | Record the tool version in both runs |
| Ordering differs, same content | The tool does not sort output | Sort components by purl before comparing |
| A whole stage is missing | Multi-stage scan scope changed | See the Docker note below |
A short triage routine
- Compare the serial number and timestamp, and ignore them.
- Compare the generator name and version recorded in the metadata.
- Compare the lockfile or image digest behind each SBOM.
- Sort both component lists by package URL and diff only those.
- If differences remain, treat them as real and read them.
The package URL format gives each component a predictable identifier made of a type, optional namespace, name and version. Using it as the sort and match key removes most cosmetic noise.
Docker scope is a common surprise
Docker's documentation states that BuildKit scans only the final stage of a multi-stage build by default, and that earlier stages are included only when you set the scan stage argument. If someone turns that on between releases, the diff will show many added build-time components that were always there. That is a change in measurement, not in software.
Make the diff boring on purpose
Pin the generator version in CI. Generate both files with the same command. Store the generator version beside each file. Then a diff that is not empty means something about the software moved, which is the only thing you want it to tell you.
Safeguard generates SBOMs and scans dependencies, and its policies and gates can act on component changes. You can try a scan on your own project at the free scan page.
Try it yourself
Click through it at your own pace, or open it full screen.
Look at how each component is identified by name and version. That identity is what a diff should match on.
Quick check
Should a changed timestamp count as drift?
No. The timestamp records when the file was created, so it differs on every run by design.
Is a reordered component list a change?
No. Sort by package URL before comparing, and the order stops mattering.
Sources
- CycloneDX 1.6 JSON reference
- SPDX 2.3.1 document creation information
- CycloneDX CLI repository
- Package URL specification
- Docker SBOM attestations
Last reviewed: 19 October 2026.