How should you pin dependencies and why?

Niccolo Belli

What Is Pinning and Why Is It so Important?

With the term pinning we are referring to the practice of making explicit the version of the libraries your application is depending on. Package managers like npm or yarn use semver ranges by default, which basically allows you to install a “range” of versions instead of a specific one.

By freezing the dependencies we want to achieve repeatable deployment and make sure that every developer is testing on the very same codebase.

Why Did Package Managers Default to Semver?

The main reason is to automatically get updates every time we run npm install (assuming you’re not using a lock file, more on that later). This is done because we want to get security fixes as fast as possible. The theory behind that is that semantic versioning should protect us against breaking chances, while still getting the security fixes.

What Happens When Semver Fails?

Unfortunately semantic versioning is far from being infallible and breakage might occur. Since multiple dependencies can be updated at once when that happens you will have to manually check which one to blame, and then you will be forced to pin it to fix the issue.

With pinning, you will have to make a PR to update your dependencies and thus get some feedback from the automated tests. So you will know exactly which dependency is going to break your app before that happens.

Tests Can Fail Either

Truth is that tests are not perfect either and chances are you probably didn’t read the release notes looking for breaking changes before merging a green-light PR. Nevertheless pinning still has a big advantage even when the failure is not caught in time: instead of randomly looking for which dependency broke your code, you will be able to bisect the issue very quickly. Git bisecting is a quick way to roll back to previous commits and find out which one introduced the regression. Instead of doing it manually a git bisect allows you to specify a good commit and a bad commit, then it will pick up a commit in the middle and ask you if it’s good or bad. Depending on your answer it will divide the leftmost or rightmost interval and iterate the process until the guilty commit is detected. The whole process can be automated and it’s usually very quick.

Downsides of Pinning

Automation

You may be asking who is going to PR the repo every time a new dependency gets released, because this is a very tedious task to be done manually. Fortunately there are several tools you can use to automate the process, like Renovate. Such tools will constantly check for dependency updates and take care of automatically PR your repo.

Libraries

The biggest downside of pinning concerns libraries development. If you are publishing you own library to npm, and you decide to pin the dependencies then the incredibly narrow range of versions will almost certainly lead to duplicates in node_module. If another package pinned a different version you will end up with both and your bundle size will increase (and thus the loading times). According to Rhys Arkins (the author of Renovate), even if both authors are using a service like Renovate this is still not a good idea:

Even if both projects use a service like Renovate to keep their pinned dependencies up to date with the very latest versions, it’s still not a good idea — there will always be times when one package has updated/released before the other one, and they will be out of sync. e.g. there might be a space of 30 minutes where your package specifies foobar 1.1.0 and the other one specifies 1.1.1 and your joint downstream users end up with a duplicate.

It must be noted that despite our best efforts’ duplication is a “characteristic” of yarn and a simple yarn upgrade against an existing lock file does not mean that the whole tree gets shaken for duplicates. You will need post-processing of lock files using yarn-deduplicate to superseed this issue.

Obviously everything we said about duplication doesn’t apply to Node.js libraries, because the bundle size doesn’t matter on the server.

We explained why package.json pinning is a bad idea, but you may still be wondering if it is wise to publish the yarn.lock file along with your library.

When you publish a package that contains a yarn.lock, any user of that library will not be affected by it. When you install dependencies in your application or library, only your own yarn.lock file is respected. Lockfiles within your dependencies will be ignored.

Since the library lock file will be ignored when it gets installed as a dependency, it won’t produce any duplication.

Upgrade Noise

Going through dozens of PRs each and every day can be annoying. Fortunately Renovate gives you several solutions to deal with the problem, like auto-merging (this may sound scary, but if you don’t have full coverage you could automatically merge patch updates while manually merging minor and major updates), branch auto-merging (it’s basically the same, but the dependency are merged in a test branch which can be periodically merged back into master), scheduling (which allows you to avoid immediate notifications) and packages grouping (Apollo-Client and all it’s related packages in one PR).

How to Pin Packages

package.json And the Sub-Dependencies Problem

Historically the most common way to pin dependencies was to specify an exact version in your package.json, for example using the --save-exact parameter with npm install (you can make it default by adding save-exact=true to your .npmrc). With yarn you can use --exact / -E.

Unfortunately pinning in package.json will protect you against breakage of a very small portion of your packages. If fact even when you pin a package all of its dependencies will still be free to update: you will protect yourself against a single bad release, but you will still be exposed to dozens through subdeps.

Even if we pin @angular/compiler-cli we would still be exposed to dozens of sub-dependencies

