Article

The Python Tools I Use to Keep Code Quality Under Control

Ruff, Pylint, Vulture, Black, mypy, pytest and coverage.py solve different problems. The value comes from understanding those boundaries rather than simply installing more tools.

by Gary Worthington, More Than Monkeys

Python makes it remarkably easy to start building software. That is one of its greatest strengths, but it can also become a weakness because a project can grow quickly without much thought, allowing inconsistent formatting, unused code, questionable design decisions and subtle type errors to accumulate just as quickly.

The language will happily let you do a great many things, although that does not necessarily mean you should. Automated development tools help by providing fast feedback while we are writing code, catching straightforward mistakes before review, and maintaining a consistent standard without turning every pull request into a prolonged discussion about import order or line wrapping.

The tools I regularly reach for include Ruff, Pylint, Vulture, Black, mypy, pytest and coverage.py. They are sometimes grouped together under the broad heading of “code quality”, but that framing is too imprecise to be useful — they operate at different levels and answer very different questions.

Ruff provides fast linting, import sorting and automated fixes, while Pylint examines potential programming errors, design problems and maintainability concerns. Vulture looks for code that appears to have outlived its purpose, and Black makes formatting deterministic.

mypy checks whether values are being used consistently with their declared types, pytest verifies behaviour by executing the software, and coverage.py shows which parts of the code those tests actually exercised. Together, they cover a meaningful range of risks, although they still depend on sensible configuration and engineering judgement.

Code quality is not one problem

Before comparing individual tools, it helps to separate the areas we are trying to cover. A mature Python development workflow usually needs feedback on formatting, linting, static analysis, type safety, dead code, executed behaviour and test coverage.

No single tool covers all of those concerns, which is important to remeber because teams often install several tools that provide overlapping checks and then conclude that they have built a comprehensive quality process. In reality, they may simply have created multiple ways to report the same unused import.

The aim should be to cover distinct failure modes without creating duplicated or contradictory noise. Each tool should have a defined responsibility, and any overlap should be deliberate rather than an accidental consequence of adding every popular package to the development dependencies.

Ruff: fast enough to change the workflow

Ruff has become difficult to ignore because it combines an extremely fast Python linter with import sorting, automated fixes and formatting support. It implements hundreds of rules associated with tools and plugins including Pyflakes, pycodestyle, isort, pyupgrade and flake8-bugbear.

It can lint code:

ruff check .

Apply safe automatic fixes:

ruff check . --fix

And format the project:

ruff format .

For many projects, Ruff can replace several separate tools with one executable and one configuration file:

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
select = [
"E",
"F",
"B",
"I",
"UP",
"SIM",
]

This configuration enables pycodestyle errors, Pyflakes checks, bugbear rules, import sorting, Python syntax upgrades and simplification suggestions.

Where Ruff earns its place

The obvious advantage is speed, but the effect of that speed matters more than the benchmark itself. A check that completes almost immediately can run whenever a file is saved, before every commit, across the entire repository in continuous integration, and during automated code-generation workflows without becoming a noticeable interruption.

That changes linting from an occasional quality gate into continuous feedback. Developers can address issues while the relevant code is still fresh in their minds rather than discovering a collection of avoidable failures after pushing a branch. We’ve all been there, and that stuff kills productivity.

Ruff also reduces tool sprawl. Instead of maintaining separate configurations and dependencies for linting, import sorting, syntax upgrades and several plugins, a team can manage much of that behaviour in one place.

Automatic fixes are another major advantage because Ruff can remove unused imports, sort imports, modernise syntax and repair many other findings without repetitive manual edits. When introducing new rules to an existing codebase, it can often resolve much of the resulting work automatically and leave developers to concentrate on cases that require judgement.

Where Ruff falls short

Ruff covers a great deal, but it does not cover everything. It implements rules directly and does not support arbitrary third-party plugins in the same way as more traditional plugin-based tools, which means specialist or organisation-specific checks may still require separate tooling.

It also implements some Pylint rules without fully replacing Pylint’s deeper inference and broader design analysis. Ruff can find suspicious constructs very quickly, but it does not attempt to provide every form of architectural or object-level reasoning associated with Pylint.

Nor is Ruff a type checker. It can identify certain annotation mistakes and typing-related anti-patterns, but it does not establish that values flow through the application consistently with their declared types.

There is also a temptation to enable every available rule because Ruff makes doing so easy. More rules do not automatically produce better code, and an overly aggressive configuration can create noise, encourage meaningless suppressions and train developers to ignore the output.

