The debate between pinning exact dependency versions and using semver ranges has been running for years, and both camps have valid points. Pin everything and you get reproducibility but fall behind on security patches. Use ranges and you get automatic updates but introduce unpredictability. The right choice depends on your risk profile, your testing infrastructure, and how much you trust the open-source ecosystem.
Let me break down the actual tradeoffs.
What Pinning Looks Like
Exact pinning means specifying the precise version of every dependency:
{
"dependencies": {
"express": "4.18.2",
"lodash": "4.17.21",
"axios": "1.4.0"
}
}
Every install produces the exact same dependency tree. No surprises.
What Ranges Look Like
Semver ranges allow automatic updates within defined boundaries:
{
"dependencies": {
"express": "^4.18.2",
"lodash": "~4.17.21",
"axios": "^1.4.0"
}
}
The caret (^) allows minor and patch updates. The tilde (~) allows only patch updates. Each time you install, you might get a different version than last time.
The Case for Pinning
Reproducible builds. When you pin versions and commit your lock file, every developer, every CI run, and every deployment uses exactly the same code. This eliminates "works on my machine" issues caused by dependency version differences.
Protection from supply chain attacks. If a maintainer publishes a compromised patch version (like the event-stream incident), pinned versions will not automatically pull the malicious update. You are protected until you explicitly upgrade.
Audit clarity. When your SBOM lists exact versions, there is no ambiguity about what shipped. Auditors and customers get a precise inventory.
Controlled upgrades. Every version change is a deliberate decision, reviewed in a pull request, with full test coverage. Nothing changes without someone deciding it should.
The Case for Ranges
Automatic security patches. When a vulnerability is fixed in a patch release, ranges can pull the fix automatically on the next install. Pinned versions require a human to update, and that human might not be aware of the fix for days or weeks.
Reduced maintenance burden. Managing exact versions for hundreds of dependencies is tedious. Ranges reduce the number of manual update operations.
Ecosystem compatibility. Many libraries are tested against semver ranges of their dependencies, not exact versions. Using ranges aligns with how the ecosystem is designed to work.
Faster development. Developers spend less time managing dependency versions and more time building features.
The Real Risk: Lock Files Change the Equation
Here is the nuance most discussions miss: lock files make the pinning-vs-ranges debate less binary than it appears.
When you have a lock file (package-lock.json, yarn.lock, poetry.lock), your production builds use the exact versions recorded in the lock file, regardless of what your manifest says. The range only matters when the lock file is regenerated.
So the practical question is not "should I use exact versions in my manifest?" but rather:
- Do you commit your lock file? You should. Always.
- When do you regenerate the lock file? This is where the strategy matters.
With a committed lock file:
npm installrespects the lock file, giving exact versions.npm updateregenerates the lock file within the ranges specified inpackage.json.- Dependabot or Renovate can automate lock file updates with PRs.
This means you can use ranges in your manifest for flexibility while getting exact pinning from your lock file for reproducibility. Best of both worlds, if you manage the lock file correctly.
The Recommended Approach
For Applications (Services, Websites, CLI Tools)
Use ranges in manifests, commit lock files, and automate updates.
{
"dependencies": {
"express": "^4.18.0",
"pg": "^8.11.0"
}
}
- Commit
package-lock.json(or equivalent). - Use Renovate or Dependabot to create PRs for dependency updates.
- Auto-merge patch updates if your test suite is comprehensive.
- Manually review minor and major updates.
This gives you the security benefit of prompt updates with the stability of lock-file-pinned builds.
For Libraries (Published Packages)
Use ranges and do not commit lock files.
Libraries specify ranges to allow consumers flexibility in resolving the dependency tree. If your library pins lodash@4.17.21 and another library pins lodash@4.17.20, the consumer might end up with two copies.
{
"dependencies": {
"lodash": "^4.17.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
}
For Security-Critical Dependencies
Pin exact versions for dependencies that directly handle security functions:
- Authentication libraries
- Cryptographic implementations
- JWT parsing
- Input sanitization libraries
- TLS/SSL implementations
These deserve extra scrutiny on every version change. Pin them and update them deliberately with careful review.
{
"dependencies": {
"bcrypt": "5.1.1",
"jsonwebtoken": "9.0.2",
"helmet": "7.1.0"
}
}
Transitive Dependencies
Your manifest controls direct dependencies, but transitive dependencies (dependencies of your dependencies) are governed by your direct dependencies' specifications. You have limited control here.
Lock files pin transitive dependencies too, which is another reason to commit them. Without a lock file, transitive dependencies can change between installs even if your direct dependencies are pinned.
Some tools allow overriding transitive dependency versions:
{
"overrides": {
"semver": "7.5.4"
}
}
Use overrides sparingly and only for security fixes. Overriding a transitive dependency to a version its parent was not tested with can introduce subtle bugs.
Automation Is the Answer
The real solution to the pinning-vs-ranges debate is automation. Regardless of your manifest strategy:
- Run automated dependency update tools (Renovate, Dependabot) that create PRs for available updates.
- Maintain comprehensive tests that validate dependency updates automatically.
- Configure auto-merge for low-risk updates (patch versions with passing tests).
- Scan continuously so you know when a pinned version becomes vulnerable.
With this setup, the manifest format matters less because you have a systematic process for keeping dependencies current regardless of how they are specified.
How Safeguard.sh Helps
Safeguard.sh monitors your dependencies regardless of whether you use pinning or ranges. It tracks the exact versions in your lock files, alerts you when those versions have known vulnerabilities, and helps you understand the security implications of your dependency management strategy. For teams using ranges, Safeguard.sh validates that automatic updates are not introducing new risks. For teams using pinning, it ensures you are aware of security patches you need to manually apply. The platform provides the visibility layer that makes either approach work safely.