The worst review comment I have ever left was about a quote character. Single versus double, on one line, in a pull request that changed how a call got routed. I caught myself typing it and deleted it, and then I went and set up the formatter properly, because the comment was not wrong. It was just not my job.
Formatting is a class of decision where the outcome matters much less than the fact that a decision exists and gets applied everywhere without a human in the loop. That is the definition of work a tool should own. Below is the setup I actually run, in this site's repo and in every other repo I maintain, and the three rules behind it.
One formatter per language, and it wins
Every repo I own has exactly one formatter per language, its config committed, and nothing that lets an individual file opt out of it. For JavaScript and TypeScript that is Prettier. For Python it is ruff format. For JSON, YAML, CSS and Markdown, Prettier again, because one tool covering six file types is worth more than the best tool for each.
The corollary matters more than the choice: the formatter owns whitespace, and the linter owns correctness. They never overlap. ESLint in my repos runs Next.js core web vitals rules and nothing stylistic. Ruff's lint pass runs pycodestyle, unused imports, naming and import ordering, and none of the rules its formatter documents as conflicting, because two tools with opinions about the same character position is how you get a commit that oscillates forever.
Pin the version, or the formatter becomes a merge conflict generator
prettier: 3.9.6 in my devDependencies, no caret. Ruff capped at a minor version in the dev dependency group, in the repos where I have done it right. This looks like paranoia until the first time a minor release changes how one expression wraps, and a teammate on the newer version reformats four hundred lines while fixing a typo.
Formatters are allowed to change their output between releases, and Ruff says so in writing: its versioning policy treats style changes as minor-version material. Pin it, upgrade it deliberately, and when you do, land the reformat as its own commit that touches nothing else. Add that commit's hash to .git-blame-ignore-revs so blame skips it and you do not lose the history of who wrote a line.
Three enforcement points, because each one leaks
I run the same formatter in three places, and the redundancy is the point.
On save, in the editor. This is where it costs nothing and where developers stop noticing formatting exists at all. It is also the only layer that is purely voluntary, which is why the other two exist.
On commit, via a hook. In JavaScript repos that is Husky plus lint-staged, so only staged files get touched. For Python, pre-commit with the official Ruff hooks does the same job, although most of my Python repos skip the hook and let CI be the gate. This catches the contributor who has not configured their editor, and the commit made from a web UI or an agent that bypassed the editor entirely.
In CI, as a check, never a fix. prettier --check . and ruff format --check . both exit non-zero on a file that would change. CI does not write commits back to my branches, because a bot that rewrites your work while you are mid-rebase is worse than a red build. If CI is failing on formatting, something above it leaked, and I would rather know.
A commit hook can be skipped with --no-verify, which is the right escape hatch to leave available and the reason CI gets the last word.
Stop tuning the options
Prettier's defaults are good, and the honest reason to change one is that your team already writes code a certain way, not that the alternative is better. I set singleQuote: true and keep printWidth: 100 in most repos. Both are habits, not arguments. Everything else I leave alone, including semi: true, trailingComma: "all" and endOfLine: "lf", all three of which are already Prettier's defaults as of version 3.
This is the whole reason Prettier's option philosophy page exists and why it says no to most new options. Each knob you turn is a decision a new contributor has to discover, and a diff you inherit if you ever drop the override. The correct number of custom options is small, and the correct place to record why each one exists is the repo, not somebody's memory.
What formatting does not fix
A formatted codebase is not a good codebase. The formatter will happily align a function that takes nine positional arguments and a module that imports from three layers above itself. Naming, file layout, module boundaries and API shape are all still review material, and they are the things worth spending review attention on once the whitespace arguments are gone. I wrote separately about the naming conventions I use in Next.js projects, which is the layer directly above this one.
What formatting buys you is narrow and real: diffs that contain only the change, reviews that are about behavior, and no cost to switching between files written by different people.
The three guides
Each of these is the config I run, option by option, with the reason for every place I deviate from a default.
- Front-end JavaScript and TypeScript: Prettier for a Next.js and Tailwind app, import sorting, the two sort-imports plugins and why I moved between them.
- Back-end JavaScript and TypeScript: the same config with the front-end plugins removed, and what actually differs on a Node service.
- Python:
ruff formatplusruff check, and why there is no Black or isort left in any of my repos.