A better approach is to start with a deliberate rule set and add categories only when the team understands the behaviour they are intended to encourage.

Pylint: broader and more opinionated analysis

Pylint goes beyond fast linting by analysing code without running it and reporting potential programming errors, naming problems, questionable constructs, duplicated code and structural smells. It can warn about functions with too many arguments, classes with too many responsibilities, excessively nested logic and suspicious attribute access.

Running it is simple:

pylint src

The more difficult part is deciding which of its opinions are relevant to the codebase, because its default configuration can be considerably more prescriptive than many teams need.

Where Pylint earns its place

Pylint can identify problems that faster linters will not. Consider this function:

def generate_invoice(
customer_id: int,
line_items: list[dict],
discount_code: str,
shipping_address: str,
billing_address: str,
payment_method: str,
send_confirmation: bool,
) -> dict:
"""Generate and return an invoice for the given order details."""
...

Ruff has nothing to report. The function is clearly named, annotated and free from obvious issues. Pylint disagrees:

billing.py:1:0: R0913: Too many arguments (7/5) (too-many-arguments)

This is not a verdict. The function may be perfectly reasonable. But the warning prompts a useful question: should seven parameters be collapsed into a data structure? Is this function carrying the weight of two? That kind of prompt is what distinguishes Pylint from a fast linter.

Beyond argument counts, Pylint can surface overly complex functions, broad exception handling, inconsistent return paths, invalid attribute access, duplicated code, excessive coupling and naming inconsistencies that warrant further investigation.

Used well, Pylint behaves less like a basic linter and more like an automated reviewer with strong opinions about maintainability. That can be valuable, provided the team treats its findings as prompts for reflection rather than unquestionable instructions.

Where Pylint falls short

The main problem is noise. Running Pylint against an established project with its default configuration may produce so many messages that genuinely useful findings become difficult to distinguish from conventions the team simply does not share.

Framework-heavy Python can also confuse static analysis. Django models, dynamically registered plugins, descriptors, dependency injection and runtime-generated attributes can produce false positives because the analyser is trying to reason about behaviour that deliberately emerges at runtime.

The answer is careful configuration, although that introduces its own maintenance cost because the team must agree which warnings matter and which should be disabled:

[tool.pylint."MESSAGES CONTROL"]
disable = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"too-few-public-methods",
]
A project receiving 9.8 out of 10 tells you that it satisfies a particular Pylint configuration, not that its architecture is sound, its tests are meaningful or its product works correctly. The individual findings can be useful. The score is considerably less interesting.

Vulture: finding code that has outlived its purpose

Most tools focus on code that exists and appears questionable, whereas Vulture asks whether anything is using that code at all. It identifies unused functions, methods, classes, variables, properties and imports.

A basic run looks like this:

vulture src tests

Including the tests is important because production code that is referenced only by a test should not normally be classified as unused. Running Vulture across both application and test code can also expose obsolete fixtures, helpers and test utilities.

Vulture supports confidence thresholds, exclusions and whitelists:

[tool.vulture]
paths = ["src", "tests"]
min_confidence = 80
sort_by_size = true
ignore_decorators = [
"@app.route",
"@router.*",
]

Where Vulture earns its place

Dead code has a genuine maintenance cost because every unused function is another unit of behaviour that somebody may read, test, modify or attempt to preserve. It increases the apparent size of the system without increasing its capability.

It also creates uncertainty. A developer finding an unfamiliar function must determine whether it is still used through an obscure execution path, whether it represents a partially completed feature, or whether deleting it will break an integration nobody remembered existed.

Vulture is useful during large refactors, framework migrations, feature removal, package consolidation and long-running maintenance work. It is particularly effective at finding code left behind after a feature has been removed in stages, where the endpoint disappears first but the service, helper functions and constants remain because nobody is entirely certain whether they are safe to delete.

Where Vulture falls short

Python is highly dynamic, which makes dead-code analysis difficult. Functions can be called through reflection, decorators, dependency injection, plugin registries, string-based configuration or framework discovery, all of which may make actively used code appear unused to a static analyser.

The reverse can also happen, with static analysis failing to establish that a piece of code is genuinely unreachable. Vulture therefore produces candidates for investigation rather than deletion instructions.

I would not configure an automated process to remove everything Vulture reports. Even high-confidence findings should be reviewed, tested and understood, particularly in framework-driven applications where implicit registration is common. Whitelists and decorator exclusions reduce the noise, but some human review remains necessary.

Black: consistent formatting without debate

Black takes a deliberately uncompromising approach to formatting. It rewrites Python code according to a consistent style and offers relatively little configuration compared with more traditional formatters.

black src tests

It can also verify formatting without changing files:

black --check src tests

The main value of Black is not that every formatting decision is objectively better than all alternatives. Its value is that those decisions are automated, deterministic and consistent across the repository.

Where Black earns its place

Black removes formatting as a recurring code-review concern because developers no longer need to debate line wrapping, spacing or whether a particular expression should span two lines or four. The formatter makes the decision, and the team can spend its time discussing behaviour, design and risk.

That consistency has practical benefits. Pull requests contain fewer cosmetic changes, merge conflicts caused by manual reformatting are reduced, new developers do not need to memorise a lengthy style guide, and continuous integration can enforce formatting regardless of individual editor settings.

Black is especially useful in larger teams, where personal formatting preferences would otherwise create unnecessary churn. The same source and Black version should produce the same result regardless of who runs it, which makes formatting a repeatable build concern rather than a subjective review activity.

Where Black falls short

Black is opinionated by design, which means it will occasionally produce output that an individual developer finds less readable. There are limited opportunities to persuade it otherwise, and that can be frustrating around large data structures, complex boolean expressions or code where manual layout carries useful meaning.

It also overlaps with Ruff Format. For a new project already using Ruff, Ruff Format is the cleaner choice: it keeps formatting in the same tool and reduces the number of dependencies and configuration files to maintain. For a project already using Black, migrating rarely justifies the disruption — Black’s behaviour is well understood, widely supported and unlikely to cause problems. What matters most is the commitment to one formatter. Running both against the same codebase is unnecessary and will eventually produce conflicting output.

mypy: checking assumptions about types

Python is dynamically typed, but that does not mean types are absent. Every value still has a type at runtime, although the language does not require us to declare those relationships or verify them before execution.

mypy uses type annotations to analyse those relationships without running the code:

mypy src

Consider this example:

def calculate_total(prices: list[float]) -> float:
"""Calculate the total of a collection of prices."""
return sum(prices)

values = ["10.00", "12.50"]
calculate_total(values)

The code is valid Python syntax, and Ruff may have nothing significant to report. mypy can still identify that list[str] is incompatible with the declared list[float] parameter.

Where mypy earns its place

mypy catches errors that linting cannot, including incorrect argument types, incompatible return values, unsafe handling of None, missing attributes, invalid collection contents and inconsistent implementations of interfaces.

Type checking becomes increasingly valuable as a codebase grows. Within a small function, it may be obvious what a value represents, but that context becomes harder to retain as data moves across modules, services and transformation layers.

Good annotations document those expectations in a form that tooling can verify. They improve editor support, make interfaces easier to understand, and catch cases where an apparently harmless refactor changes a contract in a way that downstream code does not expect.

mypy also supports gradual adoption, so a team does not need to make an entire legacy codebase strict before receiving value. It can start with new modules, public interfaces or important business logic and strengthen the configuration over time:

