A pinned dependency is one where the exact version cannot change without a deliberate edit. But the word hides differences. In pip it means an exact version with an equals sign. In NuGet, a plain version number is actually a minimum. In Gradle, a dynamic selector floats until you lock it. Agree on a team definition that works in all of them.
Why one word causes trouble
When a Python developer says "we pin everything" and a .NET developer says the same, they may mean different things. Both can be sincere. One is describing exact versions, the other is describing a number in a project file that the resolver treats as a floor.
A mismatch like that shows up in incident reviews as "we thought that was fixed". A shared glossary prevents it.
What the docs say per ecosystem
| Ecosystem | A plain version in the manifest means | What makes the result fixed |
|---|---|---|
| pip | Hash checking requires an exact equals version, a URL or a path | Pinned versions plus hashes |
| NuGet | The version or higher, with preference for the lowest | Lock file, restored in locked mode |
| Gradle | Dynamic selectors such as 5.+ are allowed | Dependency locking, written to gradle.lockfile |
| Composer | A constraint, resolved against composer.json | composer.lock, used by install |
| npm | A range or version in package.json | package-lock.json, used by clean install |
Notes: the NuGet docs say 3.6.0 means any version at or above 3.6.0, preferring the lowest, and they describe floating versions such as 3.6.* separately. Gradle's locking exists so builds use the same versions even with dynamic selectors. For pip, the secure installs page says every requirement must be pinned to a URL, a path or an exact version for hash checking.
Three levels worth naming
Use words that map to what you can verify:
- Declared: the manifest names a version or range.
- Locked: a lock file records the resolved graph, including transitive packages, and CI installs from it.
- Verified: the lock also carries hashes, and the installer checks them.
Most teams are at level 2 in some languages and level 1 in others. That is fine as long as the table is honest.
A short audit
For each repo, answer in one line:
- Which level is it at today?
- If it is below level 2, what stops it from getting there?
- Is the install step in CI actually using the lock?
The third question catches a common gap: a lock file exists, but CI runs an ordinary install that rewrites it.
Why transitive packages decide it
Pinning your direct dependencies is the visible part. The transitive graph is larger and is where surprises come from. NuGet's docs give an example: you declare version 4.0.0 of a package, only 4.1.0 and later exist, so you resolve 4.1.0. The next day 4.0.0 is published, and NuGet starts resolving 4.0.0. Nothing in your project file changed. A lock file removes that surprise.
Safeguard can help here: it scans dependencies and generates SBOMs, applies reachability analysis so you can sort findings by what your code can actually call, and enforces policies and gates on pull requests. You can try a free scan on a repository to see what it finds.
Quick check
Is a version number in package.json or a project file a pin?
Not on its own. It is a declaration. The lock file is what fixes the resolved versions, and only if CI installs from it.
Do I need hashes as well as a lock?
Hashes add integrity checking. pip makes them all or nothing, which is strict but clear. Treat hashes as the verified level where the ecosystem supports them.
Sources
Last reviewed: 19 October 2026.