The previous sections covered the basics of version conflicts, SemVer, and installation models.
We're now ready to examine the package lockfile in detail. In a nutshell, it stores an
installation plan for how the
node_modules folder should get installed, according to the
installation model in use. From now on, we'll assume that it's the
PNPM installation model, and so the filename is
Why do we need a lockfile?
Suppose hypothetically that we did not have a
pnpm-lock.yaml file stored in Git, and suppose also that
my-app/package.json has a dependency on
"react": "^18.0.0". Let's say today is May 1st, and the latest release
from the 18 series is
firstname.lastname@example.org. Without a lockfile, that is what PNPM would choose to install.
Latest is greatest, right? Well, consider a few hypothetical scenarios:
Ability to reproduce an error. Some time passes, and it's now June 15th. When you install
my-app, the latest version is now
email@example.com. Suppose the app malfunctions because of a change introduced in that version. But when you ask a teammate for help, they don't know what you're talking about. "Everything runs fine on my computer!" The reason is that their
node_modulesfolder still has the old
Building old branches. To investigate, you remember that the project worked successfully a week ago, so you decide to try checking out older versions of the Git branch. By pinpointing the commit that broke things, maybe you can compare the diff to find the change that broke things. However because there is no lockfile, those old branches are all now failing as well, because
firstname.lastname@example.org getting installed instead of
Deterministic releases. But that means... The next time we deploy to production, it's going to include
email@example.com cause a customer incident. Uh oh! Essentially, we're deploying something different from what we tested. How can we pin the version back to
firstname.lastname@example.org a given branch?
A naive solution would be to change
"react": "^18.0.0" to
"react": "18.1.0" in
however this only affects direct dependencies of your project. A typical
node_modules folder may contain
thousands of NPM packages, most of which are dependencies-of-dependencies, whose version range is determined
by an external
What does a lockfile store?
The package lockfile is the ideal solution to the above problem: It records the exact version number of every
single package in your
node_modules folder, along with topological information such as peer dependency
relationships. This enables the package manager to reproduce the exact same
node_modules folder structure
regardless of whatever the "latest" versions might be on that day. The
pnpm-lock.yaml file is serialized as
human-readable text, in a format whose diff minimizes Git merge conflicts as much as possible. The file format
also carefully avoids storing extra information that might not be portable between operating systems
or NPM registry configurations.
How is a lockfile created?
|PNPM command||Rush command||What it does|
|Checks whether the existing lockfile is consistent with your version ranges. If not, a minimal update is performed, preserving as much as possible from the previous lockfile.|
|Checks whether the existing lockfile is consistent with your version ranges. If not, Rush fails with an error message telling you to run |
|Uses a heuristic to detect whether the command seems to be running in a CI environment. If yes, the behavior is like |
With Rush, the lockfile is stored in
common/config/rush/pnpm-lockfile.yaml. During installation, it gets
common/temp/ where the workspace installation occurs.
If you're using a PNPM workspace without Rush, then
pnpm-lockfile.yaml is instead stored in your repository's
root folder, next to your
Two different meanings of "lockfile"
In classic computing, the word "lock file" has always referred to a file that coordinates a multi-process concurrency mechanism such as a mutex. The
@rushstack/node-core-libraryLockFile class implements such a mechanism.
When NPM was first introduced, its serialized installation plan was called a "shrinkwrap file",
shrinkwrap.yaml, and Rush still uses this terminology in many places. Yarn called its file
yarn.lock, adopting a similar naming convention as Ruby's
Gemfile.lock, which people had started calling "lockfiles". For consistency, PNPM and NPM later switched to using "lockfile" instead of "shrinkwrap file".
When the context is unclear, our documentation will sometimes use the term package lockfile to avoid any confusion with concurrency lockfiles.
Overall file structure
Recall that the
demo/sample-1 lockfile looks like this:
Let's examine the corresponding YAML file. Using
... to fold the YAML subtrees, the top-level file structure
looks like this:
Comparing with the diagram, the
importers section represents our local projects in the workspace.
packages section represents the external NPM packages downloaded from the NPM registry.
The YAML key for local projects is their file path, relative to the folder of
which Rush generates from its project inventory in rush.json. The empty
.: key corresponds to the
common/temp/package.json which is generated by Rush.
The YAML key for external packages appears to be a combination of the package name and version. For
But what are these strange entries with
_ suffixes? The answer is that they are peer doppelgangers
-- multiple installs of the same version of the same package. Although the
PNPM installation model eliminates NPM doppelgangers, it still needs to create
doppelgangers in the case of a peer dependency. For example, in the diagram we can see that
1.0.0 needs to be installed in two separate folder locations:
M@1, installed into:
K@1with both peer
N@2, installed into:
.pnpm disk folder can be found under
common/temp/node_modules/, and PNPM's various symlinks will ultimately
point into paths under this folder. The exact disk paths are implementation details and therefore not captured in
the lockfile; to observe them, you need to run
rush install and then inspect the resulting
_@email@example.com suffix is really just an informational label to improve readability for humans.
In cases where the generated label would be too long, PNPM falls back to using a hashed string such
Lockfile Explorer refers to the
packages: items as lockfile entries. It's important
to understand that "entries" don't correspond to package versions, but rather folders on disk where
a package version got installed: A single package version may produce multiple lockfile entries,
in the case of doppelgangers.
The Lockfile Explorer UI displays
importers: on the "Projects" tab. The
packages: are displayed the "Packages" tab.
👉 Now's a good time to run
lockfile-explorer in your
demo/sample-1 checkout workspace, to see how
these lockfile entries appear in the UI.
A package entry
Here's an example entry from the
packages: section, describing an install of
We can see several fields:
resolution:has a tarball hash, used in the case where an NPM registry allowed a package to be republished with different content (a deprecated practice)
dependenciesof this package, and the exact lockfile entry that was chosen to match their version range.
dependencies:field actually combines both regular and
devDependencies, because the installation plan doesn't care /why/ something is a dependency; it ultimately needs to be installed or not
trueif this package was only ever referenced via
devDependencies. It is used by PNPM's --dev parameter.
peerDependencies:tracks the peer dependencies specially, since managing them is one of the most complex aspects of the PNPM algorithm.
peerDependenciesMeta:tracks whether they are optional or not
A project entry
Perhaps surprisingly, the
importers: section for local projects has a somewhat different structure,
even though both are described by the same package.json input format. Let's see if we can explain why:
specifiers:remembers the original SemVer ranges from package.json. Because this package was not published to an NPM registry, its package.json file can be manually modified at any time, so the lockfile needs to detect such changes. In other words, the lockfile needs to capture more details about local projects, because they are mutable whereas published packages are (mostly) immutable.
dependencies:tracks the dependencies in the same way as the
link:../estring describes a dependency that will be symlinked to a local project folder, for example due to a
workspace:*specifier in package.json.
peerDependenciesnever appears in this section. The reason is that PNPM does not properly install peer dependencies for local projects. Why? Well, as we've seen, peer dependencies are installed by creating peer doppelganger folders -- multiple copies of the package folder. But for a local project that is built from source code, how would that work? Whenever you recompile the code, there would need to be some mechanism that copies the output into the various doppelganger folders under
node_modules. In practice it doesn't cause much trouble, but nonetheless it's a somewhat important limitation to understand, as it explains some weirdness with peer dependencies for local projects. See PNPM#4407 for our current thinking about it.
Lockfile entry identifiers
Looking at the above snippets, you may notice that the
dependencies: section uses a different
entry identifier syntax than the YAML keys. For example:
importers:gets referenced as
../e. The path is relative to
projects/cinstead of relative to
packages:gets referenced as
dependencies: uses a shortened form of the lockfile entry identifier, probably because it yields
significant file compression for the YAML file, and maybe is a little easier to read. However this inconsistency
makes it somewhat difficult to traverse the dependency graph by searching for strings in your text editor.
This was one of the major motivations for creating the Lockfile Explorer app.