Adding a linter to a codebase that's never had one is always a two-part story: the config, and what it actually finds. Both matter more than the setup guides usually let on. Here's exactly what happened wiring Ruff into Tabeer.ai's Django backend for the first time.
Install and Configure
pip install ruff
# backend/pyproject.toml
[tool.ruff]
line-length = 120
target-version = "py313"
exclude = ["migrations", ".venv"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "DJ"] # pycodestyle, pyflakes, isort, pyupgrade, bugbear, flake8-django
ignore = ["E501"] # line length is the formatter's job, not lint's
[tool.ruff.lint.isort]
known-first-party = ["config", "accounts", "exams", "billing", "content", "feedback", "rewards", "workflow", "community", "blog", "scholarships"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
DJ (flake8-django) is worth calling out specifically — it's the rule set that catches
Django-specific issues generic Python linters miss entirely: model field ordering conventions,
missing __str__ methods, that category of thing.
The First Run
ruff check . --statistics
12 F841 unused-variable
11 I001 unsorted-imports
7 DJ012 django-unordered-body-content-in-model
6 F401 unused-import
3 DJ008 django-model-without-dunder-str
2 B017 assert-raises-exception
2 B904 raise-without-from-inside-except
1 B007 unused-loop-control-variable
1 B905 zip-without-explicit-strict
Found 45 errors.
45 findings on a codebase that had simply never been linted before — not unusual, and not a sign of a badly-written codebase, just the expected result of introducing static analysis to code that grew without it.
Auto-Fix the Mechanical Stuff First
ruff check . --fix
17 of the 45 auto-fixed cleanly — import sorting and unused imports, purely mechanical, zero judgment required. Then the formatter:
ruff format .
91 files reformatted. That number looks alarming until you remember it's cosmetic — quote-style and whitespace normalization, not logic changes. The move that actually matters here: run the full test suite immediately after, before touching anything else, to confirm formatting alone didn't break anything:
python manage.py test
# Ran 659 tests in 63.5s — OK
Clean. As expected for a pure-formatting pass, but "as expected" isn't the same as "don't verify it."
The 28 That Needed a Human
Here's where a first-time lint run actually earns its keep — not the mechanical fixes, the judgment calls:
- Unused variables that mattered anyway.
ruff check --unsafe-fixes --fix --select F841auto-fixed most of these, but critically, it convertedunused_var = Model.objects.create(...)into a bareModel.objects.create(...)— dropping the pointless assignment while preserving the actual database write. Deleting the whole line instead (an easy mistake with a blunter find-and-replace approach) would have silently removed real test setup. - A blind exception assertion, tightened to what's actually expected:
self.assertRaises(Exception)around aunique_togetherviolation becameself.assertRaises(IntegrityError)— the test now proves the specific failure mode it's meant to guard, not just "something went wrong." - Missing
raise ... fromon two deliberate error-message replacements (catching a generic exception, raising a cleaner one for the caller) —from Noneadded explicitly, so it's clear the exception chain break is intentional, not an oversight. - An implicit-length-match
zip()givenstrict=True, since the two sequences being zipped are provably the same length by construction — cheap insurance against a silent truncation bug if that invariant ever breaks later.
Two categories — Django model field/method ordering (DJ012, 7 instances) and missing
__str__ methods (DJ008, 3 instances) — got explicitly deferred, added to the ignore list
with a comment explaining why: real findings, but reordering class members and writing
meaningful __str__ representations across production model files is a judgment-call pass
that deserves its own review, not something to rush through while wiring up the linter itself.
Why Not Black Too
Ruff's formatter is a from-scratch, Black-compatible reimplementation — same output style, dramatically faster (it's written in Rust). Installing Black alongside Ruff would mean two tools competing to own the same job, for the same result, at a fraction of the speed. Ruff replaced the need for Black entirely rather than sitting next to it — more on that specifically here.
What This Actually Buys
A CI check that catches unused imports, dead variables, unsorted imports, and a handful of real correctness issues (blind exception handling, missing exception chaining) before they land in a PR — not hypothetically, demonstrated: this exact first run found and fixed 45 real issues in a codebase every one of those PRs had already been reviewed and merged without a linter catching them.
If you're introducing linting to a codebase that's never had it and want the first pass done right — auto-fix what's safe, hand-fix what needs judgment, defer what needs its own review rather than rushing it — get in touch.
By Shahid Malik