Write a purl as pkg:type/namespace/name@version, with type pypi, gem or golang. PyPI and RubyGems usually need just a name and version. Go uses the full module path as namespace plus name. Add qualifiers after a question mark and a subpath after a hash just when they change the identity.
The pattern
The Package URL specification defines the form scheme:type/namespace/name@version?qualifiers#subpath. Scheme is always pkg. Type and name are required. Namespace, version, qualifiers and subpath are optional. Qualifiers are key and value pairs joined with ampersands.
Three worked examples
These come from the specification's own examples.
| Ecosystem | Purl | What each part says |
|---|---|---|
| Python | pkg:pypi/django@1.11.1 | Type pypi, name django, version 1.11.1 |
| Ruby | pkg:gem/jruby-launcher@1.1.2?platform=java | Type gem, name jruby-launcher, version 1.1.2, platform qualifier java |
| Go | pkg:golang/google.golang.org/genproto#googleapis/api/annotations | Type golang, namespace google.golang.org, name genproto, subpath into the module, no version |
Notice the Ruby example. The platform qualifier says which variant of the gem is meant. A gem built for Java is not the same artifact as one built for another platform, so the qualifier carries identity.
In the Go example the module path supplies the namespace and name, and the subpath points to a package inside the module. The example has no version, which is legal but rarely what you want in an SBOM.
Steps to write one by hand
- Pick the type that matches the registry the code came from.
- Take the name exactly as the registry spells it.
- If the ecosystem uses a prefix, such as a Go module host path, put it in the namespace.
- Add the exact resolved version, not a range.
- Add qualifiers that change which artifact you mean, such as platform or architecture.
- Add a subpath just if you are pointing inside the package.
- Percent encode special characters. The specification refers to ECMA-427 for full syntax, so check it for edge cases.
Mistakes that break matching
Using a range as a version, for example a caret range. A vulnerability database matches exact versions, so the component will never match.
Choosing the wrong type, for example writing a Python package as generic. The component then has no ecosystem for advisories.
Dropping the namespace for Go modules. Two modules with the same last path element would collapse into one identity.
Putting a local path or a git URL where a registry coordinate belongs, with no note that it is unusual.
How to check a batch
Pull every purl from your SBOM, sort them, and scan for missing versions, ranges and odd types. A short script or a spreadsheet filter is enough. Then feed a sample to an advisory lookup and see that each resolves.
The specification says purls are used in CycloneDX and SPDX files and in databases such as OSV, so a clean purl is what lets those systems find your component.
A worked example
You inherit an SBOM where three components have hand written identifiers. Below is a made up cleanup session.
| As written | Problem | Corrected idea |
|---|---|---|
| pkg:pypi/Django@^1.11 | Range instead of an exact version | pkg:pypi/django@1.11.1 |
| pkg:gem/jruby-launcher@1.1.2 | Missing platform when the Java build is meant | pkg:gem/jruby-launcher@1.1.2?platform=java |
| pkg:golang/genproto@1.0 | Missing the module host path in the namespace | pkg:golang/google.golang.org/genproto with a real version |
The specification's own examples show the second and third shapes. The first correction follows from the rule that a version should identify one release, which is what lets advisory databases match.
Namespaces in each ecosystem
Not every ecosystem uses a namespace. In the PyPI example there is none. In the Go example the module path supplies it. Maven, as the specification notes, uses the group identifier. The rule of thumb is to follow the ecosystem's own naming. When in doubt, read the type specific rules the specification links to.
Where qualifiers matter
Qualifiers narrow identity. The specification names arch, distro, repository_url and platform as examples. Use them when the same name and version could refer to different artifacts. Leave them out when they do not change identity, because extra qualifiers can prevent a match with a database that omits them.
Subpaths
A subpath points inside a package. The Go example uses one to point at a package within a module. In an SBOM you rarely need subpaths, since the unit of tracking is the module. Use one when a finding or a note refers to a specific path.
A validation routine
- Extract all identifiers from the SBOM into a list.
- Check that each starts with the scheme and has a type and a name.
- Flag versions that contain range characters or are empty.
- Group by type and look for typos such as misspelled ecosystem names.
- Sample ten entries and look them up in the relevant registry.
- Fix the generator or its configuration, not just the output.
If you generate identifiers by script, add unit tests with the three examples above and a few of your own. Tests catch regressions when someone refactors the code.
Encoding
The specification shows percent encoding in its examples: an at sign in an npm scope becomes %40, and a slash inside a qualifier value becomes %2F. For the full rules it points to ECMA-427 Clause 5. If you hand write identifiers with unusual characters, read that clause rather than guessing.
Why bother
The specification says purls are used in CycloneDX and SPDX files and in vulnerability databases such as OSV. A clean identifier is what lets your component match an advisory without a human reading names. A sloppy one produces either false negatives, where a vulnerable component is missed, or false positives, where an unrelated package is flagged.
Governance
Give someone ownership of identifier quality. A short guideline, a test suite and a monthly sample check keep the data healthy. Without them, small errors accumulate across thousands of components.
Where Safeguard fits
Safeguard scans SBOMs and dependencies. Upload a file to the free scan at gold.safeguard.sh/scan and check that components resolve.
Try it yourself
Click through it at your own pace, or open it full screen.
Click into a few components and note how direct and transitive packages are separated.
Quick check
Does a Go purl need a version?
The spec allows it to be omitted, but include one in an SBOM so advisories can match.
What does the platform qualifier do?
It narrows which variant of the package you mean, as in the Ruby example with java.
Can I use a range as the version in a purl?
Avoid it. A version should identify one release so that advisories can be matched exactly.
Sources
Last reviewed: 19 October 2026.