How to Write Comments in Code That Actually Help
Most advice about comments in code starts in the wrong place. It treats comments like a moral good, as if every extra line of prose made the codebase safer, clearer, or easier to maintain. In practice, the opposite is often true. Stale comments mislead people, redundant comments waste attention, and comments that explain broken structure are usually a tax on everyone who reads the file later.
A better rule is blunt: a comment has to earn its line. The code should already say the obvious parts, because developers can read syntax. The comment is only valuable when it carries intent, constraint, trade-off, or historical context that the code itself cannot express cleanly. That's the standard I use when reviewing pull requests, and it's the standard that keeps comments from rotting into fiction.
Why Most Comments in Code Are a Problem
Comment density varies wildly across codebases, which is exactly why blanket advice fails. A major empirical study of open-source repositories found an average comment density of about 18.7%, roughly one comment line for every five code lines, while related work on active projects called about 20% a practical sweet spot, not a universal target (Berkeley EECS technical report). Another large-scale dataset found an average comment density of 0.2124, with values ranging from 0.003 to 1.2691, which means some code is almost uncommented while other code is heavily documented (comment study).
That spread matters because the failure modes are predictable. Some comments restate the code, so they add noise instead of signal. Some comments lie because nobody updated them after the code changed. Some comments exist to defend a messy design that should have been refactored instead.
The three bad comment patterns
Practical rule: if a comment can be deleted without losing information, it shouldn't be there.
The first bad pattern is the obvious restatement. If the code says if (user.isAdmin()), a comment that says “check if user is admin” is just duplicated text in a different font. It doesn't help a maintainer move faster, it just asks them to read the same idea twice.
The second bad pattern is the stale comment. Mainstream guidance is consistent that comments should explain why, stay brief, and be updated when code changes, because duplicated or outdated comments can be worse than none (Stack Overflow best practices). I've seen more than one production bug where the code was right and the comment was wrong, and the wrong comment was what slowed down the fix.
The third bad pattern is comment-as-excuse. That's the line that says, in effect, “don't look at this function too hard, it's complicated.” The better move is to simplify the function, split the branch, or rename the thing so the code carries its own meaning.
What comments are actually for
Comments are a maintenance cost, not a virtue signal. They pay off when they preserve information that can't be recovered quickly from the code or from a nearby type name. The moment a comment starts repeating identifiers, narrating control flow, or apologizing for poor structure, it's no longer helping.
A useful way to think about it is this. The code should handle mechanics. The comment should explain intent, constraint, or exception. If it can't do one of those jobs, delete it.
The Decision Rule for When to Comment
Use one decision rule every time you reach for // or #: comment only when the code cannot speak for itself. That sounds simple, but it becomes practical when you ask three questions before you type a word.
A quick test before you comment
- Does this explain intent a reader can't infer from the code?
- Does this capture a constraint, trade-off, or external rule?
- If I delete this, would the next maintainer need commit history or a wiki page to understand the choice?
If the answer is no to all three, don't comment. Refactor instead. Rename the function. Split the branch. Pull the weird constant into a named symbol. The better the code reads, the less explanatory prose it needs.
Here's the difference in practice.
# Bad, it restates the code.
def process():
# loop through items and skip invalid ones
for item in items:
if not item.valid:
continue
handle(item)
A better version removes the narration and gives the function a name that does the work.
def process_valid_items():
for item in items:
if not item.valid:
continue
handle(item)
Now the function name carries the obvious intent. No comment needed.
When the comment should stay
# External API rejects bursts above 10 requests, even on retries.
def sync_batch(batch):
...
That comment earns its place because the rule isn't visible in the function body. A maintainer could read the code and still miss the fact that the limit comes from a third-party system. The comment protects against a future cleanup that “simplifies” the throttling away.
A good comment often does one of three things: it explains a non-obvious business rule, warns about a constraint that lives outside the code, or records a reason that would otherwise disappear into the commit history. That's it. Anything else is probably code smell.

