Automate Code Quality with Pre-Commit Hooks
Welcome back to the second installment of “My Digital Arsenal,” the series where I share the essential tools that power my development workflow.
In the first post we dove deep into the world of Python package managers, the unsung heroes that keep our project dependencies from collapsing into chaos.
Today, we are moving from managing dependencies to managing quality. We are setting up our automated guardian for clean code.
In this post:
Why “consistency” matters more than you think.
The “Manual Trap” of standard linters.
How to set up
pre-commitwithuv(The 60-second setup).Bonus: A look at
prek, the blazing-fast Rust alternative.
Code Quality Starts with Consistency
Before we talk about tools, let’s talk about the code itself.
We often think of “Code Quality” as high-level architecture or efficient algorithms. But there is a lower, grittier level of quality that impacts us every single hour: Consistency.
Imagine reading a book where every paragraph uses a different font size, some sentences end with two periods, and random words are capitalized. Could you read it? Sure. Would it be exhausting? Absolutely.
Code is no different. When you work on a team, or even alone on a project over several months, entropy naturally sets in.
One file uses single quotesm,another uses double.
One function has trailing whitespace, another doesn’t.
Imports are scattered randomly at the top of the file.
The Code Review Nightmare:
import os, sys # messy imports
def calculate( x):
print( “debug”) # remove print
return x*2;
These might seem like “non-important” issues, but they create cognitive friction. Every time your brain has to process inconsistent formatting, it has less energy for solving the actual business logic.
Step 1: The Mechanical Fixers
To remove the human element from style policing, the development community created two types of tools to automate the job:
Linters (e.g., Flake8, Ruff, Pylint): These are the inspectors. They analyze your code for structural rot, catching errors like undefined variables or unused imports.
Formatters (e.g., Black, YAPF, isort): These are the beautifiers. They don’t care what your code does ; they care how it looks. They rewrite your code to strictly follow a style guide.
The “Manual Execution” Trap In the past, using these tools was a manual ritual. You had to remember to run a command like black . or flake8 before every single commit.
It sounds simple, but humans are terrible at repetitive tasks. If you were in a rush (and we are always in a rush), you would forget. You would push the code, wait for the CI pipeline, and then watch it fail 10 minutes later because of a trailing comma.
This led to the infamous “Walk of Shame” in your git history: a stream of tiny commits labeled “ fix linting ,” “ formatting ,” and “ really fix formatting this time .”
We have the tools, but we lack the automation trigger.
This is exactly what drove me to adoptpre-commit. On a previous team, we had a CI stage that checked for linting errors, followed by a very long testing stage. If I forgot to run the formatter locally, the CI would fail early, but the context switch killed my momentum. I would have to fix a trivial whitespace error, push again, and wait for the pipeline to restart. We were losing hours of productivity to simple formatting mistakes.
Step 2: Enter The Automated Guardian
This is where pre-commit comes in.
If a Continuous Integration (CI) pipeline is the security checkpoint at the airport, pre-commit is the helper at your front door checking if you have your keys and wallet before you leave the house.
Under the hood, Git has a feature called “hooks” — scripts that run automatically at specific points in the Git lifecycle. Historically, managing these hooks was a pain. You had to copy-paste unwieldy Bash scripts between projects.
The pre-commit framework solves this. Instead of messy scripts, you define your rules in one simple .pre-commit-config.yaml file. When you try to commit, the framework downloads the tools, runs them against your changes, and stops you if something is wrong.
Here is why it is an essential part of my arsenal:
It Focuses Your Code Reviews: The documentation says it best: it “allows a code reviewer to focus on the architecture of a change while not wasting time with trivial style nitpicks.”
It Fixes the “Small Stuff” Automatically: It doesn’t just catch issues, it often fixes them. Trailing whitespace, end-of-file, and formatting issues are corrected before they ever hit your repository.
It’s Multi-Language: While this series focuses on the Python ecosystem (and the incredible modern tooling like Ruff), pre-commit is language-agnostic. It can manage hooks for JavaScript, Terraform, JSON, and more, all without you needing to manage
npmorgemfiles manually.
My Go-To Pre-Commit Toolkit
Let’s walk through the minimal, high-impact setup I use to fix the most common annoyances.
1. The 60-Second Setup
Since we are already using uv from the last article, let’s use it here. Instead of polluting your global Python install, we will install pre-commit as an isolated tool.
# The modern way: Install as an isolated tool (can be done of course with pip too)
uv tool install pre-commit
# The “Magic” command that activates the hooks in this repo
pre-commit install
That second command is the most important one. It installs a tiny script into your .git/hooks/ directory. Now, every time you type git commit, that script will trigger the framework before Git even saves your changes.
⚠️ The “First Run” Warning: The very first time you commit after setting this up, it will be slow. You will see a message like _
[INFO] Initializing environment for.... Don’t panic. It is creating isolated environments for your hooks. This happens only once. Future commits will be instantaneous._
3. The Recipe: My.pre-commit-config.yaml
Create a file named .pre-commit-config.yaml in your project’s root. Here is the configuration I use. It covers syntax checking, formatting, and basic file hygiene.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: “v5.0.0”
hooks:
- id: check-ast # Is it valid Python?
- id: check-case-conflict # Avoid case-sensitivity issues on Windows/Mac
- id: check-merge-conflict # Block commits with ‘<<<<<<<’ markers
- id: check-toml # Validates pyproject.toml
- id: check-yaml
- id: check-json
- id: end-of-file-fixer # Ensures files end with a newline
- id: trailing-whitespace # Trims accidental whitespace at end of lines
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.5
hooks:
# Run the linter (replaces Flake8)
- id: ruff
types_or: [python, pyi]
args: [--fix]
# Run the formatter (replaces Black)
- id: ruff-format
types_or: [python, pyi]
default_stages: [pre-commit]
What it looks like in action: When you commit, you’ll see this satisfying output:
3. The “Escape Hatch” (For Emergencies)
Sometimes, you just need to commit. Maybe you are working on a messy prototype, or you are saving work before your computer dies. If the hooks are blocking you and you need to bypass them, use the --no-verify flag:
git commit -m “wip: messy code saving for later” --no-verify
Use this sparingly. If you bypass the guard too often, you defeat the purpose of having one.
4. Level Up: Thepre-pushHook
You will notice I added default_stages: [pre-commit] at the bottom. This means these checks run on every commit.
But what about heavier tasks? Running your entire unit test suite (pytest) on every commit is too slow, it breaks your flow. But you definitely want them to run before you push your code to the team.
Git has a specific hook for this called pre-push. You can add a separate section to your config to run heavy tests only when you push:
- repo: local
hooks:
- id: pytest
name: Run Unit Tests
entry: uv run pytest
language: system
stages: [pre-push] # <--- Only runs on ‘git push’
To activate this, you need to run one extra install command
pre-commit install --hook-type pre-push
Now you have a tiered defense:
Commit: Fast linting & formatting (Instant).
Push: Heavy testing & security scans (Slower, but safe).
Level 5: Expanding Your Toolkit
We have focused heavily on formatting, but the pre-commit ecosystem is massive. You can find hooks for almost anything—from enforcing static typing to preventing security leaks.
I highly recommend exploring hooks like:
mypy: To catch type errors before execution.detect-secrets: To prevent accidental commits of API keys or passwords.commitizen: To enforce standardized commit messages across your team.
Where to find more?
The best place to start is the Official Supported Hooks Index. It is a searchable database of thousands of hooks for every language and task imaginable.
For a curated deep dive, I strongly recommend checking out Gatlen Culp’s article: Effortless Code Quality: The Ultimate Pre-Commit Hooks Guide for 2025. It was a major inspiration for this post and helped me refine my own setup.
Pro Tip: If you already rely on a specific CLI tool (like
bandit,hadolint, orsqlfluff), just Google“tool-name pre-commit”. If the tool is popular, there is a very high chance a hook for it already exists.
Level 6: The Rust Revolution (prek)
If you read my last post about uv, you know I am bullish on the “Rust-ification” of the Python ecosystem. We are seeing a wave of tools that are faster, smarter, and easier to use than their predecessors.
While pre-commit is the industry standard, it is starting to show its age. It requires a Python runtime and can be slow on large repos.
Then there is the social aspect. I had heard rumors that the maintainer’s interaction style could be ‘abrasive’ but I didn’t get it until I looked at the issue tracker myself. After reading through a few threads and rejected feature requests, I understood why some developers are looking for a friendlier alternative.
Enterprek
prek is a reimagined version of pre-commit, built entirely in Rust. It is designed to be a drop-in replacement that respects your existing config but runs circles around the original.
Why I’m keeping my eye on it:
Architectural Efficiency (Speed & Disk Space): It completely redesigned how environments are managed. Unlike the original,
prekshares toolchains between hooks rather than duplicating them. It also clones repositories and installs hooks in parallel. Combined with its internal use ofuv, this results in significantly faster setups and half the disk usage.Zero-Hassle Setup: It compiles to a single binary with no dependencies. You don’t need to manage Python versions or virtual environments manually —
prekhandles all of that automatically. You just download it and run it.Modern Workflow Features: It solves long-standing pain points like Monorepo support (via “workspaces”) out of the box. It also adds smarter CLI commands we’ve always wanted, like
prek run --directory <dir>to scope checks to a specific folder, or--last-committo check only your latest work.
How to try it
Since we are already using uv, installing prek is a one-liner:
# The modern way: Install as an isolated tool (can be done of course with pip too)
uv tool install
# The “Magic” command that activates the hooks in this repo
prek install
Then, instead of running pre-commit run, you just run:
prek run --all-files
Note: _
prek_is newer thanpre-commit, but it is already battle-tested, powering massive projects like*Apache Airflow*. While it is still reaching full feature parity, the speed and “plug-and-play” experience make it a compelling alternative if you are tired of the friction with the legacy tool.
Conclusion: The Guardian at the Gate
We started this series by taming our dependencies. Today, we tamed our code quality.
By setting up pre-commit (or prek), you are doing your future self a massive favor. You are stopping the entropy that slowly kills codebases. You are freeing your brain from worrying about whitespace and commas so you can focus on logic and architecture.
Set it up once. Configure it. Then forget about it, and let the robots do the cleaning.
Do you have a favorite custom hook I didn’t mention? Let me know in the comments.
See you in the next installment of My Digital Arsenal.
Originally published on AI Superhero



Top comments (0)