The Push-and-Pray Habit
I used to push code at 11 PM and hope for the best.
You know the drill. You finish a feature, run it once or twice, and git push. Tomorrow’s Felix can deal with the linter errors. Tomorrow’s Felix can figure out why the tests broke in CI. Problem is — tomorrow’s Felix is just you, with less sleep and more coffee.
That changed when a senior developer I worked with in government IT pulled me aside after I broke the build pipeline for the third time in a week. “If you’re relying on CI to tell you your code is broken,” he said, “you’re using CI wrong.”
He introduced me to pre-commit hooks. And honestly? It was one of those moments where you realize you’ve been working harder than you needed to. A few lines of YAML later, and my commits were cleaner, my CI pipeline was quieter, and I stopped dreading the 3 AM Slack notification that says “build failed.”

What Pre-commit Hooks Actually Do
Pre-commit hooks are scripts that run automatically before git lets you finalize a commit. They catch problems at the earliest possible moment — right there on your machine, before your code ever leaves your laptop.
Think of them as a personal QA team that checks every commit. They can format your code consistently, catch syntax errors and type mistakes before they reach CI, block secrets and API keys from being committed, run linters to enforce your team’s style guide, and check for common security vulnerabilities. The best part? Once you set them up, they run automatically. Git remembers for you.
If you’ve been sharpening your Python skills with decorators or building side projects, pre-commit hooks keep your codebase clean without adding extra steps to your workflow. They’re like that friend who tells you there’s spinach in your teeth before you walk into the meeting.
Installing Pre-commit
The pre-commit framework is a Python package that manages your git hooks. It handles installation, updates, and execution for you — no manual hook scripting needed.
Install it with pip:
pip install pre-commit
Or if you’re using uv (which I’ve switched to recently — it’s fast):
uv pip install pre-commit
Now create a .pre-commit-config.yaml file in your project root. This is where you define what checks run before each commit.
Essential Hooks for Any Python Project
Here’s a configuration I use across almost every Python project. It covers formatting, linting, type checking, and security — the four pillars of code quality.
1. Basic Cleanup: Trailing Whitespace and File Endings
Start with the basics. These built-in hooks handle the stuff that drives code reviewers crazy:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
trailing-whitespace and end-of-file-fixer clean up formatting noise. check-yaml validates your config files. check-added-large-files prevents accidentally committing a 500MB dataset. And detect-private-key — well, let’s just say there’s a reason every company eventually has a “the intern committed the SSH key” story.
2. Code Formatting with Ruff
Ruff is the tool I wish existed five years ago. It replaces Flake8, isort, and Black in a single package — and it’s written in Rust, so it’s blindingly fast.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
The --fix flag tells ruff to automatically correct issues it can fix. The ruff-format hook formats your code to match a consistent style — no more debating line length in pull request comments.
3. Type Checking with MyPy
Type hints are one of those Python features that feel optional until you work on a project with more than 5,000 lines. Then they become essential. MyPy checks your type hints for errors before runtime:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
Add additional_dependencies for any third-party libraries your project uses. Without them, MyPy will complain about missing type stubs.
The Complete Config File
Put it all together and your .pre-commit-config.yaml looks like this:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
Run this once to install the hooks into your git repo:
pre-commit install
That’s it. Every git commit from this point forward will trigger these checks automatically.
Testing It Out
Let’s test this with a deliberately messy Python file. Create a file called broken.py:
# broken.py
def add_numbers(a: int, b: int) -> int:
return a + b
result = add_numbers("hello", 5)
print(result)
When you try to commit this, pre-commit kicks in. Ruff catches the trailing whitespace and formatting issues. MyPy catches the type error — you’re passing a string where an int is expected. The commit is blocked until you fix the problems.
This is exactly what you want. Catch the bug now, on your machine, not when it’s running in production at 2 AM.
Running Hooks on Existing Code
If you’re adding pre-commit to an existing project — and you should — run it against all files first:
pre-commit run --all-files
This scans your entire codebase and shows you everything that needs fixing. On my current project, the first run caught 47 issues — formatting inconsistencies I’d been ignoring for months, a YAML file with invalid syntax, and a file that was 14MB that I had no idea was in the repo.
The same principle that makes safe string handling critical in C applies here: catch problems at the source. The earlier you find a bug, the cheaper it is to fix.
Combining Pre-commit with CI
Pre-commit hooks and CI/CD pipelines aren’t competitors — they’re partners. Pre-commit catches problems instantly on your machine. CI catches them in the shared environment where everyone’s code comes together.
Run the same hooks in both places. In your GitHub Actions workflow:
- name: Run pre-commit
uses: pre-commit/[email protected]
This way, even if someone skips hooks locally (you can do that with git commit --no-verify, but please don’t make it a habit), CI acts as the backstop. Two layers of defense are better than one.
Once your code passes linting and formatting, the next natural step is writing tests. I covered pytest in depth in a previous guide — pre-commit hooks and a solid test suite together form the foundation of code you can deploy on a Friday afternoon and actually enjoy your weekend.
Beyond the Basics
Once you’re comfortable with the essentials, pre-commit can do much more:
- Bandit — scans for common Python security issues
- Hadolint — lints your Dockerfiles
- ShellCheck — catches bugs in shell scripts
- Commitizen — enforces conventional commit message formats
- TruffleHog — scans for secrets and credentials in your codebase
Each of these tools plugs into the same pre-commit framework. Once you have the YAML structure down, adding a new hook is a three-line change.
Why I Stopped Pushing and Praying
That developer who pulled me aside years ago taught me something that stuck: good code isn’t just about writing the right logic. It’s about building the right habits. Pre-commit hooks are one of those habits that pays dividends every single day.
These days, when I push code at 11 PM, I actually sleep afterward. Not because I write perfect code — I definitely don’t — but because I’ve got a safety net that catches the obvious mistakes before they become embarrassing Slack messages.
If you’re managing an ICT team like I do, or you’re a solo developer trying to keep your side projects from turning into spaghetti, do yourself a favor: spend 15 minutes setting up pre-commit hooks. Your future self — the one who won’t be debugging a formatting error at 3 AM — will thank you.