To make things worse, chances that a sub-dependency will break your app increase with package.json pinning compared to semver: you’re going to use unpinned (and thus newer) subdeps with older pinned packages and that combo will probably be less tested.

Lock Files to the Rescue

Both yarn and recent npm versions allow you to create a lock file. This allows you to lock each and every package you depend on, including sub-dependencies.

Despite what some people think, if you have "@graphql-modules/core": "~0.2.15" in your package.json and you run yarn install, it won’t install version 0.2.18: instead it will keep using the version specified in yarn.lock. That means that your packages will practically be “pinned” despite not actually pinning any of them in package.json.

To upgrade it to 0.2.18 you will have run yarn upgrade @graphql-modulules/core (note that it won’t upgrade up to 0.4.2, because it will still obey package.json).

If a package is already at the latest version you can still use yarn upgrade <package> to update its sub-dependencies.

Unfortunately it won’t also update package.json to reflect ~0.2.18 because technically there is no need (we’re already in range). But honestly a lock file provides way less visibility compared to package.json, because it’s not designed to be human-readable. So if you’re looking for dependency updates you will have a hard time figuring it out, unless you’re using yarn outdated. It eases your work by looking through the lock file for you and reporting all the available updates in an easy-to-read format.

Even with a lock file an unexperienced user could simply run yarn upgrade and update all dependencies at once. As we discussed previously this is very bad to keep track of dependency updates, and you could have hard times figuring out which package to blame for breakage.

Why Not Both?

In theory, you could get the best of both worlds if you use --exact while still using a lock file: a human-readable format, protection against all sources of breakage (including sub-deps), protection against unwanted mass-upgrades ( yarn upgrade won’t update anything if package.json is pinned).

You get the best of both worlds, but this solution has some downsides as well. If you ever used tools like Angular CLI and in particular commands like ng new or ng update you probably noticed that some dependencies like zone.js, rxjs or typescript will get tighter ranges (like ~ which means patch versions only) compared to others. This is because the Angular team knows that some packages could easily break a certain version of the framework and thus suggest you to not upgrade over a certain version: if you want a newer version they advise you to upgrade Angular itself before. By pinning package.json you will loose such useful advices and, if your test coverage is not optimal, risk to catch some subtle issues.

Conclusion

The ideal solution would be to use Renovate with updateLockFiles enabled and rangeStrategy set to bump. That way package.json will always reflect yarn.lock to provide a human-readable format. At the same time package.json won’t be pinned, so theoretically you could be able to use it to instruct Renovate about which dependencies to automerge. I said theoretically because I would love Renovate to automerge in-range dependencies if automated tests are passing, while still undergoing through manual confirmation if they are out of the range specified in package.json. Unfortunately it is only possible to automerge either major, minor or patch versions, but not according to package.json ranges. If an in-range option was available you could use package.json to specify how confident do you feel about auto-merging a specific package: if you feel comfortable you could use ^, if you feel more cautious just a ~, while if you want to manually approve every and each upgrade simply pin it with --exact.

For example let’s say I have the following entries in my package.json:

{
  "tslib": "^1.9.0",
  "zone.js": "~0.8.26"
}

Currently, if you set automerge to “patch” when zone.js 0.8.27 gets released it will automatically merge the PR and the same would happen for tslib 1.9.1. Unfortunately once tslib 1.10.0 gets released it won’t be automatically merged, unless you decide to set automerge to “minor” (but then zone.js 0.9.0 will be automatically merged, which is not what we want).

Basically I’d like renovate’s automerging policy to obey package.json: ^ means automerge “minor” on current package ~ means automerge “patch” on current package pinned version means never automerge the current package.

It’s a way to get a more fine-grained control on the automerging policy, because some packages can be more risky than others.

Since we are stuck with either major, minor or patch for automerge, the only compelling reason to avoid package.json pinning is if you’re using tools like ng update and you don’t want to loose upstream update policies. If that doesn’t bother you, you should add package.json pinning on top of your lock file.

An Important Note about Libraries

Everything we said in the conclusion applies to normal applications, but not libraries. As we said previously with libraries we want to use wider ranges to prevent duplication. Unfortunately the bump rangeStrategy basically forces you to always use latest and greatest version, which could create some duplicates. Fortunately we also have the update-lockfile rangeStrategy which bumps the version in the lock file but keeps the range unchanged unless the update is out of range (if you range is ^1.9.0 and 2.0.0 gets released it will bump the range).

Join our newsletter

Want to hear from us when there's something new?
Sign up and stay up to date!

*By subscribing, you agree with Beehiiv’s Terms of Service and Privacy Policy.