Keep comments close to the code they explain. If the reader has to scroll, the comment is already weaker.
What Good Comments Look Like Across Languages
The best comments are small, local, and specific to the thing they protect. They don't read like tutorials. They read like reminders from the engineer who found the trap first.
Python, JavaScript, Go, and SQL examples
In Python, a good comment might explain a rate-limit workaround that the code alone can't justify:
# Cache the token for 55 seconds because the upstream rate limit resets on the minute.
token = fetch_token()
That comment is useful because it gives the constraint, not the obvious action.
In JavaScript, a guard can look redundant until you know the API quirk:
// Keep the empty-body check, this webhook sometimes returns 204 with a null payload.
if (!response.body) return null;
The code looks like a defensive habit. The comment tells future readers it's a real compatibility issue.
In Go, an intentionally named panic deserves a short explanation:
// panic is deliberate here, this path should be unreachable after config validation.
panic("unreachable")
That tells reviewers this isn't lazy error handling. It's a hard stop for a state that shouldn't exist.
In SQL, comments often matter around window functions because the intent can be easy to lose in dense syntax:
, Use ROW_NUMBER here so only the latest status per account survives the join.
SELECT ...
The comment doesn't repeat the query. It explains why the query is shaped that way.
| Comment Type | What It Captures | Example Snippet |
|---|---|---|
| Constraint comment | External limits, vendor quirks, or rate caps | # Cache the token for 55 seconds because the upstream rate limit resets on the minute. |
| Intent comment | Why a branch, guard, or panic exists | // Keep the empty-body check, this webhook sometimes returns 204 with a null payload. |
| Design comment | Why the shape of the code matters | // Use ROW_NUMBER here so only the latest status per account survives the join. |
The pattern is stable across languages. Good comments are never there to narrate syntax. They exist to preserve meaning that would otherwise vanish when the code is skimmed six months later.
Docstrings, Headers, and Style Conventions That Scale
Line comments are only one surface. Teams that write maintainable code usually decide which documentation layers deserve attention and which ones are ceremony. That's where docstrings, godoc, JSDoc, and module headers come in.
Use the lightest useful documentation surface
Public APIs deserve short, intent-focused docstrings. Internal helper functions usually don't. If a function is trivial and self-explanatory, a one-line docstring is enough. If it's exported or non-trivial, write a fuller description that explains behavior, constraints, and edge cases without repeating the signature.
Module-level comments only make sense when the file has a non-obvious purpose. A header that restates the filename is noise. Banner separators are usually noise too. They look tidy in review, then become visual clutter in a month.
Style consistency matters more than style variety. Pick one docstring convention per language and stick to it. Mixed formats create friction, especially in repos where multiple teams touch the same files.
For teams that keep a markdown knowledge base alongside code, a single written convention is easier to maintain than scattered prose. A practical reference point is a markdown knowledge base pattern that keeps long-form context in one place and leaves code comments for local intent.
A Monday-morning convention set
- One-line docstring for trivial functions that need a short purpose statement.
- Full docstring for exported or non-trivial functions that carry rules, side effects, or assumptions.
- No banner-style separators or decorative comment blocks.
- No file headers that repeat the filename or the obvious module name.
- One docstring style per language so reviews don't waste time on format drift.
If the documentation surface doesn't match the audience, it becomes dead weight. Keep the long explanation where it belongs and the code comment where it's needed.
The point isn't to document everything. The point is to make the right facts easy to find without turning the repo into a wall of prose.
Comments in AI-Generated and AI-Assisted Code
AI changes the calculus, but not the core rule. Generated code often compiles, passes tests, and still says nothing about why it exists. That's where comments become more important, not less, because the model can produce syntax without preserving durable human context.
The missing context is usually boring in the best possible way: a business rule, a compliance constraint, a historical outage, or a workaround for a system nobody wants to touch twice. An assistant can infer patterns from code. It can't reliably recover the reason a line exists after three more tools and two more people have touched it.
That's why the “explain the why” advice is only half the job now. The other half is deciding which human facts need to outlive the assistant that wrote the code.
What a generated function misses
def build_invoice_payload(order):
payload = {
"customer_id": order.customer_id,
"amount": order.total,
"currency": order.currency,
}
return payload
This is fine as code. It's also context-poor. A future maintainer can see what it does, but not why those fields are frozen in that shape or which downstream assumption depends on them.
Add one sentence, and the code becomes much more useful.
# Keep this shape stable because the billing service rejects renamed fields during month-end close.
def build_invoice_payload(order):
payload = {
"customer_id": order.customer_id,
"amount": order.total,
"currency": order.currency,
}
return payload
That one line changes the maintenance cost. It tells the next engineer not to “clean up” the object shape just because the code looks simple.
A good mental model is that AI can draft comments, but humans own the comments that carry context across tools and time. That distinction matters even more in team environments where code moves between IDEs, review systems, and assistants. The durable comment is the one a reviewer, an incident responder, or a later AI tool can trust without reconstructing the whole backstory.
Reducing hallucinations in LLM workflows becomes easier when the source of truth stays explicit. Comments are part of that source when they record constraints the model can't safely infer.
Keeping Comments Accurate as Code Changes
Comment maintenance should be treated like code maintenance. If the code changes and the explanation doesn't, somebody has created misinformation. That's not a nit, it's a bug.
A lightweight lifecycle that actually works
The simplest rule is the one many teams skip: every code change either updates the comment or explicitly leaves it alone. If a reviewer can't tell which one happened, the comment probably needs another look. Stale comments should be filed as bugs, not brushed aside because they're “just docs.”
A workable team habit is to keep the comment and the code change in the same commit, or in paired commits on the same day when the edit is large. That keeps the explanation close to the actual change and makes drift easier to spot during review. It also makes rollback less painful because the rationale and the implementation move together.
Who owns the comment
Ownership should stay with the person changing the code first, then with the reviewer. The author updates the explanation when they know it's stale. The reviewer blocks merges when a comment now contradicts the code or when the code has become readable enough to drop the comment entirely.
A practical review checklist looks like this:
- Check whether the comment still matches the branch or guard it describes.
- Remove comments that only restate the code.
- Rewrite comments that mention numbers, endpoints, or rules that no longer exist.
- Allow comment-only PRs when the code changed elsewhere and the explanation needs to catch up.
The reason this matters at scale is simple. Research on comment quality has spent a lot of time on classification and assessment, but much less on the lifecycle management teams need to keep comments synchronized with fast-moving codebases (comment quality survey). The result is that teams invent their own maintenance rules, usually after they've already been burned by drift.
For broader engineering teams, this also belongs in the same bucket as knowledge management hygiene, not separate from it. A comment that describes current behavior is part of operational truth, and a stale one is part of operational debt. The more often teams touch the same file, the more important that distinction becomes.
Best practices for knowledge management apply here because comments are tiny knowledge assets. If nobody owns them, they decay.
Linters and Editor Tools That Catch Bad Comments
Good tooling won't write comments for you, but it can catch the easy failures before they ship. That's the right goal. You want automation that flags stale, missing, or low-value comments without turning review into a rules dispute.
Pick tools by the failure they catch
Stale-comment detectors are the most valuable place to start. They look for cases where documentation and code have drifted apart, which is exactly how comments turn into lies. Coverage or ratio linters can also help by flagging files that are either comment-starved or bloated with commentary, though the threshold needs to be team-specific.
Docstring validators are useful for public APIs because they enforce presence and format without debating style. Editor plugins help with old TODO or FIXME notes, which are often forgotten long after the original author moved on. The key is not to install everything. It's to install one tool per failure mode.
Too many linters make people ignore all linters. Keep the set small enough that the team can learn the rules and trust the output.
A sane default setup is usually enough:
- One linter for public documentation coverage on exported functions or modules.
- One detector for stale comments or doc drift against changed code.
- One in-editor warning for aging TODOs or FIXMEs so forgotten notes don't pile up.
If you want a practical checklist for your repo this week, use this:
- Run the docstring or comment coverage tool on public APIs.
- Search for TODOs and FIXMEs older than the current sprint or release cycle.
- Sample a few recent diffs and verify comments changed with the code.
- Delete comments that only restate obvious control flow.
- Keep only the comments that explain intent, constraint, or trade-off.
A good comment system is less about volume and more about signal. The code should stay readable on its own, and the comments should carry the parts of the story that code can't reasonably hold.
If you're building AI workflows that need durable context, Geode gives you a tool-agnostic vault beneath the assistants, so the useful intent doesn't get lost when models or editors change. Read the docs, connect your assistant to a vault, and see how a single source of truth can keep comments, context, and tools aligned at Geode.