You know that moment? You finish writing some code, and immediately you’re waiting for your formatter to run. Or worse, you forget to format before pushing, and your CI rejects the commit because of whitespace. Or you’re in the middle of a refactoring and every editor extension is fighting with your formatter about what the code should look like.
Formatting is table stakes now. Your team has agreed on a style guide. You’re running Prettier or Black or rustfmt. But manually triggering formatting is friction. And waiting for CI to tell you something’s wrong is feedback that comes too late.
What if formatting happened automatically? Not as a pre-commit hook that sometimes gets skipped, but as an integrated part of your workflow. You edit code, and it formats as you go. You paste a block of code, and it automatically adjusts to your style. You create a file, and it’s formatted from the start.
That’s what we’re building: auto-format hooks in Claude Code that integrate with your editor and Git workflows. These hooks are not pre-commit enforcers—they’re quality-of-life improvements that make formatting invisible.
Why Hooks Matter for Development Workflow
Before diving into the technical details, understand why auto-format hooks are worth the effort. Formatting is something that seems small but compounds into significant friction over time. A developer’s day might include dozens of save operations. If formatting is manual, that’s dozens of context switches from “I’m implementing a feature” to “I need to run prettier.” Each switch is small—5 seconds—but they add up. Across a team, across a year, that’s significant.
Beyond time, there’s the psychological aspect. When formatting is automatic, developers stop thinking about it. Their mental energy stays on the problem they’re solving. When formatting is manual, developers either remember to do it (adding cognitive load) or forget to do it (leading to CI rejections). Either way, it’s friction.
Then there’s the version control aspect. When formatting is automatic, your git history is clean. Each commit represents a logical change. When formatting is manual, you end up with “fix formatting” commits that obscure what actually changed. If you blame a file to understand who changed what when, a “fix formatting” commit doesn’t help. You have to dig deeper.
Finally, there’s the team alignment aspect. When formatting is automatic, everyone’s code looks the same. There’s no question about style. When formatting is manual or inconsistent, developers develop their own style preferences. Code reviews waste time discussing style instead of logic. The team’s codebase looks inconsistent.
Auto-format hooks solve all of these problems. They make formatting invisible. Developers write code naturally, the hook formats it, and it’s done. No context switches. No manual steps. No formatting commits. No style discussions.
The Philosophy Behind Automatic Formatting
Before diving into hooks, let’s establish the philosophy. Formatting should be invisible. The developer should never think about it. They write code, and it’s automatically formatted according to standards. They never need to manually invoke a formatter. They never see formatting warnings in code review. They never think about whether to use tabs or spaces.
This philosophy requires agreement on three points: First, there’s a single, authoritative style guide that the team has agreed on. Second, a tool (Prettier, Black, gofmt, etc.) can enforce that style guide completely. Third, the tool runs automatically without developer intervention.
Without all three points, automatic formatting doesn’t work. If the team hasn’t agreed on a style guide, no formatter can help—different team members will want different styles. If the formatter can’t enforce the style completely, developers still need to make manual decisions. If the formatter doesn’t run automatically, developers have to remember to run it.
With all three points in place, something magical happens. Formatting stops being a discussion. It stops being a problem. It’s just how the code looks. This frees mental energy for actual problem-solving instead of bikeshedding about style.
Understanding Hook Triggers and Timing
Before we start coding, understand when hooks can run. Claude Code hooks can trigger on multiple events, and choosing the right trigger is crucial. The trigger determines when formatting happens and how much friction it adds to the workflow.
The on-file-save trigger fires when you save a file. This is ideal for interactive formatting—you edit, save, and immediately see the formatted result. The downside: if your formatter is slow, saving becomes slow.
The on-file-edit trigger fires as you’re editing, potentially character by character. This can enable live reformatting but is risky because it can interfere with your typing. Use this with caution.
The pre-git-commit trigger fires before a commit is created. This ensures committed code is always formatted. It’s less intrusive than save-time formatting because it only runs when you’re actually committing.
The post-git-merge trigger fires after a merge completes. This is useful for reformatting merged code that might have conflicting styles.
The on-branch-switch trigger fires when you switch Git branches. This can be useful for reformatting the entire branch to your team’s style.
Each trigger has tradeoffs. For this article, we’re focusing on on-file-save and pre-git-commit because they offer the best balance of automation and not interfering with workflow.
Formatter Choice and Configuration
Before implementing hooks, choose your formatters carefully. Different teams have different needs. JavaScript teams often use Prettier. Python teams often use Black. Go teams use gofmt. Rust teams use rustfmt. Java teams use Google Java Format. Each language community has opinions.
The key is choosing a formatter that your team can agree on. Ideally, choose an opinionated formatter. Opinionated formatters leave no room for debate because they give you no choices. Prettier is opinionated—you can’t configure line length (well, you can, but it’s discouraged). Black is opinionated—you use it as-is or you don’t use it at all. This opinionation is actually a feature, not a bug. It eliminates bikeshedding.
Less opinionated formatters like Prettier with extensive configuration or tools like ESLint with hundreds of configurable rules are more flexible but require more setup. You have to document all your configuration choices. Your team has to learn the configuration. Tools that are too flexible can lead to different developers making different configuration choices.
Once you’ve chosen a formatter, document it. “We use Prettier with default configuration except for 100-character line length.” This gives everyone a clear understanding of what the standard is.
A critical decision point is whether to use one formatter or multiple formatters per language. For JavaScript, you might use both ESLint (for code patterns) and Prettier (for style). The convention is to run ESLint first with –fix, then Prettier. This prevents conflicts. For Python, you might use Black (for formatting) and isort (for import organization). Again, order matters—run isort before Black. For languages like Go and Rust, the choice is simpler because there’s usually one clear standard tool (gofmt for Go, rustfmt for Rust).
Document the tool ordering if you’re using multiple tools. “JavaScript: run ESLint –fix, then Prettier.” This prevents developers from running tools in the wrong order and getting confused when they conflict. The ordering should be enforced by your hooks—the orchestrator runs them in the right order, not leaving it to chance.
Configuration consistency matters as much as the tools themselves. If some developers have Prettier line length set to 80 and others have it set to 100, code will format differently on different machines. Store your formatter configuration in version control. Have it reviewed in code review. Treat it as seriously as code because it affects every single file in your codebase.
The Human Cost of Formatting Inconsistency
Why spend so much time on formatting? Because the cost of inconsistency compounds. Every developer who learns your codebase spends time parsing inconsistent formatting. Every code review that discusses style instead of logic is a waste of focused attention. Every commit that contains “fix formatting” is noise in your history.
Consider a team of ten developers. If each one spends an average of 15 minutes per week dealing with formatting—running formatters, fixing formatting issues, debating style in reviews—that’s 2.5 hours per week. Across a year, that’s over 100 hours of lost productivity. For a team of 50, it’s over 500 hours. That’s two weeks of engineering time per year spent on formatting.
Automated formatting reclaims that time. Once you’ve set up hooks, the cost approaches zero. Developers hit save, code is formatted, they move on. No context switches. No cognitive overhead. No discussions. The hooks pay for themselves within weeks for any team of meaningful size.
Beyond productivity, there’s the psychological aspect. When formatting is automatic, developers feel more confident in their contributions. They’re not worried about style criticism. They can focus on the logic and let the tools handle the presentation. This reduces friction in code review and speeds up the development process.
Building the Prettier Auto-Format Hook
Now let’s build it. We’re going to create hooks that integrate with your development workflow and make formatting invisible.
Now let’s implement the hook. We’ll use Prettier as an example because it’s popular and straightforward.
Let’s start with the most common case: JavaScript/TypeScript code with Prettier. Prettier is opinionated and fast, which makes it ideal for auto-formatting.
Here’s the hook configuration:
# .claude/hooks/prettier-autoformat.yaml
name: prettier-autoformat
trigger: on-file-save
condition: |
file.extension in ['js', 'jsx', 'ts', 'tsx', 'json', 'yaml']
AND file.path not matches '(node_modules|dist|build)'
async: true
blocking: false
actions:
- command: format-with-prettier
timeout: 5000
on-failure: log-only
This configuration says: “When you save a JS/TS/JSON/YAML file (excluding node_modules, dist, build), run the format-with-prettier command. If it fails, log it but don’t block the save. Run asynchronously so it doesn’t slow down saving.”
Now the actual formatter command:
// .claude/commands/format-with-prettier.js
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
module.exports = async (filePath) => {
try {
// Check if file exists and is readable
if (!fs.existsSync(filePath)) {
console.log(`[Prettier] File doesn't exist: ${filePath}`);
return;
}
// Check if Prettier is available
try {
execSync("npx prettier --version", { stdio: "pipe" });
} catch {
console.log(
"[Prettier] Prettier not installed. Install with: npm install prettier",
);
return;
}
// Get file extension to determine parser
const ext = path.extname(filePath);
let parser;
if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) {
parser = "babel";
} else if ([".ts", ".tsx"].includes(ext)) {
parser = "typescript";
} else if (ext === ".json") {
parser = "json";
} else if ([".yml", ".yaml"].includes(ext)) {
parser = "yaml";
} else {
return; // Unknown extension, skip
}
// Read current content
const originalContent = fs.readFileSync(filePath, "utf-8");
// Format with Prettier
const formattedContent = execSync(
`npx prettier --parser ${parser} --write "${filePath}"`,
{ encoding: "utf-8" },
);
// Check if content actually changed
const newContent = fs.readFileSync(filePath, "utf-8");
if (originalContent !== newContent) {
console.log(`[Prettier] Formatted: ${filePath}`);
}
return { success: true };
} catch (error) {
console.error(`[Prettier] Error formatting ${filePath}:`, error.message);
return { success: false, error: error.message };
}
};
This command integrates Prettier into your workflow. When you save a file, it automatically formats it using Prettier. If Prettier isn’t installed, it logs a helpful message instead of erroring. If the file hasn’t changed, it doesn’t clutter your logs.
ESLint Auto-Fix Hook
Prettier handles formatting (spaces, semicolons, line breaks). ESLint handles rules (unused variables, naming conventions, logical errors). You can auto-fix many ESLint violations too:
# .claude/hooks/eslint-autofix.yaml
name: eslint-autofix
trigger: on-file-save
condition: |
file.extension in ['js', 'jsx', 'ts', 'tsx']
AND file.path not matches '(node_modules|dist|build|__tests__|spec)'
async: true
blocking: false
actions:
- command: eslint-fix-file
timeout: 5000
on-failure: log-only
And the fixer:
// .claude/commands/eslint-fix-file.js
const { execSync } = require("child_process");
const fs = require("fs");
module.exports = async (filePath) => {
try {
// Check if file exists
if (!fs.existsSync(filePath)) {
return;
}
// Check if ESLint is available
try {
execSync("npx eslint --version", { stdio: "pipe" });
} catch {
console.log(
"[ESLint] ESLint not installed. Install with: npm install eslint",
);
return;
}
// Get original content
const originalContent = fs.readFileSync(filePath, "utf-8");
// Run ESLint with --fix flag
try {
execSync(`npx eslint "${filePath}" --fix`, { stdio: "pipe" });
const newContent = fs.readFileSync(filePath, "utf-8");
if (originalContent !== newContent) {
// Parse ESLint output to see what was fixed
const eslintOutput = execSync(
`npx eslint "${filePath}" --format=json`,
{ encoding: "utf-8", stdio: "pipe" },
);
const results = JSON.parse(eslintOutput);
if (results[0]?.messages?.length) {
console.log(`[ESLint] Fixed ${filePath}`);
}
}
} catch (error) {
// ESLint exit code 1 means errors, but fixes may have been applied
const newContent = fs.readFileSync(filePath, "utf-8");
if (originalContent !== newContent) {
console.log(`[ESLint] Fixed issues in ${filePath}`);
}
}
return { success: true };
} catch (error) {
console.error(`[ESLint] Error processing ${filePath}:`, error.message);
return { success: false };
}
};
This hook runs ESLint’s auto-fix feature. Not all violations can be auto-fixed (some require human judgment), but many can. Unused variables, missing semicolons, incorrect spacing—ESLint handles these automatically.
Python Auto-Format Hook (Black)
Black is Python’s uncompromising formatter. Here’s the hook:
# .claude/hooks/black-autoformat.yaml
name: black-autoformat
trigger: on-file-save
condition: |
file.extension == 'py'
AND file.path not matches '(venv|\.venv|dist|build|__pycache__)'
async: true
blocking: false
actions:
- command: format-with-black
timeout: 5000
on-failure: log-only
And the formatter:
# .claude/commands/format-with-black.py
#!/usr/bin/env python3
from pathlib import Path
def format_with_black(file_path):
"""Format Python file with Black."""
try:
# Check file exists
if not os.path.exists(file_path):
return False
# Read original content
with open(file_path, 'r', encoding='utf-8') as f:
original_content = f.read()
# Check if Black is installed
try:
subprocess.run(['black', '--version'], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
print(f"[Black] Black not installed. Install with: pip install black")
return False
# Run Black
result = subprocess.run(
['black', file_path, '--quiet'],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"[Black] Error formatting {file_path}: {result.stderr}")
return False
# Check if content changed
with open(file_path, 'r', encoding='utf-8') as f:
new_content = f.read()
if original_content != new_content:
print(f"[Black] Formatted: {file_path}")
return True
except Exception as e:
print(f"[Black] Exception formatting {file_path}: {e}")
return False
if __name__ == '__main__':
if len(sys.argv) > 1:
format_with_black(sys.argv[1])
else:
print("Usage: format-with-black.py <file_path>")
Rust Auto-Format Hook (Rustfmt)
Rust has rustfmt, which is fast and opinionated:
# .claude/hooks/rustfmt-autoformat.yaml
name: rustfmt-autoformat
trigger: on-file-save
condition: |
file.extension == 'rs'
AND file.path not matches '(target|\.cargo)'
async: true
blocking: false
actions:
- command: format-with-rustfmt
timeout: 5000
on-failure: log-only
// .claude/commands/format-with-rustfmt.sh
#!/bin/bash
FILE_PATH="$1"
if [ ! -f "$FILE_PATH" ]; then
exit 0
fi
# Check if rustfmt is available
if ! command -v rustfmt &> /dev/null; then
echo "[Rustfmt] rustfmt not found. Install with: rustup component add rustfmt"
exit 0
fi
# Save original content
ORIGINAL=$(cat "$FILE_PATH")
# Run rustfmt
rustfmt "$FILE_PATH" 2>/dev/null
# Check if changed
NEW=$(cat "$FILE_PATH")
if [ "$ORIGINAL" != "$NEW" ]; then
echo "[Rustfmt] Formatted: $FILE_PATH"
fi
Setting Up Your Formatter Environment
Before implementing hooks, ensure your formatters are available and configured correctly. Different formatters have different installation and configuration requirements.
Prettier is typically installed as a dev dependency in your Node project: npm install --save-dev prettier. Configuration goes in .prettierrc or package.json. The configuration is optional—Prettier has good defaults—but your team might want to customize line length or other options.
Black is installed via pip: pip install black. Black has minimal configuration options (intentionally). Configuration goes in pyproject.toml or setup.cfg. Most teams just install Black and use it with defaults.
Go’s gofmt is built into the Go toolchain. No installation needed if you have Go installed. No configuration needed—it’s opinionated and non-configurable.
Ruby’s RuboCop is installed via gem: gem install rubocop. Configuration goes in .rubocop.yml. RuboCop has many configuration options.
Rust’s rustfmt is part of the Rust toolchain. Install with rustup component add rustfmt. Minimal configuration.
Create an onboarding guide for your team. “To work on this project, run these commands to install formatters.” Make setup as simple as possible. The more friction there is in setup, the more resistance you’ll encounter.
Pre-Commit Format Gate
While auto-formatting on save is nice, you might also want a pre-commit hook that ensures all staged files are formatted before allowing the commit:
# .claude/hooks/pre-commit-format-check.yaml
name: pre-commit-format-check
trigger: pre-git-commit
blocking: true
actions:
- command: check-staged-formatting
timeout: 30000
on-failure: abort-commit
// .claude/commands/check-staged-formatting.js
const { execSync } = require("child_process");
const fs = require("fs");
module.exports = async () => {
try {
// Get list of staged files
const stagedOutput = execSync("git diff --cached --name-only", {
encoding: "utf-8",
});
const stagedFiles = stagedOutput.trim().split("\n").filter(Boolean);
if (stagedFiles.length === 0) {
return { success: true, message: "No files to check" };
}
const issues = [];
// Check JavaScript/TypeScript files
const jsFiles = stagedFiles.filter(
(f) =>
/\.(js|jsx|ts|tsx|json|yaml)$/.test(f) && !f.includes("node_modules"),
);
for (const file of jsFiles) {
const formatted = execSync(`npx prettier --check "${file}"`, {
encoding: "utf-8",
stdio: "pipe",
}).catch(() => null);
if (formatted === null) {
issues.push(`${file} is not formatted with Prettier`);
}
}
// Check Python files
const pyFiles = stagedFiles.filter(
(f) => /\.py$/.test(f) && !f.includes("venv"),
);
for (const file of pyFiles) {
try {
execSync(`black --check "${file}"`, { stdio: "pipe" });
} catch {
issues.push(`${file} is not formatted with Black`);
}
}
if (issues.length > 0) {
console.log("❌ Commit blocked: files are not formatted");
issues.forEach((issue) => console.log(` - ${issue}`));
console.log("\nFix with:");
console.log(" npx prettier --write <files> # for JS/TS");
console.log(" black <files> # for Python");
return { success: false };
}
console.log("✅ All staged files are properly formatted");
return { success: true };
} catch (error) {
console.error("Error checking formatting:", error.message);
return { success: false, error: error.message };
}
};
This hook runs before every commit. If any staged files are not formatted, the commit is aborted and you get actionable feedback about which files to fix.
Why Formatters Fight Each Other
Understanding why formatters conflict helps you prevent the conflicts. Prettier is a code formatter—it handles whitespace, semicolons, line length. ESLint is a linter—it handles code patterns, naming conventions, unused variables. They’re addressing different concerns. The problem is that sometimes their concerns overlap.
For example, Prettier might format code to 80 characters per line. ESLint might have a rule that says identifiers must be descriptive, which can push code over 80 characters. Now Prettier wants shorter lines and ESLint wants longer names. They fight.
Or consider Python: Black is a formatter that makes opinionated choices about string quotes (it prefers single quotes unless the string contains single quotes). isort is an import organizer that reorganizes imports. flake8 is a linter that checks for issues. If you run them in the wrong order, they can conflict. isort might reorganize imports, then Black might reformat them, then flake8 might complain about the result.
The solution isn’t to remove tools—each tool is valuable. The solution is to establish a clear order and let each tool do its job. Some tools should always run first. Some should always run last. Tools in the middle can be reordered if they don’t conflict.
Handling Conflicting Formatters
Real-world codebases often have multiple formatters fighting each other. Prettier and ESLint can disagree. Black and mypy can have opinions about the same code. Here’s how to orchestrate them safely. The key principle: establish an order where each tool has dominion over a specific concern. Don’t let tools fight over the same things.
// .claude/commands/multi-format-safe.js
const { execSync } = require("child_process");
const fs = require("fs");
module.exports = async (filePath) => {
const ext = require("path").extname(filePath);
try {
// Get original content
const original = fs.readFileSync(filePath, "utf-8");
// For JavaScript, always run ESLint BEFORE Prettier
// ESLint can create issues that Prettier will then fix
if ([".js", ".jsx", ".ts", ".tsx"].includes(ext)) {
console.log("[Multi-Format] Running ESLint...");
try {
execSync(`npx eslint "${filePath}" --fix`, { stdio: "pipe" });
} catch {
// ESLint might exit with code 1 if there are errors
// But fixes may have been applied anyway
}
console.log("[Multi-Format] Running Prettier...");
execSync(`npx prettier --write "${filePath}"`, { stdio: "pipe" });
}
// For Python, run Black (it's the final authority)
if (ext === ".py") {
console.log("[Multi-Format] Running Black...");
try {
execSync(`black "${filePath}"`, { stdio: "pipe" });
} catch {
// Black succeeded, exit code just indicates there were changes
}
}
// Log if anything changed
const formatted = fs.readFileSync(filePath, "utf-8");
if (original !== formatted) {
console.log(`[Multi-Format] Formatted: ${filePath}`);
}
return { success: true };
} catch (error) {
console.error(`[Multi-Format] Error:`, error.message);
return { success: false };
}
};
The key insight: order matters. If you run Prettier before ESLint, ESLint might add semicolons that Prettier then removes. Run tools in the order they’re designed to work together.
Performance Considerations
Auto-formatting on every save can get slow if your codebase is large or formatters are slow. Here are practical optimizations:
First, only format the file being edited, not the entire project. Your hooks should pass the specific file path, not run globally. This is the most important performance optimization. Formatting a single file takes milliseconds. Formatting your entire codebase takes minutes. Never format globally on save. Format only the file that changed.
Second, use async hooks with blocking: false. The file saves immediately, and formatting happens in the background. This is better UX than blocking the save. From the developer’s perspective, the save is instant. The formatter runs in the background, and if there are formatting changes, they appear shortly after. This feels responsive and doesn’t create friction.
Third, consider running expensive formatting only on commit, not on save. Pre-commit hooks are acceptable overhead because they only run occasionally. This is a good compromise for teams with slower formatters or very large files. The developer saves the file instantly with no formatting. Formatting is deferred until commit time. This is still much better than the manual approach where developers have to remember to run formatters.
Fourth, add timeouts. If a formatter takes more than 5 seconds, kill it and log the issue. You don’t want slow formatting to block your workflow. Better to have unformatted code that loads quickly than formatted code that takes forever. Timeouts prevent formatters from becoming a bottleneck. If a formatter consistently hits the timeout, investigate why it’s slow. Maybe you need a faster formatter. Maybe the file is too large for this formatter. Maybe there’s a bug in the formatter that’s causing it to hang.
Finally, cache formatter results. If nothing in the file changed since the last format, skip running the formatter. This is an advanced optimization but can be valuable. Before running the formatter, hash the file contents. After formatting, store the hash. Next time you save, check if the file hash matches. If it does, skip formatting. This prevents repeatedly running formatters on identical file contents.
One subtle point: be careful with caching in team environments. If developer A formats a file one way and developer B formats it another way (different formatter versions, different configs), cache can cause confusion. The file might not change after formatting because it’s already in the expected format from B’s perspective, but A’s formatter would format it differently. This is why versioning your formatters is important.
Advanced Patterns: Orchestrating Multiple Formatters Safely
Real-world projects often need multiple formatters working together. The challenge is making sure they don’t fight each other. We covered this conceptually, but let’s dig into the practical orchestration.
The key insight is that formatters have a natural ordering based on what they operate on. Tools that change the structure of code (add/remove lines, reorder items) should run first. Tools that only change whitespace and punctuation should run last. This ordering prevents cascading conflicts where one tool changes code in a way that breaks the next tool.
For JavaScript/TypeScript projects, the typical ordering is:
- isort-equivalent (import sorting) – reorganizes imports
- ESLint –fix – fixes code pattern issues, which might change formatting
- Prettier – applies consistent formatting
For Python projects:
- isort – reorganizes imports (might add/remove lines)
- Black – applies Black formatting (final word on style)
- flake8 – checks for remaining style issues (in CI only, not auto-fix)
For polyglot projects with multiple languages, you dispatch to the right formatter chain based on file extension. The dispatcher is a single orchestration point that knows the ordering and runs tools in sequence.
The key is that each tool in the chain should be idempotent—running it twice on the same file produces the same result. If a tool is non-idempotent, you have a problem because re-running it might change the output again. Prettier and Black are idempotent. Some linters with auto-fix are not. Test this before relying on it.
Error handling in the chain matters too. If the first tool fails, should the second tool still run? Usually, yes—errors in one tool shouldn’t block others. But if the first tool modified the file and the second tool fails, you’re left with a partially-processed file. Consider the failure modes carefully.
Logging what changed helps with debugging. “Prettier reformatted 5 lines” tells the developer what happened. “Black applied 2 style fixes” is informative. When tools run silently, developers don’t know what’s happening and might distrust the process.
Integration with Your Editor
Claude Code hooks integrate with VS Code and JetBrains IDEs. You might also want your editor’s native formatting to respect the same rules:
{
"editor.formatOnSave": false,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false
},
"[python]": {
"editor.defaultFormatter": "ms-python.python",
"editor.formatOnSave": false
}
}
Note the formatOnSave: false. You’re not relying on the editor’s formatter—you’re relying on Claude Code hooks. This ensures consistency.
Why Different Files Format Differently (And How to Fix It)
One of the trickiest issues with auto-formatting hooks is when different files format differently even though they should format the same way. A JavaScript file in one directory formats one way. A JavaScript file in another directory formats another way. The formatter is the same. The configuration should be the same. But the output is different.
This usually means there are multiple formatter configurations in play. Maybe there’s a .prettierrc in the project root and another one in a subdirectory. Maybe there’s a global Prettier config on the developer’s machine. Prettier respects a hierarchy of configuration files, looking from the file location up to the project root. If different parts of the project have different configurations, formatting will be different.
The solution is centralization. Have one .prettierrc at the project root. Delete any other formatter configuration files in subdirectories. This ensures all files use the same configuration. If you need different rules for different directories, use a single configuration file with directory-specific rules rather than multiple configuration files.
Document this clearly. “All Prettier configuration comes from .prettierrc at the project root. Don’t create .prettierrc in subdirectories.” This prevents confusion and mistakes.
Another cause of formatting differences is formatter version. If developer A has Prettier 3.0 and developer B has Prettier 2.8, they’ll format code differently. The solution is version pinning. In your package.json, specify exact versions: "prettier": "3.0.1", not "prettier": "^3.0.0". Use lockfiles (package-lock.json, yarn.lock) to ensure everyone has the same version.
This applies to all formatters. Black version mismatches can cause issues. gofmt is less problematic since it’s versioned with Go itself, but if some developers have Go 1.20 and others have 1.21, they might have different gofmt behavior.
Common Formatter Configuration Issues and Solutions
Teams often configure formatters incorrectly, which leads to fighting tools and developer frustration. Here are the most common issues and how to solve them:
Issue: Prettier and ESLint have different line length limits. Prettier wants 80 characters. ESLint wants 120. Code gets formatted by Prettier to 80 chars, then ESLint complains it needs 120. Solution: Sync the configurations. Set Prettier and ESLint to the same line length in their config files. Better yet, set them both to a reasonable value (88 or 100) and stick with it.
Issue: Black and flake8 disagree on string quotes. Black prefers single quotes. flake8 has a configuration that prefers double quotes. They fight. Solution: Configure Black and flake8 to agree. In pyproject.toml or setup.cfg, set the string quote preference once and document it.
Issue: ESLint auto-fix causes Prettier to complain. ESLint fixes a style issue, then Prettier immediately unfixes it because Prettier’s rule is different. Solution: Run ESLint first with --fix, then run Prettier. Let Prettier have the final say on formatting. This is the canonical order for JavaScript: ESLint for logic and patterns, Prettier for style.
Issue: Multiple projects use different formatter versions. Frontend project uses Prettier 3.0. Backend uses Prettier 2.8. They format differently. Solution: Version pin your formatters in package.json and requirements.txt. Use lockfiles. Make sure all developers on a project use the same formatter version.
Issue: Generated code conflicts with formatter. You generate code with a tool, and the generated code doesn’t match your formatter’s style. Solution: Configure your code generator to output in the style your formatter expects, or configure your formatter to skip generated files. Most generators have style options.
Real-World Patterns: Polyglot Projects
Real projects aren’t single-language. You have JavaScript frontend, Python backend, Bash scripts, Terraform, Kubernetes manifests, SQL migrations. Each has its own formatter. Here’s how to organize this:
Create a central formatter dispatcher that detects file types and invokes the appropriate language-specific formatter. This prevents formatter conflicts and centralizes your formatting logic. Use file extensions to route to the right formatter. JavaScript files go to Prettier. Python files go to Black. Bash files go to shfmt. Terraform files go to terraform fmt.
Document the dispatch logic. If developers need to know which formatter handles which file type, they’ll waste time debugging. Document it in a README or configuration file. “JavaScript/TypeScript → Prettier, Python → Black, Go → gofmt, Bash → shfmt, etc.”
Test the dispatch logic. Make sure the router detects file types correctly. Make sure each formatter is actually installed. Make sure the tool paths are correct.
Monitor formatter performance. If JavaScript formatting becomes slow, investigate. Maybe you added a large file. Maybe Prettier is struggling with complex code. Make sure formatting doesn’t become a bottleneck that discourages developers from saving.
Why Local Formatting Beats CI Formatting
Some teams delegate formatting to CI. The reasoning is sound: CI runs once, so why not have CI format the code? The problem is that developers don’t get immediate feedback. They write code, commit, push, and CI tells them it’s not formatted. They have to fix it, commit again, and push again. This creates friction. Multiple commits for formatting feel like noise in the history.
With local formatting hooks, developers see the formatted version immediately. No separate commits. No CI rejections. Just write, save, and it’s formatted. When you push, the code is already formatted because it was formatted as you worked.
Local formatting is also faster for large teams. If every commit has to go through CI formatting, and you have hundreds of developers, that’s a lot of CI overhead. With local formatting, the overhead is distributed across developers’ machines, not concentrated on CI infrastructure.
The downside of local formatting is enforcement. What if a developer disables the hook or bypasses it? With CI formatting, you have a guarantee that formatting happened. With local hooks, you have to trust developers to run the formatter. This is why combining local hooks with a pre-commit hook (that blocks commits if files aren’t formatted) is powerful. Local hooks give fast feedback. Pre-commit hooks give enforcement.
Monitoring Hook Performance
As your formatting hooks run across your codebase, monitor their performance. Some formatters are slow. Some files are slow to format. You need visibility into this.
Collect metrics on hook execution: which files were formatted, how long did each formatter take, did the formatter make changes. Track slow formatting operations. If the same file takes 30 seconds to format every time, investigate why. Maybe the file is very large. Maybe the formatter is struggling with the code structure. Maybe there’s a configuration issue.
Use these metrics to optimize. If Prettier is consistently slow, check your Prettier configuration. Maybe your line length is too restrictive and forcing many rewrites. If one file is consistently slow, look at that file. Maybe it’s a generated file that shouldn’t be formatted anyway.
Track formatter success and failure rates. If a formatter fails 10% of the time, that’s a problem. Investigate why it’s failing. Is it a permissions issue? A missing tool? A file encoding issue?
Create dashboards that show formatter health. “Prettier: 10,000 files formatted this week, average 50ms per file, zero failures.” “Black: 500 files formatted this week, average 100ms per file, 2 failures.” This gives you visibility into formatter performance.
Formatter Coordination and Update Strategies
When you have multiple formatters running, they need to be coordinated. If you update Prettier, you need to update Black at the right time. If you disable ESLint temporarily for debugging, you need to remember to re-enable it.
Create a formatter registry that documents all formatters, their versions, their configuration, and their purpose. “Prettier 3.0 for JavaScript/TypeScript, Black 23.7 for Python, gofmt for Go.” This registry is the source of truth for your formatting setup.
Have a process for updating formatters. When a new version is available, create a branch, update the formatter, run your entire test suite and build pipeline, verify that formatting is consistent, then merge. Don’t update formatters on a whim. Be deliberate about it.
Have a rollback plan if a formatter update causes problems. Maybe a new version of Prettier breaks your code in unexpected ways. You need to be able to roll back quickly.
Scaling Hooks Across Teams
If you’re implementing hooks for a single project, the setup is straightforward. But if you’re implementing across multiple projects or a large team, you face additional challenges.
Consistency is critical. If Project A uses Prettier 3.0 and Project B uses Prettier 2.8, they format differently. Developers context-switching between projects get confused. Solution: establish a canonical version of each formatter. All projects use the same Prettier version.
This requires coordination. Every time Prettier releases a major version, your team decides whether to upgrade. You establish a timeline. You update all projects together. You document the change.
Some teams centralize this further by providing a shared formatter configuration. Instead of each project configuring Prettier separately, there’s a company-wide .prettierrc that all projects inherit. This ensures absolute consistency but reduces flexibility.
Documentation is critical. When a new developer joins, they need to know what the formatter standards are. Document it in your onboarding guide. Document it in your README. Ideally, the first-time setup process automatically configures formatters for them.
Tooling helps. Create a setup script that installs and configures formatters. Create a verification script that checks that all formatters are installed correctly. These scripts should be runnable in one command.
Wrapping Up
Auto-formatting hooks transform formatting from a chore into something invisible. You edit code naturally. The hooks ensure it’s formatted correctly. You commit knowing your code meets style standards. You push without worrying about CI rejecting formatting.
Start with on-file-save for interactive formatting and pre-git-commit for a final gate. Pick the formatters your team uses. Configure the hooks. Let them run in the background. Your developers will appreciate the friction reduction.
The key is making formatting a background task, not a conscious action. When that happens, style consistency stops being a cultural enforcement problem and becomes a technical guarantee. Your team will ship faster because they’re not spending mental energy on formatting decisions. Your git history will be cleaner because you’re not creating formatting-only commits. Your code reviews will focus on logic and design, not style.
The patterns in this article scale from solo projects to large teams. Whether you’re managing five files or fifty thousand, the same principles apply: detect file types, dispatch to the right formatter, run in parallel where possible, give immediate feedback, enforce with gates.
Building auto-formatting hooks is one of those technical investments that pays dividends every single day. On the surface, it seems cosmetic—just making code look consistent. But in reality, it transforms your development experience. Code reviews become faster because they focus on logic instead of style. Developers feel more productive because they’re not thinking about formatting. Git history becomes cleaner because there are no “fix formatting” commits. New team members onboard faster because they don’t need to learn your style guide.
The best auto-formatting implementation is one that developers never think about. Code automatically formats as they work. They never wait for a formatter. They never see warnings about style. They never commit code that fails pre-commit checks because of formatting. The infrastructure is so seamless that it becomes invisible.
Start with your most-used language. If you’re a JavaScript shop, start with Prettier. If you’re a Python shop, start with Black. Get that one language formatted perfectly. Then add the next language. By the time you support three languages, the patterns are clear and adding more is straightforward.
Document your choices and rationale. Why did you choose Prettier over ESLint for JavaScript? Why those particular configuration options? This documentation helps new developers understand and defend the choices. It also helps when tool versions change or new alternatives emerge. You have a record of why you made the decisions you made.
Over time, auto-formatting becomes part of your team’s culture. It’s just how code looks. Developers who join your team quickly adapt. And they appreciate it—working on a project where style is automatic is noticeably less frustrating than projects where they have to manually run formatters or deal with style discussions in code review.
-iNet