writing-release-notes
Use when preparing release notes, a changelog, an upgrade guide, or a version announcement; when a release contains breaking changes, deprecations, removals, or anything a consumer must act on; when turning commit subjects into user-facing text; when an announced feature also depends on a remote flag, a server deploy, or app-store setup; or when consumers keep asking what they must change to upgrade. Also use when a note someone approved is about to be published. Covers ordering by reader impact, per-audience tagging, migration steps, deprecation timelines, checking each announced feature is live for its reader, re-checking the facts in an approved note, and what to leave out.
Overview
Release notes are read by one person with one question: what must I do, and what do I get? They are a decision aid for an upgrade, not a record of the team's work. Anything that does not move that reader toward a yes or no is a tax they pay in scanning time.
When to use
- Cutting a release of anything another team, customer, or process consumes
- The release contains a breaking change, a removal, a deprecation, or a required migration
- Turning a generated commit log into something a human can act on
- An entry announces something that also needs a flag turned on, a server deployed, or a store product configured outside the release
- Consumers are asking in support channels what an upgrade requires
- Not for: why the change was made →
writing-adrs. How to use the new feature →writing-durable-docs. Notes link to both; they do not contain them.
Two artifacts, not one
| Changelog | Release notes | |
|---|---|---|
| Produced by | Generation from commits or merged changes | A human, deliberately |
| Coverage | Complete, every change | Selective, only what affects a reader |
| Order | Chronological or by type | By impact on the reader |
| Purpose | Archive and attribution | Upgrade decision and migration |
Merging them yields either a changelog nobody can act on or notes that are incomplete and therefore untrustworthy. Ship both; the notes link to the changelog for completeness so the notes are free to omit.
Order by what it costs the reader
Strict. A reader who stops after any section has still received the most expensive thing they would have gotten.
- Breaking changes, each with its migration step. When there are none, say "No breaking changes" explicitly — its presence is the single most valuable line in the document for an upgrade decision, and its absence forces a full read.
- Required actions that are not breaking — data migration, new mandatory config, raised minimum runtime or dependency versions, a manual step during deploy.
- New capability — what the reader can now do that they could not.
- Fixes a user could have noticed. A fix to code shipped in this same release is not a fix; it is the absence of a bug and belongs nowhere.
- Deprecations, each with a removal version and date.
- Internal, performance, and dependency changes — one condensed block or a link, never interleaved with the above.
Every entry names its audience
This is where most notes fail: every reader reads every line to discover that 90% of it does not apply to them.
- Tag each entry with who it affects —
[API consumers],[self-hosted operators],[plugin authors],[CLI users]— or split the document by audience with a nav line at the top. - Target: a reader in one audience can skip to their part and read under ~20% of the document.
- An entry you cannot attribute to an audience is usually an internal change that should be omitted.
A fix line claims the reader had the bug
"Fixed: X" carries a premise nobody states: that this reader could have run into X. The commit log cannot settle it — the log records when a bug was fixed, never whether anyone outside the team was exposed to it. Only one span can, and it is the span the notes are actually for: from the build this audience is on to the build they are getting.
Two kinds of entry fail that test while looking like real fixes in the diff.
- Introduced and fixed inside the same window. The ordering list above states the easy version of this — a fix to code shipped in this same release is not a fix. The window is the reader's, though, not the release's: an audience three versions behind never met a bug that appeared and died in the two versions between. Both commits are legitimately in range, so nothing in the log marks it.
- The fix had no symptom. The platform silently ignored the malformed attribute, the wrong value was never read, the broken branch was unreachable. The repair is genuine and there was nothing for the reader to have suffered.
The cheap tell for the first: does the entry reference a feature this audience has never had? If the fix only makes sense to someone who ran a build between two of yours, it is not news to them.
Keeping such a line anyway is a legitimate call — some projects want the record complete, and a fix with no symptom can still be worth stating where a reader is auditing rather than upgrading. Running the check is what makes that a decision instead of an accident.
A capability line claims the reader can use it
"You can now X" has a premise of its own: that this reader, on the build they are getting, can do X today. The commit cannot settle this one either. A commit message describes the code, written by whoever had just watched it work, often in a development build where everything it needs is enabled by default. Whether the reader can use it may also depend on things outside the release: a remote flag or config value, a server component that has to be deployed before it accepts the new request, an app-store or billing product that has to be set up and approved, a staged rollout, a plan or region the feature is limited to. Any of them can still be off on publication day while every commit that names the feature reads as finished.
The translation table below says to omit a flagged feature until it is on, and that rule assumes you know the flag is there. The case it misses is a commit that mentions no dependency at all: the flag lives in another system, someone added it later, or the server that must accept the new request has not been upgraded.
So on the day you publish, not the day you drafted, for each new-capability entry:
- Name what it needs outside this release — the flag, the server version, the store or billing setup, the rollout, the entitlement.
- Read each one's current state where it is set: the flag service, the version the deployed server reports, the store's own console. Not the default in the repository, and not your own build. The record that says "still off" often exists already, written by whoever does the deploys, in a handoff note, a deploy log, or a rollout dashboard; one read of it is cheaper than the support thread.
- Where you can, use it from the reader's seat: the build they will install, an account on their plan, no debug or test override.
An entry you cannot observe working goes out as "coming", with a date only if you can keep it, or it does not go out. One that is on for some readers says so — "rolling out over the next two weeks", "on the paid plans". A reader who tries what the notes announced and is refused learns that the notes are not to be trusted, and every later release pays for it. A release note is a copy that describes behavior, so keeping-copies-in-sync's A copy that describes behavior is true of a build, not of a commit covers publishing it with the release; this section covers what shipping the release does not switch on by itself.
Breaking changes carry the step, not the difference
The entry contains the literal action, not a description of what is now different.
- Weak: "
parsenow returns a result type instead of throwing." - Strong: "
parsereturns a result instead of throwing. Update call sites:x = parse(s)→x = parse(s).or_default(). Find them by searchingparse(. A codemod is available at <link>."
Include, in order: the old form, the new form, how to find every affected site, and whether a mechanical fix exists. If the migration needs more than ~5 lines, the note carries a one-line statement of what breaks plus a link to a migration guide — never a bare "see the migration guide", which forces every reader to open it to find out whether they are affected.
Deprecations need a version and a date
"Deprecated" with no removal target is ignored, because it is the ambient state of everything.
- State: deprecated in X, removed no earlier than Y (version and date), and the replacement.
- If you cannot name Y, you are not committed and this is not yet a deprecation — say "we expect to replace this" instead, and do not start a clock nobody will honor.
- Repeat every live deprecation in every release's notes between deprecation and removal. A reader upgrading across five versions reads one document, and a deprecation announced three releases ago is invisible to them.
- Removal itself is a breaking change and goes in section 1, even though it was announced.
Translate commit language into reader language
| Commit says | Note says |
|---|---|
| "Refactor auth middleware" | Omit — or, if measurable, "sign-in latency down ~40ms" |
| "Fix null check in exporter" | "Exports no longer fail when a record has no owner" |
| "Bump lib to 3.2" | Omit, unless it raises a minimum version, fixes a published vulnerability, or changes behavior |
| "Add feature flag for X" | Omit until X is on for the reader |
| "Improve performance" | Quantify or omit: "cold start 2.1s → 0.9s" |
Each entry names the observable difference from outside the system, and where the effect is a number, gives the number with a baseline.
Every detail in an entry — the number and its baseline, a name, a default, a limit, a count — is a claim about the build, and an approval of the note is not a check of it. An approver usually reads a note for tone, for what is in and what is left out, and for what it promises; the details inside each line they take from whoever drafted it, often with no means to check them. A detail written from memory and then approved has still been checked by no one, and now reads as signed off. So re-derive each one from the code, configuration, or data it describes: before asking, ideally, and in any case after the yes and before the note goes out. Correct a slip without asking again, and say which details changed when you report the release; go back to the approver only when a correction changes what the line means — a feature narrower than announced, a limit that now reads as a restriction. finishing-what-you-started's Numbers and coverage claims holds the same rule for the numbers in a report.
What to omit
Internal refactors, test and CI changes, formatting, dependency bumps with no observable effect, changes behind an off flag, and fixes to unreleased code. Any entry you cannot finish without naming an internal module the reader has never heard of.
Omission is not concealment — the changelog has all of it. Padding the notes with internals trains readers to skim, and the first thing a skimming reader skips is section 1.
Common mistakes
| Symptom | Real cause |
|---|---|
| Users hit a break that "was in the notes" | It was, below 40 lines of internal changes |
| "What do I actually have to change?" in support | Differences described, migration steps never given |
| Notes are a bulleted list of commit subjects | Generated changelog shipped as notes with no human pass |
| Every release says "various fixes and improvements" | Written from the version number, not from the diff |
| Deprecation warnings ignored for a year, then removal breaks everyone | No removal version or date, and no repetition in intervening releases |
| Long notes for a patch, thin notes for a major | Length tracked commit count instead of reader impact |
| Notes assembled at tag time from memory | The user-facing line was not captured when the change was made |
| Readers on one platform read four screens that do not apply | No audience tagging |
| A fix entry nobody recognises, for a bug nobody reported | The bug was introduced and fixed between two of this audience's builds, so the log had it in range and the reader never met it |
| A published note gets a count, a name, or a limit wrong, and it had been approved | The approval was taken as a check of the facts; the approver read the wording and trusted the drafter for every detail in it |
| Readers try a feature the notes announced and are refused, or cannot find it | The entry was written from a commit describing working code, while a flag it also needed was off, its server was not yet deployed, or its store product was not set up |
Red flags
- "It's a small change, they'll figure it out"
- Writing the notes from the commit log alone, without reading the public surface
- A breaking-change entry with no code in it
- Publishing "no breaking changes" without having checked the exported interface
- An entry only comprehensible to someone who read the pull request
- Announcing a deprecation with no removal date because the team has not agreed on one
- Writing the notes after the tag is already published
- A "Fixed:" line for a bug that never had a symptom, kept because the commit was real
- A new-capability entry whose only evidence that it works is the commit message or the author's own build