To install a specific version of an npm package, append the version after an @: npm install [email protected]. That writes the exact version into package.json and package-lock.json. Everything more complicated than that is about what happens on the next install, on someone else’s machine, six weeks later.
Version pinning is one of those topics where the command is trivial and the consequences are not. The interesting questions are which range syntax to use, why your colleague got a different tree from the same package.json, and why npm install and npm ci behave differently on purpose.
Table of contents
- The syntax
- What the range prefixes actually permit
- The lockfile is what actually decides
- Downgrading, and why it sometimes does not stick
- Pinning strategy, honestly
- Making CI reproduce production exactly
- How this fits the rest of the stack
- FAQ
The syntax
# Exact version
npm install [email protected]
# Latest matching a range
npm install lodash@^4.17.0 # >=4.17.0 <5.0.0
npm install lodash@~4.17.0 # >=4.17.0 <4.18.0
npm install 'lodash@>=4.17.0 <4.18.0'
# Dist-tags, not versions
npm install react@latest
npm install react@next
npm install react@beta
# Globally
npm install -g [email protected]
# Save exactly, no caret prefix
npm install --save-exact [email protected]
Quote any range containing >, <, or spaces, or your shell will interpret it as redirection and create a file called 4.18.0 in your project.
To see what is available before choosing:
npm view lodash versions --json | tail -20
npm view lodash version # current latest
npm view lodash dist-tags
npm ls lodash # what is installed, and why
What the range prefixes actually permit
By default npm install lodash writes ^4.17.21 — with a caret. Most people know the caret means compatible and stop there. Knowing the exact boundary matters when a patch release breaks your build.
^4.17.21(caret) — allows anything up to but excluding 5.0.0. Minor and patch upgrades install automatically.~4.17.21(tilde) — allows patch only: up to but excluding 4.18.0.4.17.21(exact) — this version and no other.*orlatest— anything. Do not do this in a project you intend to keep.
The caret behaves differently below 1.0.0, and this catches people. For ^0.2.3, the range is >=0.2.3 <0.3.0 — it treats the minor position as breaking, because pre-1.0 packages have no stability promise. For ^0.0.3 it allows only that exact version.
The default project-wide behaviour is configurable:
# Make --save-exact the default for this project
npm config set save-exact=true --location=project
# Or in .npmrc at the repo root
echo 'save-exact=true' >> .npmrc
The lockfile is what actually decides
This is the part that resolves most confusion. package.json records your intent — a range. package-lock.json records the result — the exact version of every package and every transitive dependency, with integrity hashes.
When both exist and agree, npm installs what the lockfile says, ignoring that the range would permit something newer. That is the entire point: reproducible installs.
# Respects the lockfile, but may update it if package.json changed
npm install
# Installs strictly from the lockfile. Fails if they disagree.
# Deletes node_modules first. This is the CI command.
npm ci
Use npm ci in CI and Docker builds, always. It is faster, it cannot silently drift, and it fails loudly when package.json and the lockfile are out of sync — which is exactly the signal you want, rather than a mystery version difference in production.
The corollary: commit your lockfile. A repository without one has no reproducible builds, and the tree you get depends on the day you installed.
Downgrading, and why it sometimes does not stick
Downgrading a direct dependency is straightforward:
npm install [email protected]
npm ls lodash # confirm
But if npm ls shows the old version still present, it is because a transitive dependency requires it — something else in your tree asked for a version your downgrade does not satisfy, so npm installed both.
$ npm ls lodash
[email protected]
├── [email protected]
└─┬ [email protected]
└── [email protected] <- pulled in separately
For genuine conflicts, overrides in package.json forces a version across the whole tree — including packages that asked for something else.
{
"overrides": {
"lodash": "4.17.21",
"some-package": {
"lodash": "4.17.21"
}
}
}
Overrides are a sharp tool. You are telling npm that a package’s stated requirements are wrong. It is the right answer for patching a transitive security advisory quickly and the wrong answer as a permanent fixture — leave a comment explaining why, because the next person will not guess.
Pinning strategy, honestly
There is a real trade-off and the internet tends to argue only one side of it.
Exact pins everywhere give perfect reproducibility and a maintenance burden — every patch, including security patches, needs a deliberate update. Projects that pin exactly and lack automated updates end up years behind.
Caret ranges everywhere get patches for free and occasionally get a broken minor release straight into your build, because not every publisher respects semver.
The setup that works in practice combines both: carets in package.json, a committed lockfile, npm ci in CI, and automated dependency PRs. The lockfile provides reproducibility, the ranges keep the door open, and the bot proposes updates that your test suite votes on.
- Pin exactly for anything that has burned you before, with a comment saying so.
- Pin exactly for build tooling where a minor bump changes output.
- Use carets for well-maintained libraries with real release discipline.
- Never use
*,latest, or a git branch reference in a project you intend to deploy.
Making CI reproduce production exactly
The version drift that hurts is not usually in your dependencies. It is in Node itself, or in npm, differing between a laptop and the build machine.
{
"engines": {
"node": ">=20.11.0 <21",
"npm": ">=10.2.0"
}
}
# Enforce engines rather than warning about them
echo 'engine-strict=true' >> .npmrc
# Pin the toolchain for anyone using corepack
corepack enable
corepack use [email protected]
Add an .nvmrc with the Node version so local shells match, and set the same version in your build configuration. Three files saying the same thing is redundant and it is how you stop the works on my machine conversation.
On RunxBuild, Node services build from the repository with the Node version as configuration rather than as whatever the machine happened to have, and the build log shows the install step in full — so a lockfile mismatch surfaces as a clear npm ci failure at build time instead of a runtime surprise. The Node services documentation covers where that version is set.
How this fits the rest of the stack
Use @version for the install, understand that the caret allows more than most people assume, and let the committed lockfile be the source of truth. Run npm ci anywhere reproducibility matters and treat a lockfile conflict as useful information rather than an obstacle. If you are sizing a Node deployment and want the build minutes and the runtime as separate figures, the RunxBuild hosting calculator shows them apart.
Useful related references:
- Cloud Computing Engineer: A More Specific Flavor of Cloud Engineering
- Git Clone Specific Branch: Four Commands and What Each Actually Downloads
- npm Clean Install: When Deleting node_modules Is Right, When
npm ciIs Right, and When Both Are Slowing You Down - Services on RunxBuild
FAQ
How do I install a specific version of an npm package?
Run npm install package-name@version, for example npm install [email protected]. Add —save-exact to write the version without a caret prefix so future installs cannot drift.
What is the difference between npm install and npm ci?
npm install respects the lockfile but may update it when package.json changes. npm ci deletes node_modules and installs strictly from the lockfile, failing if the two files disagree. Use npm ci in CI and Docker builds.
What does the caret in ^4.17.21 allow?
Anything from 4.17.21 up to but excluding 5.0.0. Below version 1.0.0 the rule is stricter: ^0.2.3 allows up to but excluding 0.3.0, because pre-1.0 packages make no stability promise.
Why is an old version still installed after I downgraded?
A transitive dependency requires it, so npm installed both copies. Run npm ls package-name to see which package pulled it in, and use overrides in package.json if you need to force one version across the whole tree.
Should I commit package-lock.json?
Yes, always, for applications. Without it there is no reproducible install and the dependency tree you get depends on when you ran the command. Libraries published to npm are the one debatable case.