Trivy vs Grype: choosing a container image scanner

Both scanners read the same alpine:3.18.0 tar on the same morning. They agreed on 49 findings, split on 14 more and on seven severities, and exit differently; what that means for the gate.

Jul 11, 2026·Updated ·14 min readIntermediate·By SecOpsLog · command-tested

Most Trivy-versus-Grype pages list features. The question a pipeline owner has is narrower: for the image we build, will the two tools report the same thing, and when they differ, whose number goes in the ticket? The only honest way to answer it is to hand both scanners the same bytes on the same morning and read the JSON, which is what this article does: alpine:3.18.0 pulled by digest, saved once with docker save, scanned by Trivy 0.74.0 through --input and by Grype 0.118.0 through docker-archive:, with both vulnerability databases refreshed in the same run and then frozen. The image is deliberately old (May 2023, end of life since May 2025), so it carries fixed CVEs and shows what each scanner does when the distribution feed has stopped.

One tar, two scanners

Both tools read the same file and report the same image ID (the image config digest). Databases are downloaded first, then every scan runs with updates disabled, so neither scanner sees newer data than the other.

1pull by digestdocker pull alpine@sha256:02bb6…2refresh bothdatabasestrivy image --download-db-only;…3scantrivy image --input image.tar…4compare the JSON(package, CVE) pairs,…5gate, except,round-trip--exit-code / --fail-on, the…
bash — observed: versions, databases and the two summary lines (alpine:3.18.0 by digest, linux/arm64)observed
trivy --version | tr "\n" " "
Version: 0.74.0 Vulnerability DB: Version: 2 UpdatedAt: 2026-09-13 07:13:14.295197098 +0000 UTC NextUpdate: 2026-09-14 07:13:14.295196898 +0000 UTC DownloadedAt: 2026-09-13 08:37:45.253362457 +0000 UTC
grype version | grep -E "^(Version|Syft Version|Supported DB Schema)"; grype db status | grep -E "^(Schema|Built|Status)"
Version: 0.118.0; Syft Version: v1.51.1; Supported DB Schema: 6;
Schema: v6.1.9; Built: 2026-09-13T06:31:42Z; Status: valid;
trivy image --input image.tar --scanners vuln --format json --output trivy.json # stderr
INFO Detected OS family="alpine" version="3.18.0"
INFO [alpine] Detecting vulnerabilities... os_version="3.18" repository="3.18" pkg_num=15
WARN Using severities from other vendors for some vulnerabilities. Read https://trivy.dev/docs/v0.74/guide/scanner/vulnerability#severity-selection for details.
WARN This OS version is no longer supported by the distribution family="alpine" version="3.18.0"
trivy image --input image.tar --scanners vuln -q | grep ^Total
Total: 49 (UNKNOWN: 0, LOW: 14, MEDIUM: 26, HIGH: 6, CRITICAL: 3)
grype docker-archive:image.tar -o json --file grype.json # stderr
15 packages from EOL distro "alpine 3.18.0" - vulnerability data may be incomplete or outdated; consider upgrading to a supported version
jq -r '"matches: \(.matches|length) …"' grype.json
matches: 63 (Critical: 5, High: 10, Low: 6, Medium: 42); unique ids: 28; packages from an EOL distro: 15
both scans exit 0: without --exit-code (Trivy) or --fail-on (Grype), findings never change the exit code

What each scanner found in the same tar

Forty-nine rows are common to both: the same (package, CVE) pairs, with the same fixed versions, across busybox, busybox-binsh, ssl_client, libcrypto3, libssl3, musl and musl-utils. Every one of Trivy's rows names alpine as its data source, and every one of Grype's forty-nine has the namespace alpine:distro:alpine:3.18. Both scanners built that list from the Alpine security database, and for this image they built the same list. Grype reports fourteen more rows that Trivy does not: NVD records, matched by CPE, for busybox 1.36.0, musl 1.2.4 and zlib 1.2.13, none with a fixed version. Grype's apk matcher, in its own words, treats the distro feed as the authority and lets NVD count only where the feed is silent, and it strips the fix state from those NVD rows because an upstream release number is not an apk version. The Alpine feed for 3.18 has been silent since the branch reached end of life in May 2025, and none of these six CVEs (two published in April 2025, four in 2026) has an entry in it, so they reach Grype's table through NVD and never reach Trivy's, whose documented source for Alpine packages is the security database alone.

