# PEP 8 Speaks puts a style review comment on every Python pull request

> PEP 8 Speaks is a GitHub App that runs pycodestyle or flake8 over the diff of a pull request and leaves a single comment that it keeps updating as commits arrive. It needs no configuration to work, reads linter settings from your setup.cfg, and can be tuned per repository with a .pep8speaks.yml file that controls the scanner, the ignored codes and the wording of the comment itself.

**pep8speaks-org/pep8speaks** — A GitHub :octocat: app to automatically review Python code style over Pull Requests

- Repository: https://github.com/pep8speaks-org/pep8speaks
- Website: https://pep8speaks.org
- Stars: 620 · Forks: 87
- Language: Python
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/pep8speaks-org-pep8speaks

## One comment, kept up to date, and only when Python is involved

PEP 8 Speaks is a GitHub App to automatically review Python code style over pull requests. The three behaviours in the feature list are the reason it is pleasant to have in a repository.

The bot makes a single comment on the pull request and keeps updating it on new commits, so there is one thread to look at and no email to triage. The comment content is regenerated rather than appended, which means the review reflects the current state of the branch.

The bot comments only if Python files are involved, so the README recommends installing the integration on all of your repositories: it will not comment where it should not. That is a design decision worth noting, because a style bot that fires on a JavaScript pull request is a bot people mute, and a muted bot is worse than no bot.

By default the bot does not comment when there are no PEP 8 issues, and the configuration lets you change that so a clean pull request gets an acknowledgement instead of silence.

Installation is through the GitHub App listing. You go to the app page on GitHub, click the Configure button, and add the repositories or organizations you want it active on. There is nothing to install in your own repository and no configuration file is required for the integration to work.

The project is MIT licensed, the homepage is pep8speaks.org, and the repository is not archived, with the last push on 2026-10-05.

## The configuration file controls the scanner and the wording

A .pep8speaks.yml file in the root of the project is optional, and when present it overrides the defaults, which live in a file called data/default_pep8speaks.yml in the repository.

The scanner section has two settings that matter most. diff_only decides whether the entire file touched by the pull request is scanned or only the diff is scanned, and linter selects pycodestyle or flake8. The pycodestyle section, or the flake8 section depending on the linter, then carries the linter configuration, including max-line-length, which defaults to 79 in PEP 8, and an ignore list of codes to suppress.

Two more switches affect how the result is presented. no_blank_comment, if true, means no comment is made on a pull request without errors, which is the default behaviour. descending_issues_order, if true, displays the issues in descending order of line numbers in the file instead of the default order.

The message section is where you make the bot sound like part of your team. There are separate templates for the comment when a pull request is first opened and when new commits are added, each with a header and a footer, and the keyword {name} in the header is replaced with the author's username. The templates are written as they would appear over GitHub, so a link to a style guide can go in the footer. There is also a separate message for the no-errors case.

The braces around the placeholders in a YAML mapping are worth noticing, because they are how the template is written rather than a YAML feature being used for its own sake. The footer of the updated message in the example is deliberately empty, with a comment asking why to link the style guide every time.

## Configuration is looked up in the base branch first, then the head branch

Two rules about where the bot looks for settings are the ones that cause confusion when the behaviour does not match expectations.

The first is which branch is consulted. For every pull request, the bot looks for .pep8speaks.yml in the base branch, the existing one. If the file is not found there, it then searches the head branch, the incoming changes. So a pull request that introduces a configuration file does not change the behaviour of that same pull request, but it does change it for later ones. That is a sensible default, since it stops a contributor from changing the rules in the same request that the rules are applied to.

The second is the priority of linter configuration. For settings such as ignore and max-line-length, the bot looks and prioritises configurations in a defined order. First the pycodestyle section, or the flake8 section, of .pep8speaks.yml, which depends on the scanner.linter value. Then the [pycodestyle] section, or the [flake8] section, of the setup.cfg file in the root of the project, and this second step is independent of scanner.linter, so a [flake8] section of setup.cfg will also work when pycodestyle is the selected linter.

The README also explains the relationship between the two linters, noting that flake8 is a wrapper around pycodestyle with additional enforcements. That matters for the ignore list, because codes such as C406 for an unnecessary list literal come from flake8's own checks and will simply never be produced by plain pycodestyle.

## A Flask webhook service with the linters as libraries

The repository layout shows how the service is put together, and the dependencies describe the pipeline.

The core is a Flask application: there is a server.py at the root, the pep8speaks package holds the application code, and the data directory holds the default configuration. Gunicorn is the WSGI server in front of it, and the topic list for the repository includes github-webhooks, which is how the service learns about pull request activity in the first place.

The linting itself is done in process. Both pycodestyle and flake8 are dependencies rather than being shelled out to, and flake8 is a wrapper around pycodestyle, so the same code path can run either. autopep8 is also a dependency, which is the library that rewrites code to fix style automatically, and it is the reason the bot can offer a fix rather than only a complaint.

The diff handling is done with unidiff, which is what lets the scanner restrict itself to changed lines, and the results are rendered as Markdown, with markupsafe and beautifulsoup4 in the dependency list for handling the HTML that GitHub returns when it posts a comment. pyyaml reads the configuration, python-dotenv reads the environment file, and requests handles the calls back to the GitHub API.

The test setup is conventional for a Flask project. The dev dependency group adds pytest, pytest-flask and pytest-mock to the runtime dependencies, there is a conftest.py at the root, a tests directory, and the project file records a uv.lock, which means the dependency versions are pinned for reproducible installs.

## Running it yourself means a GitHub App and a container

You can use the hosted service, or you can run the bot yourself, and the second option has a documented path.

The container build is a multi-stage file. It starts from a Python 3.8 Alpine image, copies the uv binary and uvx in from the uv image, copies the lock file and project file, runs a frozen sync, then copies the application package, the server module and the data directory, and finally runs Gunicorn with four workers bound to port 8000.

The compose file describes the same thing as a service. It builds from the local directory, maps a port with a default of 8000, and runs the same Gunicorn command through uv. The environment section is where the GitHub App credentials go: an application secret key, a GitHub token, the webhook secret, a log level, a Flask debug flag and the bot username, with the bot username defaulting to pep8speaks. The restart policy is always.

The .env.sample file lists the same variables for local development:

```bash
APP_SECRET_KEY="APP_SECRET_KEY"
GITHUB_TOKEN="GITHUB_TOKEN"
GITHUB_APP_WEBHOOK_SECRET="GITHUB_APP_WEBHOOK_SECRET"
LOG_LEVEL="INFO"
FLASK_DEBUG=0
BOT_USERNAME="pep8speaks"
```

The log level there is INFO and Flask debug is off.

Two things to notice. The compose file pins four Gunicorn workers, so a self-hosted instance is a small multi-process service rather than a single process, which matters if you run several of them behind a load balancer. And the credentials model is a GitHub App installation, not a personal access token, so a self-hosted deployment needs its own app registration, its own webhook secret and its own installation on the repositories you want covered. There is a DEPLOYMENT-GUIDE.md at the top level for that, and a .circleci directory for the pipeline.

## A stalled release history and two version mismatches to watch

The release record is unusual and worth reading carefully.

The published releases are v1.9, whose title is that pep8speaks is back again, dated 2023-09-26, then v1.8 from 2020-05-24 and v1.7 from 2020-04-28. So the project went quiet for three years, came back with a new major version, and then went quiet again for the releases while the code kept moving. The last push was on 2026-10-05, and the version recorded in the project file is 1.9, matching the last tagged release.

That gap between the tag and the tree is the practical thing to understand. If you deploy from the repository, you are running code that is newer than any release, and the changelog in the tag will not describe it. If you pin the release, you are pinning code from 2023, whose dependencies are pinned at older versions of Flask, flake8 and pycodestyle.

There are also two small inconsistencies in the configuration. The project file requires Python 3.9.0 or later, while the container image is built from a Python 3.8 Alpine base. A container built from that Dockerfile will not satisfy the declared requirement, so an image build may work by accident until a dependency uses a 3.9 language feature, and the fix is to move the base image forward rather than to relax the requirement.

The second is the .pep8speaks.yml in the repository root, which is the project's own configuration file. It is the file the bot would read when it looks at this repository, and it is a working example of the format described above.

## Where a style bot is the wrong answer

Three situations argue against installing it.

The first is a team that wants style failures to block a merge. The bot comments, but a comment is advisory. If the convention must be enforced, running the linter in continuous integration gives you a failing check, and GitHub Actions can run flake8 with your own configuration and the full plugin set, which the bot does not offer. The bot's value is visibility and habit, not enforcement.

The second is a team with a rich linter configuration. The bot honours a limited slice of the linter's settings, the ignore list and the maximum line length among them, and it reads them from two specific places. A project that relies on flake8 plugins, per-file ignores, exclusion patterns or a custom select list will find that most of it is not honoured, and the bot will report issues the team has explicitly configured away, which is worse than no bot.

The third is a repository that is not Python, or a monorepo where Python is a small part. The bot already avoids commenting on pull requests without Python files, so this is a mild case, but the same reasoning applies to a project that has moved to a formatter such as black, where a style linter is checking rules the formatter has already decided.

One more limitation is worth naming plainly. The bot is a service that receives your repository content over a webhook and calls back into the GitHub API with a token. A self-hosted deployment makes that token yours, and the .env.sample shows a default secret value in the compose file, which you must replace. The hosted service is the simpler choice for most teams, and the trade is that your code passes through someone else's service.

## Conclusion

PEP 8 Speaks fits a team whose Python projects already have a style convention and no automation for it, and it does not fit a team that wants the full power of a linter configuration, since the bot honours a limited set of linter settings, or a team that would rather run the linter in CI where failures block a merge. Before enabling it, read the diff_only setting, because by default only the changed lines are scanned and pre-existing violations stay invisible. Note that the last push was on 2026-10-05 while the last published release is v1.9 from 2023-09-26, so you are likely running a newer tree than any tagged release, and the project file asks for Python 3.9 while the container image is built on Python 3.8.

## FAQ

### How do I install PEP 8 Speaks on a repository?

Go to the app page on GitHub, click the Configure button, and add the repositories or organizations you want it active on. A configuration file is not required for the integration to work, and the bot only comments on pull requests that involve Python files.

### How do I configure the linter that PEP 8 Speaks uses?

Add a .pep8speaks.yml file in the project root with a scanner section where diff_only selects scanning only the diff or the whole touched file, and linter selects pycodestyle or flake8. Linter settings such as max-line-length and ignore are then read from the matching section of that file, or from the [pycodestyle] or [flake8] section of setup.cfg.

### Does PEP 8 Speaks read setup.cfg?

Yes. The bot can read your setup.cfg for the [flake8] and [pycodestyle] sections, and that lookup is independent of the scanner.linter setting, so a [flake8] section in setup.cfg also applies when pycodestyle is the chosen linter. The .pep8speaks.yml file takes priority over setup.cfg.

### Can I self-host PEP 8 Speaks?

The repository ships a Dockerfile and a docker-compose file. The compose service runs the Flask application under Gunicorn with four workers on port 8000 and takes its configuration from environment variables including the app secret key, the GitHub token, the webhook secret, the log level and the bot username, with a DEPLOYMENT-GUIDE.md at the top level.

### Why does PEP 8 Speaks only post one comment on a pull request?

The bot makes a single comment on the pull request and keeps updating it on new commits, so there is one thread to review and no stream of email notifications. By default it does not comment at all when there are no PEP 8 issues, and the no_blank_comment setting controls that behaviour.

## Sources

- [License: MIT](https://github.com/pep8speaks-org/pep8speaks/blob/master/LICENSE)
- [pep8speaks-org/pep8speaks on GitHub](https://github.com/pep8speaks-org/pep8speaks)
- [Project website](https://pep8speaks.org)
- [README](https://github.com/pep8speaks-org/pep8speaks/blob/master/README.md)
- [Releases](https://github.com/pep8speaks-org/pep8speaks/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/pep8speaks-org-pep8speaks
