npm, semver and lock files
Manage dependencies on purpose with semantic versions and ranges, lock files and npm ci, package scripts and node --run, npx, workspaces, and the exports and imports fields of package.json.
What you will learn
- Read a semantic version and the ranges ^, ~ and exact in package.json
- Explain what package-lock.json records and when to use npm ci instead of npm install
- Run package scripts with npm run and with node --run, and know when npx is the right tool
- Control what a package exposes with the exports field, and name internal modules with imports
- Describe what workspaces do and when a monorepo needs them
On this page
Almost every JavaScript project is built from other people’s code. npm downloads it (it ships with Node) from the npm registry. The beginner lesson on package.json added one dependency. This lesson does it deliberately: what version numbers promise, what the lock file protects you from, and how a package decides what others may import.
Listings that need the network (npm install, npx) are pasted from real runs on 17 October 2026 with npm 11.20.0 on Node 26.11.1, so your numbers will differ. Listings that need only Node are checked on Node 22, 24 and 26.
Semantic versions
Package versions follow semantic versioning (semver): three numbers, MAJOR.MINOR.PATCH, for example 2.4.1.
- PATCH goes up for bug fixes that change no behaviour you could depend on.
- MINOR goes up when features are added in a compatible way.
- MAJOR goes up when something may break existing users.
It is a promise by the author, only as good as their discipline. The parts are compared as numbers: 1.10.0 is newer than 1.9.0, although as text "1.10.0" sorts first. A hyphen, as in 2.0.0-beta.1, marks a prerelease: it comes before 2.0.0 and ordinary ranges do not match it.
Ranges in package.json
The dependencies of a package.json do not name one version. They name a range of acceptable versions:
| Range | Means | Matches 1.2.3, 1.9.0, 2.0.0? |
|---|---|---|
1.2.3 |
exactly this version | only 1.2.3 |
^1.2.3 |
>=1.2.3 <2.0.0: anything compatible |
1.2.3 and 1.9.0 |
~1.2.3 |
>=1.2.3 <1.3.0: patches only |
1.2.3 |
>=1.2.3 <2.0.0 |
an explicit interval | 1.2.3 and 1.9.0 |
1.x |
any 1.*.* |
1.2.3 and 1.9.0 |
* |
any version | all three |
The caret ^ is what npm install some-package writes by default. Below 1.0.0 the rule is stricter, because anything may change in 0.x software: ^0.2.3 means >=0.2.3 <0.3.0, and ^0.0.3 means exactly 0.0.3.
npm decides with the semver package, which has a command-line tool that prints the versions that fit a range. Run it with npx, which downloads a package’s command on first use.
$ npx semver -r '^1.2.3' 0.0.3 1.2.3 1.3.0 1.9.0 2.0.01.2.31.3.01.9.0$ npx semver -r '~1.2.3' 0.0.3 1.2.3 1.2.9 1.3.0 2.0.01.2.31.2.9$ npx semver -r '^0.2.3' 0.0.3 0.2.9 0.3.0 1.2.30.2.9$ npx semver -r '^0.0.3' 0.0.3 0.0.4 0.2.90.0.3$ npx semver -r '^2.0.0' 2.0.0-beta.1 2.0.0 2.1.02.0.02.1.0$ npx semver 1.10.0 1.9.0 1.2.31.2.31.9.01.10.0Without -r the tool sorts, and 1.9.0 comes before 1.10.0. The beta did not match ^2.0.0. (semver 7.8.5.)
Installing, locking and updating
npm install reads the ranges in package.json, picks the newest matching version of every package (and of the packages those need), puts them in node_modules, and writes the choice into package-lock.json. In a fresh folder:
$ npm init -y$ npm install is-odd@^3.0.0
added 2 packages, and audited 3 packages in 534ms
found 0 vulnerabilitiesnpm init -y wrote this package.json, and npm install added the last four lines:
{ "name": "proj", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "type": "commonjs", "dependencies": { "is-odd": "^3.0.1" }}The range is the newest version at the time, with a caret. The lock file records what was installed, where it came from and an integrity hash:
"node_modules/is-odd": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz", "integrity": "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==", "license": "MIT", "dependencies": { "is-number": "^6.0.0" },So package.json states what you will accept, a range, and package-lock.json what you got, an exact tree. Commit both: the lock file makes every install the same, instead of whatever is newest that day.
npm installfollows the ranges and may change the lock file.npm ci(clean install) deletesnode_modulesand installs exactly what the lock file says. If the two files disagree it stops and changes nothing. Use it in CI and in deployments.
That is the safety net. Change the range to ^2.0.0 without updating the lock file and npm ci refuses. This error block is shortened: the real message goes on to print npm’s usage text.
$ npm cinpm error code EUSAGEnpm errornpm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.npm errornpm error Invalid: lock file's is-odd@3.0.1 does not satisfy is-odd@2.0.0npm error Invalid: lock file's is-number@6.0.0 does not satisfy is-number@4.0.0npm errornpm error Clean install a project(shortened: npm then prints its usage text)Other commands: npm outdated (installed, wanted, latest), npm update (newest versions the ranges allow), npm ls (the tree), npm audit (known vulnerabilities), npm uninstall name. Use npm install --save-dev name (-D) for tools only development needs, such as a linter; they go in devDependencies.
Scripts
The "scripts" field names commands for the project, so every contributor has the same few words: npm test, npm start, npm run build. Here is a complete package.json with two scripts:
{ "name": "np-scripts", "version": "1.0.0", "scripts": { "hello": "node -p \"'hello from a script'\"", "show": "node -p \"process.argv.slice(1).join(' ')\"" }}npm run hello runs one. Node can run them itself with node --run, available from Node 22.0 (from 22.3 it also finds a package.json in a parent folder). It starts faster than npm run, but is deliberately smaller: no pre and post scripts, no npm environment variables, and --env-file variables are not passed to the script. Arguments after -- are appended to the command.
$ node --run hellohello from a script$ node --run show -- one twoone twonpx
npx runs a command that a package provides. It looks in your project’s node_modules/.bin first; otherwise it downloads the package into a cache and runs it, as with semver above. Running a name you do not know means running someone else’s code with your permissions, so check it: typos of popular package names are a known trick.
What a package exposes: exports
Without configuration, import "greeter/src/internal.js" reaches every file inside an installed package, and people will, which freezes your file layout forever. The exports field lists the public paths; nothing else can be imported from outside. The imports field names private shortcuts inside your own package; names start with #, and * stands for any text.
A package with exports can import itself by name, the easiest way to try this without installing anything; other packages see the same map. The folder np_demo:
np_demo/ package.json app.mjs lib/util.js src/main.js src/extra.js src/internal.js src/dual.mjs src/dual.cjssrc/main.js exports greet, src/extra.js exports shout, lib/util.js exports twice, src/internal.js exports secret, and the two dual files each export a string, "esm build" and "cjs build". The map makes three of these public:
{ "name": "np-demo", "version": "1.0.0", "type": "module", "exports": { ".": "./src/main.js", "./extra": "./src/extra.js", "./dual": { "import": "./src/dual.mjs", "require": "./src/dual.cjs" } }, "imports": { "#lib/*": "./lib/*.js" }}An entry can depend on how the package is loaded: "./dual" has one file for import and one for require, as a package ships ES module and CommonJS builds (why both exist). The app uses the public names and the private #lib/util, then reaches for a file that is not exported.
import { createRequire } from "node:module";import { greet } from "np-demo";import { shout } from "np-demo/extra";import dual from "np-demo/dual";import { twice } from "#lib/util";
console.log(greet("Ada"), shout("hi"), twice(21));console.log(dual, "|", createRequire(import.meta.url)("np-demo/dual"));await import("np-demo/src/internal.js").catch((e) => console.log(e.code));$ node app.mjsHello, Ada! HI 42esm build | cjs buildERR_PACKAGE_PATH_NOT_EXPORTEDThe public names came through exports, with different files for import and require; #lib/util came through imports; and np-demo/src/internal.js was refused with ERR_PACKAGE_PATH_NOT_EXPORTED, although the file exists.
Workspaces
A monorepo keeps several packages in one repository, such as a library and its app. With the workspaces field in the root package.json, npm installs all their dependencies at once and links packages from the same repository into node_modules instead of downloading them.
{ "name": "root", "private": true, "workspaces": ["packages/*"]}If b lists a in its dependencies, one npm install at the root links both to their folders, so import "a" in b sees the current source of a with no publishing step. npm run build --workspaces --if-present runs a script in every workspace that defines it.
Common pitfalls
- Not committing
package-lock.json. Every install can then produce a different tree. - Using
npm installin CI. Usenpm ci, which fails when the files disagree. - Editing or committing
node_modules. It is generated; add it to.gitignore. - Putting a test tool in
dependencies. Users of your package download it too. UsedevDependencies. - Comparing versions as strings.
"1.10.0" > "1.9.0"isfalse. Use a semver library. - Expecting
^0.xto float like^1.x. For 0.x versions the caret allows far less. - Installing packages without looking. Every dependency is code you did not write. Check what a package pulls in with
npm ls. - Importing
package/dist/internal.jsfrom a package withoutexports. It may vanish in the next release.
Exercises
Make a folder with a package.json that holds only a name and the version 1.2.3. Use the real tool to bump the version: npm version patch, then minor, then major, each with --no-git-tag-version (so that npm does not need a git repository). Then read the version back with npm pkg get version. Predict the five outputs first: a minor bump resets the patch to 0, and a major bump resets the minor and the patch.
{ "name": "bump-demo", "version": "1.2.3"}Expected output (npm 11.20.0, 17 October 2026):
$ npm version patch --no-git-tag-versionv1.2.4$ npm version minor --no-git-tag-versionv1.3.0$ npm version major --no-git-tag-versionv2.0.0$ npm pkg get version"2.0.0"Show solution
Nothing to write: the exercise is to run the three commands in order. Each bump starts from the result of the previous one, so minor turns 1.2.4 into 1.3.0 and major turns 1.3.0 into 2.0.0. npm version also rewrites package.json, which now holds "version": "2.0.0". Without --no-git-tag-version, npm would also commit and tag in a git repository.
Make a folder mathy with the package.json below (it has no exports yet), the files lib/add.js and lib/mul.js, and an app.mjs that imports mathy/add and mathy/mul. Add an exports field to package.json so that every name mathy/NAME maps to ./lib/NAME.js. Then node app.mjs must print the sum and the product, and the failed import of mathy/missing must print its error code.
{ "name": "mathy", "version": "1.0.0", "type": "module"}export default (a, b) => a + b;// lib/mul.jsexport default (a, b) => a * b;import add from "mathy/add";import mul from "mathy/mul";
console.log(add(2, 3), mul(2, 3));await import("mathy/missing").catch((e) => console.log(e.code));Expected output:
$ node app.mjs5 6ERR_MODULE_NOT_FOUNDShow solution
A package can import itself by name only when it has an exports field, so the app fails until you add one. A * in an exports key stands for any subpath, and the same text replaces the * in the target. A name that matches the pattern but whose file does not exist fails with ERR_MODULE_NOT_FOUND, which differs from ERR_PACKAGE_PATH_NOT_EXPORTED for a name the map does not offer at all.
{ "name": "mathy", "version": "1.0.0", "type": "module", "exports": { "./*": "./lib/*.js" }}For the versions 0.9.0 1.0.0 1.4.9 1.5.0 2.0.0 3.1.0, predict which ones each range accepts, then check your answer with npx semver -r RANGE VERSIONS... (quote the range). The ranges are 1.x, >=1.2.3 <1.5.0 and ^1.2.3 || ^3.0.0. A || joins alternatives.
Expected output (semver 7.8.5, 17 October 2026):
$ npx semver -r '1.x' 0.9.0 1.0.0 1.4.9 1.5.0 2.0.0 3.1.01.0.01.4.91.5.0$ npx semver -r '>=1.2.3 <1.5.0' 0.9.0 1.0.0 1.4.9 1.5.0 2.0.0 3.1.01.4.9$ npx semver -r '^1.2.3 || ^3.0.0' 0.9.0 1.0.0 1.4.9 1.5.0 2.0.0 3.1.01.4.91.5.03.1.0Show solution
1.x accepts every 1.*.*. The interval has an exclusive upper bound, so 1.5.0 is out. In the third range ^1.2.3 accepts 1.4.9 and 1.5.0 (but not 1.0.0, which is below 1.2.3) and ^3.0.0 accepts 3.1.0. Nothing in these ranges accepts 0.9.0 or 2.0.0. If a result surprises you, npx semver is the quickest way to find out what a range means.
Summary
- Versions are
MAJOR.MINOR.PATCHand compared number by number.^1.2.3allows>=1.2.3 <2.0.0,~1.2.3allows>=1.2.3 <1.3.0, and^0.xis narrower.npx semver -r RANGE VERSIONS...shows what a range accepts. package.jsonholds the ranges you accept.package-lock.jsonholds the exact tree you got, with integrity hashes. Commit both.npm installfollows ranges and may update the lock file.npm ciinstalls exactly what the lock file says and fails if the two disagree: use it in CI.npm runandnode --run(Node 22.0 and later; no pre/post scripts) run the scripts.npxruns a package’s command.exportslists what a package makes public, with conditions such asimportandrequireand patterns with*.importsdefines private#namesinside your own package.workspaceslink several packages of one repository together.
That completes the Node essentials. Next module: testing and tooling, starting with Node’s built-in test runner.