Recorded counts for the same image (databases of 2026-09-13; they move with every update)

Trivy 0.74.0Grype 0.118.0
rows reported4963 (49 with a fix, 14 from NVD without one)
unique CVE ids2228
Critical / High / Medium / Low3 / 6 / 26 / 145 / 10 / 42 / 6
data source of the rowsalpine for all 49alpine:distro:alpine:3.18 for 49, nvd:cpe for 14
shared (package, CVE) pairs4949
end-of-life signalWARN This OS version is no longer supported; Metadata.OS.EOSL: true in the JSON; --exit-on-eol available15 packages from EOL distro on stderr; alertsByPackage in the JSON
bash — observed: the overlap, computed from the two JSON filesobserved
jq -r '.Results[].Vulnerabilities[] | "\(.PkgName)\t\(.VulnerabilityID)"' trivy.json | sort -u > t.pairs
jq -r '.matches[] | select(.vulnerability.fix.state=="fixed") | "\(.artifact.name)\t\(.vulnerability.id)"' grype.json | sort -u > g.fixed.pairs
trivy rows: 49 grype rows with a fix: 49 grype rows without a fix: 14
shared (package, CVE) pairs: 49 only Trivy: 0 only Grype (with fix): 0
grype rows without a fix version, by source namespace: nvd:cpe 14
busybox CVE-2024-58251
busybox CVE-2025-46394
busybox CVE-2025-60876
musl CVE-2026-40200
musl CVE-2026-6042
zlib CVE-2026-27171 unknown
(busybox-binsh and ssl_client repeat the busybox rows, musl-utils the musl rows: 14 in all)
trivy data source of every row: alpine
grype docker-archive:image.tar --only-fixed -o json | jq '{matches: (.matches|length), ignored: (.ignoredMatches|length)}'
matches: 49 (Critical: 5, High: 8, Medium: 36) ignoredMatches: 14 (nvd:cpe: 14)

Whether those fourteen rows are findings or noise depends on what you want the scanner to say about an image whose distribution has stopped tracking it. They are not wrong: busybox 1.36.0 is inside every range NVD lists. They are also not actionable inside Alpine 3.18, because no package upgrade fixes them, which is why --only-fixed moves all fourteen into ignoredMatches and leaves Grype with the same forty-nine rows Trivy prints. Trivy answers the question with a warning and, if you ask for it, an exit code. Grype answers it with rows. On a supported base image the two behaviours converge, because the distro feed is rarely silent for long; on an image past its end of life they do not, and a comparison that only ever scans alpine:latest never sees it.

Seven CVEs, two severities

Of the twenty-two CVEs both scanners agree exist, fifteen carry the same severity and seven do not. The seven are all OpenSSL, all in libcrypto3 and libssl3, and all rated LOW by Trivy while Grype says Medium, High or, for CVE-2024-5535, Critical. The JSON explains the split without guessing. Trivy's VendorSeverity map for each of the seven has no nvd entry and SeveritySource is empty: NVD has never produced its own analysis for them (their NVD status is Deferred), and Trivy's documented rule, when NVD is silent, is to take the next vendor in the trivy-db source order, which is Red Hat. Red Hat rates all seven Low, and Low is what Trivy printed. Grype's record for each carries exactly one CVSS 3.1 vector, marked Secondary, from source 134c704f-9b21-4f2e-91b3-4a467353bcc0, which NVD's source registry names CISA-ADP; the severity Grype prints is the band of that score (9.1 is Critical, 7.5 High, 4.1 to 5.9 Medium). Two defensible rules, two answers, from the same NVD page.

