Modules, packages, and the toolchain · Lesson 63 of 77 · concept
Versions and lockfiles
About 45 minutes.
By the end of this lesson you can
- Read a version like 4.1.0 and say what each of the three numbers promises.
- Say which upgrades ^1.4.2 and ~1.4.2 permit, and how they differ below version 1.0.0.
- Explain what a lockfile records and why it is committed to the repository.
- Choose between npm install and npm ci for a given situation, and justify it.
Read, not run. The sandbox on this site has no modules, the DOM, the network and the filesystem, so the examples in this lesson are shown rather than executed. Where an example cannot run, it says so and explains why instead of leaving you to find out.
Why a number needs rules at all
Why it matters
Your project depends on someone else's code, and that code keeps changing. Every change is a small gamble: does the new release fix a bug you had, or break something you relied on? Without a convention you would have to read the changelog of every package on every update, which nobody does.
Semantic versioning is the convention that turns the version number into a promise about that risk. The npm registry documents it, most packages follow it, and once you can read it you can tell at a glance whether an upgrade is a routine bump or something to schedule an afternoon for.
The promise is only as good as its author's discipline - a package can break you in a patch release by accident. Semver is a strong convention with human beings behind it, not a guarantee enforced by machinery. Which is precisely why the second half of this lesson exists.
major.minor.patch
Explanation
A version is three numbers separated by dots: 4.1.0 is major 4, minor 1, patch 0. Each position answers a different question about what changed.
A patch release increments the last digit and means backwards-compatible bug fixes: code that worked before still works. A minor release increments the middle digit and resets the last to zero, and means backwards-compatible new features: things were added, nothing you were using was taken away. A major release increments the first digit and resets the other two, and means breaking changes: something you might be relying on has changed or gone.
So 4.1.0 to 4.1.3 should be invisible to you. 4.1.0 to 4.4.0 should be safe but brings new capability. 4.1.0 to 5.0.0 is the one that needs reading and testing. Nothing about the size of the number tells you about quality or maturity - a package at 47.0.0 has simply broken compatibility 47 times, which may just mean it is honest about it.
The caret and the tilde: what you actually write down
Explanation
The versions in a package.json are usually not exact. "date-fns": "^4.1.0" is a range, and it means "at least 4.1.0, and any later release that does not change the major number" - so 4.9.2 is allowed in, 5.0.0 is not. The caret is npm's default when you install something, and it is a bet on the author keeping the semver promise for minor and patch releases.
The tilde is stricter. "~4.1.0" allows patch releases only: 4.1.9 yes, 4.2.0 no. Use it when you want bug fixes without new surface area. An exact "4.1.0" with no symbol allows nothing at all - the strictest choice, and occasionally the right one for a dependency that has burned you.
There is one edge case that catches everyone, and it is not an exception so much as the rule stated properly. The caret allows changes that do not modify the left-most non-zero number. Above 1.0.0 that number is the major, so ^4.1.0 permits minor and patch updates. Below 1.0.0 the left-most non-zero number is the minor: ^0.2.5 permits 0.2.x but NOT 0.3.0, because a 0.x package is announcing that its shape is still moving and a minor bump may well break you.
npm Docs — About semantic versioning
Check there: The patch / minor / major table (backwards-compatible bug fixes, backwards-compatible new features, breaking changes) and that the caret allows minor-level updates while the tilde allows patch-level updates.
A range means two machines can install different code
A mental model
Sit with what a range implies. Your package.json says ^4.1.0. You installed in March and got 4.1.0. A colleague clones the repository in June, runs npm install, and gets 4.6.2 - also inside the range, also correct, and not the same code you have been testing against. Neither of you did anything wrong, and yet you are now running different programs.
Multiply that by every package your packages depend on, and "works on my machine" stops being a joke and becomes an accurate description of the situation. The ranges are doing exactly what you asked; the problem is that what you asked for was a set of acceptable versions, not a specific program.
The fix is to record the answer as well as the question. package.json holds the question - which versions would be acceptable. A second file holds the answer - which versions were actually chosen last time, all the way down the tree.
The lockfile, and why it is in the repository
Explanation
npm writes package-lock.json whenever an install changes node_modules or package.json. Its own documentation describes it as a description of the exact tree that was generated, so that later installs can produce an identical tree regardless of what has been published since.
That is why it is committed to source control rather than ignored. The npm docs give the reasons plainly: teammates, deployments and continuous integration all install exactly the same dependencies; you can travel back to a previous state of node_modules without committing the folder itself; and changes to the tree show up as a readable diff, so a review can see that a dependency changed.
It also records exactly where each package came from and an integrity hash for its contents, which is what turns "the same version number" into "the same bytes". A version number can be re-published; a hash cannot be argued with.
Check there: package-lock.json describes the exact tree generated so that later installs produce an identical tree, and it is committed so teammates, deployments and CI install exactly the same dependencies.
npm install and npm ci are for different jobs
Explanation
npm install is the working command. It reads package.json, may resolve a range to something newer, updates node_modules and writes the lockfile to match. Use it when you are adding, removing or deliberately updating a dependency.
npm ci is the reproducing command. It requires a lockfile to exist, removes node_modules first if it is there, and installs exactly what the lockfile says. If the lockfile and package.json disagree, it stops with an error instead of quietly resolving the difference. It also cannot install a single package - it is all or nothing, by design.
The rule that follows: continuous integration, deployments and any "why does this only fail on the server" investigation want npm ci, because it is the only one of the two that promises you the same tree twice. Day-to-day development wants npm install. If npm ci fails complaining that the two files are out of sync, the fix is to run npm install locally and commit the updated lockfile - not to delete it.
Check there: npm ci requires an existing package-lock.json or npm-shrinkwrap.json, removes node_modules before installing, errors instead of updating the lockfile when it disagrees with package.json, and cannot install individual dependencies.
Three habits that cause bad afternoons
A common mistake
Deleting package-lock.json to fix an install problem. This is the single most expensive reflex in this lesson. It does often clear the immediate error - by throwing away the record of every version that was known to work, and re-resolving the entire tree to whatever is newest today. If you must reset, delete node_modules and run npm ci, which keeps the record.
Resolving a lockfile merge conflict by hand. It is a generated file, and hand-editing it produces a tree that never existed anywhere. Take one side, run npm install, and commit what npm writes.
Reading a caret as a safety guarantee. It is a promise made by a human being about their own future releases. Most keep it; some do not. That is not an argument for pinning everything to exact versions - you would never get a security fix - it is an argument for the lockfile plus tests you trust, which is the subject of the next module.
Recap
Recap
major.minor.patch: breaking, additive, fixed. A caret allows anything that does not change the left-most non-zero number, so ^4.1.0 permits 4.x and ^0.2.5 permits 0.2.x only; a tilde allows patch releases; a bare number allows nothing. Ranges mean two machines can legitimately install different code, so the lockfile records the exact tree that was chosen, with integrity hashes, and is committed. npm install may update that record; npm ci reproduces it exactly and refuses to paper over a mismatch.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
The caret rule, written as a function
The clearest way to check you have understood a rule is to implement it. This models the caret for versions at 1.0.0 and above: same major, and not older than what you asked for. Read the three tests underneath as the three questions you would ask npm.
1function parseVersion(text) {2 const parts = text.split(".");3 return { major: Number(parts[0]), minor: Number(parts[1]), patch: Number(parts[2]) };4}5 6function caretAllows(installed, candidate) {7 const now = parseVersion(installed);8 const next = parseVersion(candidate);9 10 if (next.major !== now.major) return false;11 if (next.minor !== now.minor) return next.minor > now.minor;12 return next.patch >= now.patch;13}14 15console.log(caretAllows("1.4.2", "1.4.9"));16console.log(caretAllows("1.4.2", "1.9.0"));17console.log(caretAllows("1.4.2", "2.0.0"));18console.log(caretAllows("1.4.2", "1.4.1"));- line 2
A version is text until you take it apart. split gives three strings; Number turns each into a number so they can be compared as quantities rather than alphabetically.
- line 10
The major must match. This one line is the whole promise of the caret: a breaking release is never accepted automatically.
- line 11
A newer minor is fine, an older one is not. Ranges only ever move forwards.
- line 18
false, because 1.4.1 is older than the 1.4.2 you asked for. A range has a floor as well as a ceiling.
What it prints
truetruefalsefalse
What a lockfile actually records
One package's entry from a package-lock.json, trimmed to the fields worth understanding. Compare it with the range in package.json: the manifest said what would be acceptable, and this says what was chosen.
1{2 "name": "kettle-timer",3 "lockfileVersion": 3,4 "packages": {5 "node_modules/date-fns": {6 "version": "4.1.0",7 "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",8 "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",9 "license": "MIT"10 }11 }12}- line 6
One exact version, not a range. package.json said ^4.1.0; this is the version that request actually resolved to.
- line 7
Where it came from. Not every package comes from the public registry, and the lockfile is where you can see that.
- line 8
The integrity hash - a fingerprint of the file's contents. A later install that fetches different bytes under the same version number fails instead of proceeding.
- line 5
The key is the path inside node_modules, which is how the file can describe the whole tree, including two versions of one package living at different depths.
This one does not run herepackage-lock.json is generated JSON data, not a program, and the sandbox has no file system or installer. It is here to be read: the point is the shape of what is recorded, not any code you could run.
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Your package.json says "lodash": "^4.17.21". Which of these can an install give you?
A version string is taken apart into numbers. Predict all three lines - the second is arithmetic on the parts, not string joining.
function parseVersion(text) { const parts = text.split("."); return { major: Number(parts[0]), minor: Number(parts[1]), patch: Number(parts[2]) };}const installed = parseVersion("2.7.14");console.log(installed.major);console.log(installed.minor + installed.patch);console.log(installed.major === Number("2"));
A deployment pipeline builds your app on a fresh machine. Which command should it run, and why?
No exercise in this lesson
There is no npm, no registry and no file system in the sandbox, so nothing here can be installed, locked or reproduced in a graded run. The rule that can be modelled in code - what a caret range accepts - is the runnable worked example above, and the checks test the rest.
Worth remembering
- What do the three numbers in 4.1.0 promise?
Major: breaking changes. Minor: backwards-compatible additions. Patch: backwards-compatible fixes. Only a major bump is expected to break you.
- State the caret rule in one sentence, including the 0.x case.
It allows changes that do not modify the left-most non-zero number: ^4.1.0 permits 4.x, ^0.2.5 permits 0.2.x only.
- package.json says ^4.1.0. What does the lockfile add?
The exact version chosen, where it came from and an integrity hash - so every machine installs the identical tree.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
npm Docs — About semantic versioning
Check there: The three-part version number and its patch / minor / major meanings, and that ^1.0.4 permits minor-level updates while ~1.0.4 permits patch-level updates only.
Check there: The lockfile describes the exact generated tree so later installs are identical, and the stated reasons for committing it.
Check there: The caret range allows changes that do not modify the left-most non-zero element of the major.minor.patch tuple, which is why ^0.2.5 does not permit 0.3.0.
Check there: What npm ci requires, what it deletes, and when it refuses to run.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown