pyupgrade: the mechanical half of a Python 3 migration
A tool (and pre-commit hook) to automatically upgrade syntax for newer versions of the language.
At a glance
- What is it?
- A small rewriting tool that turns Python 2 idioms into their modern equivalents in place, understands six.PY2 guards well enough to delete dead branches, and gives up rather than produce code that will not parse.
- Who is it for?
- pyupgrade occupies a narrow slot that most Python tooling leaves empty. Ruff's pyupgrade rules cover a related set of modernizations, and formatters like Black will normalize some of this by hand, but neither of them deletes a dead Python 2 branch by proving the condition false or strips a `__future__` import that your minimum version no longer needs.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 32 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Installing it and wiring it into pre-commit
Installation is a single pip command:
pip install pyupgradeThe more interesting half of the README is the second one, because running pyupgrade on every commit is how the tool earns its keep. It ships as a pre-commit hook, and the sample configuration pins a specific release:
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgradeThat four-line stanza is the whole integration. The hook modifies files in place, so a commit either contains upgraded syntax or fails until you accept the rewrite, which is the property you want from this class of tool. Pinning `rev` to an exact tag rather than a branch is what pre-commit expects and it means a pyupgrade release can never change your formatter output without a deliberate commit on your side.
The rest of the invocation surface is flags rather than configuration. Several transformations are opt-out, which is how you can disagree with the maintainer's taste on one specific rewrite without giving up the other forty. `--keep-percent-format` preserves printf-style string formatting. `--keep-mock` leaves `mock` package imports alone instead of rewriting them to `unittest.mock`. And a family of `--py3X-plus` flags declare the oldest Python your project supports, which unlocks the more aggressive deletions described later.
The collection rewrites, one diff block at a time
The bulk of the README is a catalogue of transformations, and reading it is the fastest way to form a view of what the tool considers settled. The set literal rules come first and cover the whole family:
-set(())
+set()
-set([])
+set()
-set((1,))
+{1}
-set((1, 2))
+{1, 2}
-set([1, 2])
+{1, 2}
-set(x for x in y)
+{x for x in y}
-set([x for x in y])
+{x for x in y}`dict((a, b) for a, b in y)` becomes `{a: b for a, b in y}` on the same principle. `defaultdict` calls with an unnecessary lambda collapse to the type itself, and the list is instructive because it shows how far the matching goes: `lambda: list()`, `lambda: ()`, `lambda: 0`, `lambda: 0j` and `lambda: ''` all become `list`, `tuple`, `int`, `complex` and `str`.
Format specifiers get positional indices dropped, so `'{0} {1}'.format(1, 2)` becomes `'{} {}'.format(1, 2)`, and that holds for implicitly concatenated literals too, where `'{0}' '{1}'` becomes `'{}' '{}'`.
String encoding has its own cluster. The `u` prefix is removed, which matters because `u'foo'` is a syntax error on Python 3.3 and later. `.encode()` with no argument or with an explicit ASCII or UTF-8 argument becomes a bytes literal, so `'foo'.encode('utf-8')` becomes `b'foo'`, and separately `b'foo'.encode('utf-8')` form is simplified so an explicit utf-8 argument is dropped, since UTF-8 is the default. Invalid escape sequences are fixed according to a rule worth knowing: a string containing only invalid sequences becomes a raw string, while a string with a mix of valid and invalid sequences has the invalid ones escaped instead, so `'\n\d'` becomes `'\n\\d'`. The README also flags that this specific fix repairs a genuine syntax error rather than just a style problem.
Two more from the same cluster are easy to miss. A `# coding: utf-8` comment is deleted, since PEP 3120 made UTF-8 the default source encoding. And a forced `str` of a native string is folded to a plain literal, so `str()` becomes an empty string.
Fixes aimed at correctness rather than taste
A subset of the rewrites exist because the old form was actually wrong, and that distinction is worth keeping in mind when you decide whether to accept a rewrite in a code review.
The clearest is identity comparison against literals. In Python 3.8 and later, `x is 5` raises a `SyntaxWarning`, because whether the comparison succeeds depends on object caching, which is an implementation detail. So `x is 5` becomes `x == 5`, `x is not 5` becomes `x != 5`, and `x is 'foo'` becomes `x == 'foo'`. That is a behavior change, and it is the correct one, but it means a file with this pattern deserves more than a mechanical review.
The exception tuple dedupe is in the same category. Duplicated entries in `isinstance(x, (int, int))`, `issubclass(y, (str, str))` or `except (Error1, Error1, Error2)` are removed, which is harmless but also almost always a copy and paste artifact. Extraneous parentheses around a `print()` argument are stripped while genuine tuple arguments are preserved, a fix the README credits to an issue on the older python-modernize project: `print((1,))` and `sum((i for i in range(3)), [])` are left alone, while `print(("foo"))` becomes `print("foo")`.
Then there is the class modernization. `class C(object)` becomes `class C`, and `class C(B, object)` becomes `class C(B)`. A `__metaclass__ = type` declaration is deleted outright. And `super(C, self).f()` becomes `super().f()`.
The unittest aliases are the most immediately valuable for anyone inheriting an old suite. `self.failUnlessEqual(1, 1)` becomes `self.assertEqual(1, 1)` and `self.assertEquals` becomes `self.assertEqual`, catching the plural form people mistype. The `yield` to `yield from` rewrite covers the two-argument tuple form as well as the plain one, so `for a, b in c: yield (a, b)` becomes `yield from c`, which is a genuine readability and speed improvement rather than a cosmetic one.
Deleting code that can never run
The transformations that separate pyupgrade from a syntax tidier are the ones that remove code. A version guard whose condition pyupgrade can evaluate as false, given the minimum version you declared, has its dead branch deleted:
import sys
-if sys.version_info < (3,):
- print('py2')
-else:
+ print('py3')The inline comment matters, because it tells you the matcher also understands `six.PY2`, `six.PY3` and negations of both. In a codebase that still imports six, that is the difference between a tool that works on your files and one that skips every guard it finds.
Which guards are eligible depends on the flag you pass. `--py36-plus` removes Python 3.5 and earlier only blocks, `--py37-plus` removes 3.6 and earlier only blocks, and so on. Both comparison directions are handled, so a `sys.version_info >= (3, 6)` branch with an `else` is rewritten to the 3.6+ path just as the `<` form is. Three separate blocks are shown in the README demonstrating that a single run collapses all of them.
One limitation is stated plainly and is the right call. Note that `if` blocks without an `else` will not be rewritten as it could introduce a syntax error. Deleting the body of an `if` with no alternative leaves an empty block, and the author would rather skip the transformation than emit a file that fails to parse.
The same reasoning extends to imports. `__future__` imports are removed by default, covering `nested_scopes`, `generators`, `with_statement`, `absolute_import`, `division`, `print_function` and `unicode_literals`, and `--py37-plus` additionally removes `generator_stop`. Compatibility shims go the same way, so `from io import open`, `from six.moves import map` and `from builtins import object` are all deleted. Version-gated `@pytest.mark.skipif` decorators are handled by the same logic, described as similar to the version blocks above: redundant marks are removed.
The import rewrites that do change a name are gated behind `--py36-plus`, and the README cross-references `reorder-python-imports` for the wider job of removing obsolete six imports. `from collections import deque, Mapping` splits so that `Mapping` comes from `collections.abc`, `from typing import Sequence` moves to `collections.abc`, and `from typing_extensions import Concatenate` moves to `typing`.
printf formatting and other rewrites you may want to refuse
Not every transformation in the catalogue is uncontroversial, and the ones that are not are the ones a reviewer is most likely to push back on.
The biggest is printf-style string formatting. Unless `--keep-percent-format` is passed, `'%s %s' % (a, b)` becomes `'{} {}'.format(a, b)`, `'%r %2f' % (a, b)` becomes `'{!r} {:2f}'.format(a, b)`, and the mapping form `'%(a)s %(b)s' % {'a': 1, 'b': 2}` becomes `'{a} {b}'.format(a=1, b=2)`. That last one is the most invasive, since it restructures the call rather than translating syntax in place. If your project has settled on f-strings, pyupgrade will not take you there, because f-strings require reasoning about expression boundaries and the tool deliberately does not do that. The migration from `%` formatting to f-strings is a separate pass, and if you want it, reach for a tool that targets it directly.
The `mock` import rewrite has the same shape of risk. Unless `--keep-mock` is passed, `from mock import patch` becomes `from unittest.mock import patch`, which is the right answer for any project that only runs on Python 3, and the wrong answer for a project that still supports Python 2 or depends on the backported package's behaviour differences.
Beyond those, there is a long tail of smaller rewrites the README documents without much commentary, which is itself a signal. If you scan the list and recognize three idioms your team has decided to keep, that is a reasonable reason to pass the corresponding flag rather than a reasonable reason to stop using the tool. The value is in the rewrites you would have done by hand and not looked forward to.
Where pyupgrade sits in a modern Python toolchain
The competitive picture has changed since this tool was written, and the related-search terms for it are mostly comparisons. Ruff ships pyupgrade-compatible rules, which is the most common reason people evaluate it and the reason many projects end up not installing a second tool. The practical distinction is one of scope. Ruff is a linter and formatter that can also perform a subset of these rewrites as autofixes, bundled with a much larger ruleset you probably want anyway. pyupgrade is a dedicated rewriter whose transformations go further into program structure, particularly the version-block deletion and the `__future__` import removal, which depend on knowing the minimum supported version rather than on a local pattern.
The repository layout reflects a small, conventional project. `pyupgrade/` is the package, `testing/` and `tests/` both exist, and `setup.py` is now a three-line shim that calls `setup()` with configuration living in `setup.cfg`, which is the standard migration for this era of Python packaging. `tox.ini` and `.pre-commit-config.yaml` at the root mean the project dogfoods the hook ecosystem it depends on, and `.pre-commit-hooks.yaml` is what pre-commit reads to know the hook id and entry point. There is a `requirements-dev.txt` and a GitHub Actions workflow on `main`, with a pre-commit.ci badge in the README, so changes are checked by both.
Version tags are the versioning mechanism, with the README's pre-commit sample pinning `rev: v3.21.2`, which tells you the project is on a mature release cadence and that the sample is kept current. There are no separate documentation files: the README is the feature list, and the wiki-style explanation is simply absent because the tool's behaviour is meant to be read off the diffs.
For adoption, the practical sequence is to add the hook with no flags, read the resulting diff on a branch of its own, and only then start declaring `--py3X-plus` and `--keep-percent-format` once the project has agreed on a floor and a house style. A tool that rewrites files on commit is one you want the team to have agreed to before it touches everyone's branch.
Editorial conclusion
pyupgrade occupies a narrow slot that most Python tooling leaves empty. Ruff's pyupgrade rules cover a related set of modernizations, and formatters like Black will normalize some of this by hand, but neither of them deletes a dead Python 2 branch by proving the condition false or strips a `__future__` import that your minimum version no longer needs. That is the work here, and it is work a linter is not supposed to do because it requires rewriting the program rather than complaining about it. The design philosophy is visible in the edges: `if` blocks with no `else` are left alone because rewriting them could introduce a syntax error, `defaultdict(lambda: list())` collapses to `defaultdict(list)` because the transformation is provably equivalent, and strings with only invalid escape sequences become raw strings while mixed ones get their backslashes escaped instead. At 4,112 stars, 219 forks, 23 open issues, MIT licensed and last pushed 2026-09-04, it is actively maintained by a maintainer whose other tools appear throughout the Python pre-commit ecosystem. Set `--py36-plus` or a later flag to match the oldest Python you actually support, and commit the result as its own change so a reviewer can read a diff of rewrites instead of reasoning about them inside a feature.
Frequently asked questions
What is pyupgrade used for in Python projects?
It rewrites Python source in place so that older idioms become their modern equivalents, which matters most during and after a Python 2 migration. Typical rewrites include `set((1, 2))` becoming `{1, 2}`, `super(C, self).f()` becoming `super().f()`, `u'foo'` losing its prefix, `class C(object)` becoming `class C`, and printf-style formatting being translated to `.format()` unless you pass `--keep-percent-format`. Given a minimum supported version through `--py36-plus` and later flags, it also deletes branches that can never execute and strips unneeded `__future__` imports.
How do I install pyupgrade as a pre-commit hook?
Add the repository to your `.pre-commit-config.yaml` with an exact `rev` and enable the `pyupgrade` hook id, which is how the README's own sample is written. The hook rewrites files in place, so a commit either has upgraded syntax or fails until you accept the change. Pinning `rev` to a release tag such as `v3.21.2` rather than a branch is what keeps a new pyupgrade release from silently changing your formatting in unrelated commits. Running it once across a whole codebase at the start is usually easier to review than adopting it piecemeal.
Does pyupgrade remove dead Python 2 code branches?
Yes, when you tell it your minimum version. `--py36-plus` removes blocks that only apply to Python 3.5 and earlier, `--py37-plus` extends that to 3.6 and earlier, and the pattern continues. The matcher evaluates conditions on `sys.version_info` in both directions and also understands `six.PY2`, `six.PY3` and negations of them, so a codebase still importing six is handled. One limit is deliberate: an `if` block with no `else` is left alone, because removing the branch body would leave an empty block that fails to parse.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/asottile-pyupgrade)