The seven disagreements (recorded 2026-09-13; all in libcrypto3 and libssl3 3.1.0-r4)

CVEfixed inTrivyGrypewhat the record says
CVE-2024-55353.1.6-r0LOWCriticalNVD Deferred; CISA-ADP CVSS 3.1 = 9.1; Red Hat Low
CVE-2024-47413.1.6-r0LOWHighNVD Deferred; CISA-ADP 7.5; Red Hat Low
CVE-2024-25113.1.4-r6LOWMediumNVD Deferred; CISA-ADP 5.9; Red Hat Low; EPSS 54 % (Grype risk 29.4, third row of its table)
CVE-2023-62373.1.4-r4LOWMediumNVD Deferred; CISA-ADP 5.9; Red Hat Low
CVE-2024-46033.1.5-r0LOWMediumNVD Deferred; CISA-ADP 5.3; Red Hat Low
CVE-2024-91433.1.7-r1LOWMediumNVD Deferred; CISA-ADP 4.3; Red Hat Low
CVE-2024-131763.1.8-r0LOWMediumNVD Deferred; CISA-ADP 4.1; Red Hat Low
Severity is a derived field, and the two derivations differ
Neither scanner rated these CVEs itself; each chose which published rating to trust when NVD had none. Trivy prefers a vendor that ships the package (Red Hat here) and says so in the log line about "severities from other vendors". Grype keeps the CVSS vector on the record and adds EPSS: its default table is sorted by an EPSS-weighted risk score, which put CVE-2024-2511 (Medium, EPSS 54 %) third on this image while Trivy filed it under LOW. The earlier version of this article showed that same fix version, 3.1.4-r6, as CRITICAL in Trivy and High in Grype; the block was never from a run, and against the 2026-09-13 databases neither scanner says either of those.

Exit codes: what the pipeline actually sees

The gating flags are not symmetric, and the asymmetry decides how a CI job tells an outage from a finding. Trivy exits 0 on findings unless --exit-code N is set, and the caller chooses N; its own errors exit 1. Run the popular --exit-code 1 and a missing database, a bad archive and a CRITICAL all look identical to the job. Grype's threshold exit is fixed at 2 and its errors are 1, so a wrapper can react differently to each without parsing output. Trivy has a second gate Grype does not: --exit-on-eol N returns N for an end-of-life OS, and in this run it took precedence over --exit-code when both applied. Grype has a guard that Trivy's database options do not describe: it refuses to scan with a database older than five days (db.max-allowed-built-age), exit 1, unless db.validate-age is switched off.

Exit codes observed on the same tar

