Every codebase has bugs that were visible in the source code the whole time: a variable assigned but never read, a comparison that coerces types in a way nobody intended, a catch block that swallows the error it was supposed to report. A linter is the tool that reads your source and points at those problems before the code ever runs — in your editor, in milliseconds, on every keystroke.
Here's the short version: a linter parses your code into a structured representation, checks it against a set of rules, and reports each violation with a location and a severity. That's the whole trick. The craft is in choosing rules that catch real problems for your team and wiring the linter into your workflow so it runs everywhere, not just on one developer's laptop.
What a linter actually does
The word comes from lint, a 1978 Unix tool by Stephen Johnson at Bell Labs that flagged suspicious constructs in C — named after the fluff a clothes dryer traps. Modern linters work on the same principle with better machinery:
- Parse. The linter reads your source files and builds an abstract syntax tree (AST) — a structured representation of the code: this is a function declaration, these are its parameters, this is a comparison inside an
ifcondition. - Apply rules. Each rule is a small program that walks the tree (and often scope and control-flow information) looking for one specific pattern — an unused variable, a
==where===was probably meant, anawaitmissing on a promise. - Report. Every match becomes a diagnostic: file, line, column, rule ID, message, and severity. Your editor underlines it; your CI job fails on it.
Because the linter never executes your code, it can check every file in the project in one pass — including branches your tests never reach. That's the core promise: cheap, exhaustive review of the things a machine can review, so human reviewers can spend their attention on design.
Linting vs. formatting vs. type checking
Three different tools get lumped together, and confusing them leads to redundant configs and turf wars between tools. The one-line split:
- Formatters (Prettier, Ruff's formatter, gofmt) decide how code looks — indentation, line breaks, quote style. They rewrite the file and have opinions, not diagnostics.
- Linters (ESLint, Ruff, golangci-lint) decide whether code follows rules — correctness patterns, banned constructs, conventions. They report violations, and can auto-fix some.
- Type checkers (TypeScript's
tsc, mypy, Pyright) verify that data types line up across your whole program — that you never pass a string where a number is required.
They overlap at the edges (linters have style rules; some lint rules use type information), but the practical guidance is clean: use a formatter for layout, a linter for rules, and a type checker for types, and turn off any lint rules that just re-litigate formatting. For the deeper tour of the whole family — including security scanners — see our guide to static analysis.
What linters catch — and what they can't
Lint rules fall into two broad families, and it pays to treat them differently.
Correctness rules catch code that is probably a bug:
- Variables or imports that are declared but never used (often the residue of a refactor that removed the use but not the cause).
if (x = 5)— assignment where a comparison was intended.- Loose equality (
==) triggering surprising type coercion. - Unreachable code after a
return, or aswitchcase falling through by accident. - Promises that are created but never awaited or handled, so failures vanish silently.
Convention rules enforce team agreements: naming patterns, import ordering, maximum function complexity, banned APIs ("don't use the legacy HTTP client"). These aren't bugs, but they're how a codebase written by twelve people reads like it was written by one.
Be honest about the limits. A linter matches patterns; it doesn't understand your business logic, prove your algorithm correct, or replace tests. A codebase can lint perfectly clean and still be wrong. Linting removes a class of defects and frees review attention — it doesn't certify quality.
Setting up your first linter
Most mainstream linters follow the same shape: install, add a config that starts from a recommended rule set, run. Two examples — one JavaScript, one Python.
ESLint (JavaScript/TypeScript), using its flat config format:
// eslint.config.js
import js from "@eslint/js";
export default [
js.configs.recommended,
{
rules: {
eqeqeq: "error",
"no-unused-vars": "warn"
}
}
];
Run it with npx eslint . — every violation prints with its file, line, and rule ID.
Ruff (Python), configured in pyproject.toml:
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "B"]
select picks rule families — here pycodestyle errors (E), pyflakes correctness checks (F), and flake8-bugbear's likely-bug patterns (B). Run ruff check . and you get the same shape of output: location, rule code, message.
The pattern generalizes: Go teams typically run golangci-lint, Rust ships Clippy with the toolchain, and most other ecosystems have an equivalent. The concepts in this guide transfer directly; only the config syntax changes.
Severities: warn vs. error
Almost every linter lets you set each rule to off, warn, or error. Use the distinction deliberately:
- Error means "this cannot merge." Reserve it for rules with near-zero false positives — real correctness issues and hard team agreements.
- Warn means "a human should glance at this." It's the right level while trialing a new rule or migrating old code.
One caution from teams who've lived this: warnings that never block anything eventually become wallpaper. A common endgame is to run CI with a zero-warnings policy (ESLint's --max-warnings 0, for example), so warn is a temporary state on the way to error or off, not a permanent third category.
Autofix: let the tool do the typing
Many rules are mechanically fixable, and linters can apply those fixes themselves — eslint --fix, ruff check --fix. Sorting imports, removing unused ones, upgrading legacy syntax: there is no reason a human should do this by hand.
Two habits keep autofix safe. First, run it via version control — fix, then review the diff like any other change. Second, know that fixers only handle rules marked safe to fix; anything requiring judgment still lands on you. In practice, autofix plus format-on-save eliminates most of the day-to-day friction people fear when they hear "we're adding a linter."
Taming the noise
The number-one reason linting fails at a team is noise: someone enables 400 rules on a five-year-old codebase, gets 12,000 warnings, and everyone learns to ignore the tool. The fix is procedural, not technological:
- Start from the recommended preset, not the full rule catalog. Presets are curated for signal.
- Change rules with a reason. Every deviation from the preset gets a one-line justification in the config or the commit message. "It annoyed me once" doesn't qualify.
- Ratchet, don't boil the ocean. On legacy code, enable rules for new/changed files first, or fix one rule across the codebase per week. The goal is a trend, not a purge.
- Watch the disable comments. An occasional
// eslint-disable-next-linewith a reason is fine; clusters of them mean the rule is miscalibrated for your codebase — fix the config, not the symptom.
A useful metric: if developers routinely merge with lint findings outstanding, your setup has failed regardless of how good the rules are.
Where linting should run
A rule that only runs on some machines isn't a standard — it's a suggestion. Mature setups run the linter in three places: the editor (instant feedback while typing), a pre-commit hook (cheap gate before code leaves the machine), and CI (the enforcement point of record on every pull request). Each layer catches what the previous one missed, and only CI is authoritative. Wiring this up properly — including keeping it fast on big repos — is its own topic; we cover it step by step in linting in CI.
FAQ
Does a formatter replace a linter?
No — they solve different problems. A formatter normalizes layout (indentation, line breaks, quotes) by rewriting files; a linter detects rule violations (probable bugs, banned patterns, conventions) and reports them. The best setups use both, with the linter's stylistic rules disabled so the two never fight over the same territory.
If I use TypeScript, do I still need a linter?
Yes. The TypeScript compiler checks types, but it happily accepts unused variables, == coercion, missing awaits on promises, and inconsistent conventions. Type checking and linting catch different defect classes, and typescript-eslint exists precisely because teams need both — some of its strongest rules actually use type information the compiler provides.
Should lint warnings fail the CI build?
Eventually, yes. Teams that let warnings accumulate find they stop being read at all. The common pattern: introduce new rules at warn to gauge noise, then either promote them to error (or enforce a zero-warnings budget in CI) or turn them off. Permanent, ignorable warnings are the worst of both worlds.
How many lint rules should we enable?
Start with your linter's recommended preset and adjust from evidence, not taste. Add stricter rules when a bug slips through that a rule would have caught; drop or downgrade rules that generate repeated false positives or disable-comments. The right number is whatever your team actually keeps at zero findings — a clean 80-rule setup beats an ignored 400-rule one.
Linting is the cheapest code review you'll ever get: exhaustive, instant, and never tired. Once you know the practice, the remaining question is which tool fits your language, codebase size, and team — and that's a decision worth making on evidence. Compare the leading linters for your language on Lintense — every comparison states its criteria, so you can see exactly why one fits before you adopt it.