For build tools, make updates a deliberate act. Either save exact versions in package.json, or keep caret ranges and install from a committed lockfile with npm ci. What you want to avoid is a build that silently picks up a newer bundler or compiler release because someone ran an install.
What a range means
npm's semantic versioning guide shows the syntax. Patch releases are written as 1.0, 1.0.x or ~1.0.4. Minor releases are written as 1, 1.x or ^1.0.4. Major releases are written as an asterisk or x. So a caret range accepts new minor and patch versions, and a tilde range accepts new patch versions only.
The guide's own example uses a caret for one dependency and a tilde for another. That is the default style npm writes when you install a package without extra flags.
Two ways to get stable builds
| Approach | How | Trade off |
|---|---|---|
| Exact versions in package.json | Save with the exact option, so the version has no range | Clear in code review, every update is an edit |
| Ranges plus a committed lockfile | Keep caret or tilde, rely on package-lock.json and npm ci | Less churn in package.json, the lockfile carries the real pin |
The lockfile documentation says package-lock.json describes the exact tree that was generated, so subsequent installs generate identical trees regardless of intermediate dependency updates. It also says one purpose is to give visibility of tree changes through readable source control diffs.
Some tools show the exact approach in their own install steps. The esbuild getting started page installs the bundler with an exact save flag, for example, so the version in package.json is a fixed number.
Which to choose for build tools
Pick one rule and apply it to the whole toolchain: bundler, transpiler, linter, test runner, formatter and their plugins.
- If your team reads package.json diffs and rarely the lockfile, use exact versions for build tools. The pull request then shows a clear version change.
- If your team reviews lockfile diffs, ranges plus lockfile are fine, as long as CI uses npm ci so it never rewrites the lockfile.
- Either way, forbid plain npm install in release pipelines.
- Let a bot propose updates. Review them like code, since a build tool executes on your machines.
- Group related tool updates when you can, so one pull request moves the bundler and its plugins together and you test them together.
- Keep a note of which tools may auto update in a low risk way, such as a formatter, and which never do, such as the bundler.
What an exact pin does not do
Pinning the top level package does not pin everything below it. The bundler's own dependencies still resolve through the lockfile or, without one, through whatever is newest within their ranges. That is why the lockfile matters more than the style of range in package.json. For dependencies of dependencies, see how to fix a vulnerable transitive dependency.
What a pin does not do for security
A pin stops surprise updates. It does not make an old version safe. If an advisory lands for the version you pinned, you still need to move. Treat pins as a way to control when you change, not as a reason to stop changing. A scan on every pull request tells you when the pinned version becomes a problem. See npm caret vs tilde version ranges explained and npm ci vs npm install for supply chain safety.
Safeguard's dependency scanning and policy gates can check each pull request that changes your build tooling, and its autonomous remediation can raise fix pull requests, which are off by default. Try a free scan at gold.safeguard.sh/scan.
Quick check
Does a caret range allow a new major version?
No. Per npm's guide, a caret range accepts minor and patch releases. Major releases need a wider range.
If I commit a lockfile, do ranges still matter?
Less. With npm ci the lockfile decides the installed versions. Ranges matter when someone updates.
Sources
Last reviewed: 19 October 2026.