CaseTrivy 0.74.0Grype 0.118.0
findings, no gating flag00
findings at or above the threshold--exit-code 1 gives 1; --exit-code 2 gives 2 (caller's choice)--fail-on critical gives 2 (fixed)
tool error (input file missing)1, whatever --exit-code says1
end-of-life OS--exit-on-eol 3 gives 3, also when --exit-code 1 --severity CRITICAL would give 1warning and alertsByPackage only
database too old for policyno maximum-age setting in the database documentation; --skip-db-update with no cached database exits 11, "the vulnerability database was built … ago (max allowed age is …)"; GRYPE_DB_VALIDATE_AGE=false scans anyway
bash — observed: the gates (CRITICAL rows in this run: Trivy 3, Grype 5)observed
trivy image --input image.tar --scanners vuln --severity CRITICAL --exit-code 1 -q; echo "exit $?"
Total: 3 (CRITICAL: 3)
│ busybox │ CVE-2022-48174 │ CRITICAL │ fixed │ 1.36.0-r9 │ 1.36.1-r1 │ busybox: stack overflow vulnerability in ash.c leads to │
exit 1
trivy image --input missing.tar --scanners vuln --exit-code 2; echo "exit $?"
FATAL Fatal error run error: image scan error: scan error: unable to initialize a scan service: unable to initialize artifact: unable to initialize archive image: 2 errors occurred:
exit 1
trivy image --input image.tar --scanners vuln --exit-on-eol 3 --format json --output /dev/null; echo "exit $?"
ERROR Detected EOL OS family="alpine" version="3.18.0"
exit 3
grype docker-archive:image.tar --fail-on critical -o table --sort-by severity; echo "exit $?"
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
libcrypto3 3.1.0-r4 3.1.6-r0 apk CVE-2024-5535 Critical 5.6% (92nd) 5.1
busybox 1.36.0-r9 1.36.1-r1 apk CVE-2022-48174 Critical 3.2% (87th) 3.0
exit 2
grype docker-archive:missing.tar; echo "exit $?"
ERROR failed to catalog: no source providers were able to resolve the input "missing.tar"
exit 1
GRYPE_DB_MAX_ALLOWED_BUILT_AGE=1s grype docker-archive:image.tar; echo "exit $?"
ERROR failed to load vulnerability db: the vulnerability database was built 2 hours ago (max allowed age is 1 second)
exit 1
GRYPE_DB_MAX_ALLOWED_BUILT_AGE=1s GRYPE_DB_VALIDATE_AGE=false grype docker-archive:image.tar -o json | jq …; echo "exit $?"
matches: 63 with the age check off
exit 0

Exceptions: the same CVE in two files

A gate needs a way to accept a finding for a while, and the two mechanisms differ in ways that matter for review. Trivy's .trivyignore.yaml (still marked experimental) takes an id, an optional statement and an expired_at date, and it is applied only when --ignorefile names the file. Grype's .grype.yaml ignore rules take a vulnerability, a package name, version, type or location, a fix state and VEX fields; the documented field list has no expiry and no reason, so the ticket goes in a YAML comment and the lapse is a calendar entry. Grype reads .grype.yaml from the working directory without being asked, which is convenient in a repository and surprising in a shared runner. Both accept VEX documents (--vex) for the more formal version of the same statement.

bash — observed: one exception, two gatesobserved
cat exc/.trivyignore.yaml
vulnerabilities:
- id: CVE-2022-48174
statement: accepted until the base image bump, INFRA-442
expired_at: 2027-01-01
trivy image --input image.tar --scanners vuln --severity CRITICAL --exit-code 1 --ignorefile exc/.trivyignore.yaml; echo "exit $?"
INFO Some vulnerabilities have been ignored/suppressed. Use the "--show-suppressed" flag to display them.
│ image.tar (alpine 3.18.0) │ alpine │ 0 │
exit 0
cat exc/.grype.yaml
ignore:
- vulnerability: CVE-2022-48174
grype docker-archive:image.tar -c exc/.grype.yaml --fail-on critical --only-fixed -o json | jq …; echo "exit $?"
still failing on: CVE-2024-5535 ignoredMatches: 17, of them by the rule: 3
exit 2
cp exc/.trivyignore.yaml exc/.grype.yaml . && trivy image --input image.tar --scanners vuln --severity CRITICAL -q | grep ^Total; grype docker-archive:image.tar --only-fixed -o json | jq …
trivy, no --ignorefile: Total: 3 (CRITICAL: 3)
grype, no -c: matches: 46, ignoredMatches: 17, of them by an ignore rule: 3

The same one-line exception turns Trivy's CRITICAL gate green and leaves Grype's red, because Grype also rates CVE-2024-5535 Critical and Trivy files it under LOW. Nothing about that split says which scanner is right, and it is not a reason to add a second ignore rule. It is a reason to open the record: an OpenSSL bug with no NVD analysis, a 9.1 from CISA's enrichment and a Low from Red Hat, fixed in 3.1.6-r0, on a base image that should have been rebuilt long before the question came up. The exception you write should name that reasoning, and in Trivy's case it can also name the date it stops applying.

SBOMs: each scanner reads its own

Both tools scan an SBOM instead of an image (trivy sbom, grype sbom:), so the SBOM-first workflow that used to be described as Grype's distinguishing feature is available on either side. What the run shows is that the SBOM has to come from the same family as the scanner that will read it. Trivy re-reading its own CycloneDX found all forty-nine rows; Grype re-reading its own found all sixty-three. Crossed over, Grype found nothing in Trivy's SBOM, and Trivy found six rows in Grype's. The reason is visible in one component. Trivy records the origin package of libssl3 as a property, aquasecurity:trivy:SrcName=openssl, and writes the distro qualifier as distro=3.18.0; Syft, which Grype embeds, writes upstream=openssl and distro=alpine-3.18.0 into the purl. Each reader ignores the other's field, so the Alpine feed, which is keyed by origin package, only matches the packages whose name is already the origin: busybox and musl. Pointing Grype at the distro by hand (--distro alpine:3.18.0) recovers exactly those six and nothing else.

bash — observed: SBOM round trips (counts vs the image scans above)observed
trivy image --input image.tar --format cyclonedx --output trivy.cdx.json; trivy sbom trivy.cdx.json --format json | jq "[.Results[].Vulnerabilities[]] | length"
trivy cyclonedx 1.7, 16 components, 0 vulnerabilities -> trivy sbom: 49 rows
grype docker-archive:image.tar -o cyclonedx-json --file grype.cdx.json; grype sbom:grype.cdx.json -o json | jq ".matches | length"
grype cyclonedx 1.7, 95 components, 63 vulnerabilities -> grype sbom: 63 matches
grype sbom:trivy.cdx.json -o json | jq ".matches | length"; grype sbom:trivy.cdx.json --distro alpine:3.18.0 -o json | jq ".matches | length"
grype on trivy.cdx.json: 0 matches; with --distro alpine:3.18.0: 6 matches (busybox 5, musl 1)
trivy sbom grype.cdx.json --format json | jq "[.Results[].Vulnerabilities[]] | length"
trivy on grype.cdx.json: 6 rows (busybox 5, musl 1)
trivy purl pkg:apk/alpine/libssl3@3.1.0-r4?arch=aarch64&distro=3.18.0 properties aquasecurity:trivy:PkgType=alpine aquasecurity:trivy:SrcName=openssl
grype purl pkg:apk/alpine/libssl3@3.1.0-r4?arch=aarch64&distro=alpine-3.18.0&upstream=openssl
trivy image --input image.tar --scanners vuln --format sarif --output trivy.sarif; grype docker-archive:image.tar -o sarif --file grype.sarif
trivy sarif: 1 run, 49 results, 22 rules (one per CVE)
grype sarif: 1 run, 63 results, 63 rules (one per match)

For a registry of stored SBOMs this is the operational rule: generate with Syft if Grype will re-scan, with Trivy if Trivy will, and keep the image digest in the document so a scan of the image itself can settle any doubt. The SARIF shapes differ in the same spirit; a code-scanning dashboard receives one rule per CVE from Trivy and one per match from Grype, so the same forty-nine rows count differently on the two sides of a migration.

Choosing, and what this run does not settle

What the same-image run supports
Trivy fits when
One binary should also check IaC, secrets and licences in the same job
Exceptions must carry a statement and an expiry date the tool enforces
An end-of-life base image should fail the job on its own (--exit-on-eol)
Vendor severity (Red Hat, Debian) is what your patch SLAs are written against
Trivy-generated SBOMs will be re-scanned by Trivy
Grype fits when
Syft SBOMs are the artifact of record and Grype re-scans them
Findings and tool errors must be distinct exit codes without parsing
A stale database should stop the scan rather than pass quietly
CVSS, EPSS and KEV in the output are how you rank the work
Post-EOL NVD records should be visible, fix or no fix

What one image on one day cannot tell you is which scanner is more complete, more accurate or faster in general. Both used the same Alpine feed and agreed on it; the fourteen extra rows and the seven severity splits are policy differences, not a defect on either side, and they will look different on an image whose vendor publishes its own ratings, which Trivy prefers over NVD (the Red Hat example in its documentation). Speed was not measured here, because cache state, database downloads and the host dominate a single run. Running both and reviewing the union is a legitimate choice for a strict environment, as long as the team agrees in advance whose severity decides the gate.

When the scanner is the problem: recovery in order of preference

SituationDoDo not
the database download fails and every job is redTrivy: TRIVY_SKIP_DB_UPDATE=true with the cached database (--skip-db-update on an empty cache exits 1). Grype: GRYPE_DB_AUTO_UPDATE=false with the cache, and remember the five-day age check; db.validate-age: false is the explicit override, with a date to remove it--exit-code 0 or a dropped --fail-on to get green: the gate is gone and nothing says so
an overnight database update adds a CRITICAL to a base image nobody can rebuild todayTrivy: a .trivyignore.yaml entry with statement and expired_at, passed with --ignorefile, in the same merge request. Grype: an ignore rule with the ticket in a comment and a reminder to remove ita rule that names the package rather than the CVE: it also hides the next one
the two scanners disagree on a severityread the record: SeveritySource and VendorSeverity in Trivy's JSON, cvss[].source and relatedVulnerabilities in Grype's; decide per CVE which rating your policy followssuppress it in the tool that rates it higher because the other one rates it lower
a match looks wrong (a CPE match, a package that is not what the name suggests)Grype: check matchDetails[].type (cpe-match is the low-confidence kind) and --only-fixed; both: a VEX statement with a justification, which both tools apply with --vexa blanket --ignore-unfixed or --only-fixed in the gate without saying why: it also hides real unfixed findings on supported images
a scanner upgrade changes the numberspin the image tag (aquasec/trivy:0.74.0, anchore/grype:v0.118.0), keep the JSON of the last good run, and diff (package, CVE) pairs before raising the gatea floating latest on the tool that enforces policy
What was run for this article
Trivy 0.74.0 (aquasec/trivy:0.74.0) and Grype 0.118.0 (anchore/grype:v0.118.0, embedded Syft v1.51.1) on Docker Engine 28.5.2, linux/arm64, against docker.io/library/alpine:3.18.0 pulled by digest sha256:02bb6f42… (linux/arm64/v8 manifest sha256:30e6d357…), saved once with docker save and read by both scanners from that tar; both reported image ID sha256:44dd6f22…. Trivy's database (v2, updated 2026-09-13 07:13 UTC) and Grype's (v6.1.9, built 2026-09-13 06:31 UTC) were downloaded at the start of the run and updates were then disabled. The terminal blocks marked observed are copied from that run (build/evidence/trivy-vs-grype, 27 assertions on exit codes, image identity and matching behaviour). Counts and severities are the run's values and will move with the databases; exit codes, the Alpine-feed-only matching in Trivy, the fix-less NVD rows in Grype and the SBOM field names do not depend on the date. In the recovery table, the exception files, the stale-database refusal and its override, and the empty-cache Trivy exit were executed; the VEX flow, package-name rules and the diff-before-raising-the-gate advice follow the documentation. No timing was measured. NVD statuses were read from the NVD API on the same day.

The pipeline side of either choice is the same shape: a report pass that never blocks and a gate pass that blocks on a short list, which the Trivy in GitLab CI note builds step by step. If Syft SBOMs are going to be the artifact your registry keeps, the SBOM with Syft and Grype note covers the formats and the day a CVE lands.

Go deeper in a courseSoftware supply chain securitySBOMs, scanning, signing and admission as one pipeline, with the trade-offs stated.View course

Related posts

Quick reference