[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
no_implicit_optional = true

Where mypy falls short

Type annotations add maintenance work, and poorly chosen annotations can make code harder to read. Inaccurate annotations are worse because they create the appearance of safety without accurately describing runtime behaviour.

Third-party libraries can also introduce friction when they provide incomplete or inaccurate type information. Stub packages, plugins and targeted exclusions may be required, particularly in applications built around dynamic frameworks.

Django models, decorators, metaprogramming and runtime-generated attributes can be difficult for static type checkers to understand without additional support. Teams must decide how much complexity they are willing to accept in exchange for stronger guarantees.

There is also a risk of mistaking type correctness for behavioural correctness. A function can accept the right types, return the declared type and still calculate the wrong answer.

mypy checks whether the declared contracts are internally consistent. It does not prove that those contracts represent the correct business behaviour.

pytest: checking what the software actually does

Static analysis can identify suspicious code, but eventually the software has to run. pytest is my default testing framework for Python because it supports simple assertions, reusable fixtures, parametrisation and a large plugin ecosystem without requiring much ceremony.

A straightforward test looks like this:

def test_calculate_total() -> None:
"""Verify that prices are added together."""
assert calculate_total([10.0, 12.5]) == 22.5

Parametrisation allows the same behaviour to be checked against several examples:

import pytest

@pytest.mark.parametrize(
("prices", "expected"),
[
([], 0.0),
([10.0], 10.0),
([10.0, 12.5], 22.5),
],
)
def test_calculate_total(
prices: list[float],
expected: float,
) -> None:
"""Verify totals for representative price collections."""
assert calculate_total(prices) == expected

Where pytest earns its place

pytest makes tests easy to write and easy to read. Its use of ordinary assert statements is one of its strongest features because failures include useful introspection without requiring developers to learn a large collection of specialised assertion methods.

Fixtures allow setup and dependencies to be shared across tests:

import pytest

@pytest.fixture
def prices() -> list[float]:
"""Provide representative prices for a test."""
return [10.0, 12.5]

def test_calculate_total(prices: list[float]) -> None:
"""Verify that the supplied prices are totalled."""
assert calculate_total(prices) == 22.5

Its plugin ecosystem also provides support for Django, asynchronous code, parallel execution, HTTP mocking, databases, coverage and property-based testing.

Most importantly, pytest checks observable behaviour. Linters and type checkers can tell us whether code looks inconsistent or suspicious, but tests allow us to state what the software should do and verify that it continues doing it after the implementation changes.

Where pytest falls short

Tests are only as useful as the behaviours they assert. A large suite can still provide weak protection if it concentrates on implementation details, avoids meaningful edge cases or contains assertions that prove very little.

Tests can also become too tightly coupled to internal structure. When that happens, harmless refactoring breaks large portions of the suite even though externally visible behaviour remains unchanged.

Fixtures are powerful, but excessive abstraction can make a test difficult to understand because its actual setup is spread across several files and layers of indirect dependencies. The test may appear concise while hiding a considerable amount of machinery.

Execution speed becomes another concern as the suite grows. Unit tests should usually run quickly, but integration tests involving databases, networks or external services can make the feedback loop increasingly slow.

pytest provides an excellent framework, but it does not decide what should be tested, where the boundaries should sit, or whether an assertion represents meaningful evidence. Those are design questions that no framework resolves for you.

coverage.py: showing what the tests exercised

coverage.py records which parts of the code execute during a test run. It is commonly used alongside pytest:

coverage run --branch -m pytest
coverage report --show-missing

A configuration can define the source directories to measure and the lines to exclude:

[tool.coverage.run]
branch = true
source = ["src"]

[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
]

Statement coverage reports whether individual lines executed, while branch coverage checks whether the possible outcomes of conditional paths were exercised.

Where coverage.py earns its place

Coverage information gives developers visibility into what the test suite actually ran. The existence of tests alone does not tell us which parts of the application they protect, so a coverage report can reveal untested error handling, conditional branches that never execute, legacy modules with little protection and newly added code without corresponding tests.

Branch coverage is particularly useful. Consider the following code:

def apply_discount(total: float, is_member: bool) -> float:
"""Apply a member discount to an order total."""
if is_member:
return total * 0.9
return total

A test may execute the function and cover the discount branch while never testing the non-member path. Branch coverage makes that gap visible even when the report suggests that the relevant lines have been executed.

coverage.py also integrates well with continuous integration. Teams can publish reports, compare coverage changes and fail builds when coverage drops below an agreed threshold.

Used properly, it provides a map of where the evidence is thin rather than a simple pass-or-fail judgement on the test suite.

Where coverage.py falls short

Coverage is evidence of execution, not evidence of correctness. A test can execute every line of a function without making a useful assertion, so it is entirely possible to achieve 100 per cent coverage while failing to verify the behaviour that matters.

Coverage targets can also create poor incentives. When the percentage becomes the objective, developers may write shallow tests purely to increase the number, producing a healthier dashboard without making the software meaningfully safer.

Repository-wide targets can hide local problems as well. A project with high overall coverage may still contain critical modules with little or no useful protection.

I prefer to use coverage as a diagnostic tool rather than a performance metric. It helps identify behaviour we have not exercised, paths that deserve additional tests, changes that have reduced existing protection, and important modules that are significantly less tested than the rest of the system.

The percentage provides useful context, but it should never replace attention to what the tests are actually verifying.

Should these tools all run together?

Not necessarily. The useful question is not whether they can coexist, because they can, but whether each tool contributes distinct information without creating unnecessary duplication.

Ruff and Pylint

Ruff and Pylint can complement one another when their responsibilities are clearly separated. Ruff can handle fast, high-volume checks on every save and commit, while Pylint can run a narrower and deliberately configured collection of deeper checks in continuous integration.

The important word is “configured”. Running every Pylint warning beside a broad Ruff configuration is likely to produce more noise than value.

Ruff and Vulture

Ruff and Vulture address different concerns. Ruff can detect unused imports and variables in particular contexts, while Vulture performs broader dead-code analysis across functions, methods, classes and modules.

Vulture does not necessarily need to run on every file save. It is often more valuable across the whole project during continuous integration, major refactoring or periodic maintenance.

Black and Ruff Format

Black and Ruff Format both provide deterministic formatting, so the sensible approach is to choose one. A project already using Black may gain little from migrating unless consolidation provides a meaningful benefit, while a new project using Ruff may prefer Ruff Format to reduce the number of dependencies and configuration files.

Running both against the same files is unnecessary and you will begin to hate your life choices if you listen to both!

Linting and mypy

Linting and type checking are complementary rather than interchangeable. Ruff checks code against rules relating to correctness, style and maintainability, while mypy checks whether declared type relationships remain consistent.

A codebase can satisfy Ruff while still containing type errors, just as it can satisfy mypy while containing poor naming, unnecessary complexity or unused imports.

pytest and coverage.py

pytest runs the tests, while coverage.py records which code those tests exercise. Coverage only has meaning in the context of an executed test suite, although pytest remains valuable even when no coverage report is generated.

Used together, they provide different forms of feedback: behavioural evidence and visibility into the gaps in that evidence.

The stack I would choose today

For a new Python project, my default tool set would be: Ruff for linting and automated fixes, Ruff Format for formatting, mypy for type checking, Pylint for selectively configured deeper analysis, Vulture for dead-code detection, pytest for behavioural testing, and coverage.py with branch coverage for test visibility.

The selection matters less than placing each tool at the right point in the development cycle.

On save and in pre-commit hooks, Ruff and the Ruff format should run automatically. Both complete fast enough not to interrupt work, and Ruff’s automatic fixes mean many findings resolve themselves without manual intervention. Catching issues at this stage means addressing them while the context is still fresh.

During CI, mypy should run against the full codebase. Type errors accumulate quickly when the check is infrequent, and running it only occasionally creates a misleading sense of safety. pytest and coverage.py naturally belong here too: the full test suite runs, coverage is reported, and builds can be configured to fail if protection drops below an agreed threshold.

Also in CI, but with more discretion: Pylint and Vulture. Both are worth running, but their value depends on careful configuration. Pylint should cover a deliberately narrowed set of checks rather than its full default output. Vulture is most useful across the whole project, particularly during refactoring and maintenance work.

The goal is not to make every tool run everywhere. It is to place each check at the point where it gives useful feedback without disrupting the development workflow.

Tools should reduce friction, not create it

The purpose of development tooling is not to maximise the number of warnings. It is to shorten feedback loops, remove repetitive review comments and catch defects at the cheapest sensible point.

A useful finding should lead to the code being improved, the rule being deliberately suppressed with a clear reason, or the rule being removed because it does not represent the team’s standard. What we should avoid is a fourth outcome, where developers become accustomed to ignoring permanently noisy output.

That is why configuration matters as much as tool selection. Teams should start with a small and useful set of rules, agree what those rules are intended to protect, automate them, and strengthen the standard as the codebase and team mature.

Ruff has become my preferred linting foundation because it consolidates common Python checks and makes continuous feedback practical. Pylint remains useful when I want deeper analysis, Vulture addresses the separate problem of dead code, and Black or Ruff Format removes formatting debates from code review.

mypy catches inconsistencies in typed contracts, pytest verifies behaviour, and coverage.py exposes the parts of that behaviour the test suite has not exercised.

The real objective is not to produce code that keeps a collection of command-line tools happy. It is to build software that is understandable, maintainable and demonstrably correct, with tooling that helps us find the places where it probably is not.

Gary Worthington is a software engineer, delivery consultant, and fractional CTO who helps teams move fast, learn faster, and scale when it matters. He writes about modern engineering, product thinking, and helping teams ship things that matter.

Through his consultancy, More Than Monkeys, Gary helps startups and scaleups improve how they build software, from tech strategy and agile delivery to product validation and team development.

Follow Gary on LinkedIn for practical insights into engineering leadership, agile delivery, and team performance.


The Python Tools I Use to Keep Code Quality Under Control was originally published in Python in Plain English on Medium, where people are continuing the conversation by highlighting and responding to this story.