What’s New in Python 3.12: Features and Upgrade Guide
Python 3.12 brings cleaner generic syntax, unrestricted f-strings, smarter error messages, and the removal of distutils. Here is what changed and how to upgrade.
Python 3.12.0 was released on 2 October 2023. The previous release was easy to summarize in one word: faster. This one is harder, because its changes are spread across syntax, typing, tooling, and cleanup. Several of them are groundwork for later releases rather than features you use today.
This article covers the Python 3.12 new features with a short runnable example for each, then walks through the upgrade. If you skipped the previous version, start with what Python 3.11 changed, since 3.12 builds directly on it.
You need Python 3.12 installed beside your current version. Run python3.12 --version to check. Every example uses only the standard library.
My position: treat 3.12 as a quality-of-life release with one sharp edge. Adopt the interpreter soon, adopt the new generic syntax later, and audit your build tooling before either.
The Python 3.12 new features at a glance
| Feature | Reference | Why you care |
|---|---|---|
Type parameter syntax and the type statement |
PEP 695 | Generics without TypeVar boilerplate |
| F-string restrictions lifted | PEP 701 | Reuse quotes, nest freely, and write multi-line expressions |
| Better error messages | Release notes | Suggestions for missing imports and self. |
typing.override |
PEP 698 | Type checkers catch misspelled overrides |
itertools.batched |
itertools | Split any iterable into fixed-size chunks |
Path.walk() |
pathlib | Walk directory trees with Path objects |
| Comprehension inlining | PEP 709 | Comprehensions up to two times faster, per the release notes |
| Per-interpreter GIL | PEP 684 | Groundwork only, available through the C API |
| Low-impact monitoring | PEP 669 | Cheaper debuggers and profilers through sys.monitoring |
distutils removed |
PEP 632 | Old setup.py files and some tools break |
Generics without the boilerplate
A generic function or class works with any type while keeping that type consistent. Before 3.12, you had to declare a TypeVar separately and reference it by name.
# Python 3.11 and earlier
from typing import Generic, TypeVar
T = TypeVar('T')
def first(items: list[T]) -> T:
return items[0]
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
Python 3.12 lets you declare the type parameter in square brackets, right where you use it (PEP 695).
# Python 3.12
def first[T](items: list[T]) -> T:
return items[0]
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
type Pair[T] = tuple[T, T]
if __name__ == '__main__':
stack = Stack[int]()
stack.push(3)
print(first(['a', 'b']), stack.pop())
a 3
The new type statement creates a type alias, and the alias can be generic too. The parameter T exists only inside the function or class that declares it, which removes a common source of confusion with module-level type variables.
The catch: your type checker must understand it
Here is the practical insight that the release notes cannot give you. The syntax is part of the language, so Python 3.12 runs it. Your type checker is a separate program, and it needs its own update. At the time of writing, pyright supports the new syntax, and mypy does not yet. A mypy run on the code above reports that PEP 695 generics are not supported.
Consequently, a project that relies on mypy in CI cannot adopt this syntax today, even after moving to 3.12. There is a second constraint: the syntax is a SyntaxError on Python 3.11 and earlier, so any library that supports older versions must keep using TypeVar. My rule is to upgrade the interpreter first, and to adopt new syntax only when the oldest supported Python and every tool in the pipeline can read it. For background on those tools, see this guide to type hints and type checkers.
@override catches silent mistakes
typing.override marks a method that is meant to replace one in a parent class (PEP 698). A type checker then reports an error if no such parent method exists.
from typing import override
class Exporter:
def export(self) -> str:
return 'base'
class CsvExporter(Exporter):
@override
def export(self) -> str:
return 'csv'
@override
def exprot(self) -> str: # typo: a type checker flags this line
return 'never called'
Without the decorator, the misspelled method is simply a new method that nothing calls. The same protection applies when someone renames the parent method later. The decorator does nothing at runtime, so its value depends entirely on running a type checker.
F-strings without the old restrictions
F-strings used to have odd limits. You could not reuse the outer quote character inside an expression, and you could not use a backslash or a comment there. Python 3.12 gives f-strings a formal grammar (PEP 701) and removes those limits.
user = {'name': 'Asha', 'roles': ['admin', 'dev']}
print(f'{user['name']} has roles: {', '.join(user['roles'])}')
print(f'{'\n'.join(user['roles'])}')
total = f'{
sum(
len(role) # comments are allowed now
for role in user['roles']
)
} characters'
print(total)
Asha has roles: admin, dev
admin
dev
8 characters
On Python 3.11, the first line is a SyntaxError, because the inner single quotes end the string early. The practical benefit is that you can paste any expression into an f-string without rewriting its quotes. The trade-off is readability. Deeply nested f-strings are now legal, and they are still hard to read. Syntax errors inside f-strings also point at the exact position now, instead of at the whole string.
Error messages that suggest the fix
Each recent release has improved error messages. Version 3.12 adds suggestions for several everyday mistakes.
>>> sys.version_info
NameError: name 'sys' is not defined. Did you forget to import 'sys'?
class Cart:
def __init__(self):
self.items = []
def count(self):
return len(items)
Cart().count()
NameError: name 'items' is not defined. Did you mean: 'self.items'?
>>> import Counter from collections
SyntaxError: Did you mean to use 'from ... import ...' instead?
>>> from collections import OrderedDic
ImportError: cannot import name 'OrderedDic' from 'collections'. Did you mean: 'OrderedDict'?
None of these change what your program does. They shorten the time between an error and its fix, which matters most to people who are new to the language.
Standard library additions
itertools.batched
Splitting a sequence into chunks used to require a hand-written helper. It is now one import.
from itertools import batched
for batch in batched(range(7), 3):
print(batch)
(0, 1, 2)
(3, 4, 5)
(6,)
Each batch is a tuple, and the last one may be shorter. batched works on any iterable, including generators, so it suits bulk database inserts and paginated API calls.
pathlib and other small wins
from pathlib import Path
for root, dirs, files in Path('.').walk():
python_files = [name for name in files if name.endswith('.py')]
if python_files:
print(root, len(python_files))
Path.walk() is the pathlib counterpart of os.walk(), and it yields Path objects for the directory. Other additions include a command-line interface for SQLite (python -m sqlite3) and for uuid, and proper support for subclassing Path.
Performance: no headline number this time
Python 3.11 arrived with a clear claim of 10 to 60 percent over 3.10. The 3.12 release notes make no comparable overall claim. They list targeted improvements instead:
- Comprehensions are inlined into the enclosing function, which the notes say speeds them up by up to two times (PEP 709).
isinstance()checks against runtime-checkable protocols are 2 to 20 times faster.- Some
asynciobenchmarks show a 75 percent speed-up, according to the notes. - The
tokenizemodule is up to 64 percent faster.
These are narrow gains, so do not promise a general speed-up to anyone. As always, run your own workload under both interpreters and compare. If your service is built on asyncio tasks, measure it specifically, since that area received attention.
The myth: Python 3.12 removed the GIL
Headlines about a “per-interpreter GIL” led many people to believe that threads now run Python code in parallel. They do not. The global interpreter lock still allows one thread at a time to execute bytecode in an ordinary Python 3.12 program.
What PEP 684 delivers is narrower. A single process can host several sub-interpreters, and each one can now have its own lock. In 3.12, that ability is exposed only through the C API. There is no standard library module for it yet, so pure Python code cannot use it.
A separate effort, PEP 703, proposes a build of CPython without the GIL. The Steering Council has announced its intent to accept that proposal, with a gradual, experimental rollout planned for later releases. Neither change affects code you run on 3.12 today. For CPU-bound parallelism, you still use multiple processes.
What was removed, and what breaks
This section matters more than any new feature. Most upgrade failures on 3.12 come from removals.
| Change | Typical symptom | Fix |
|---|---|---|
distutils removed (PEP 632) |
ModuleNotFoundError: No module named 'distutils' |
Upgrade the dependency, or move your build to setuptools and pyproject.toml |
setuptools no longer installed in new virtual environments |
ModuleNotFoundError: No module named 'pkg_resources' or 'setuptools' |
Declare setuptools as a build requirement, or install it explicitly |
asynchat, asyncore, imp, and smtpd removed |
ModuleNotFoundError on import |
Use asyncio and importlib |
Deprecated unittest aliases removed, such as assertEquals |
AttributeError in old test suites |
Rename to assertEqual and friends |
datetime.utcnow() deprecated |
DeprecationWarning |
Use datetime.now(UTC) |
Invalid escape sequences such as '\d' |
SyntaxWarning, which was a quieter DeprecationWarning before |
Use a raw string: r'\d' |
The first two rows cause most of the pain. A package with an old setup.py that imports distutils fails to build. A tool that assumed setuptools is always present fails at import. Both problems usually sit in a dependency, not in your code.
How to upgrade safely
- Install 3.12 side by side and confirm it with
python3.12 --version. - Create a fresh virtual environment and install your dependencies.
python3.12 -m venv .venv312
source .venv312/bin/activate # macOS and Linux
.venv312\Scripts\Activate.ps1 # Windows PowerShell
python -m pip install -r requirements.txt
- Read the install log. A
Building wheel for ...line means no 3.12 wheel exists for that package. Adistutilserror means the package’s build script is outdated. Upgrade the package or wait for a release. - Search your own code for removed modules.
grep -rnE "import (distutils|imp|asyncore|asynchat|smtpd)|from (distutils|imp) " src/ tests/
- Run the tests with warnings as errors.
python -W error -m pytest
- Upgrade linters and type checkers. Older versions may fail to parse the new f-string and generic syntax.
- Add 3.12 to CI next to your current version, then roll out one service at a time.
How real teams adopt a new Python release
- Interpreter first, syntax later. Teams move the runtime to 3.12 and keep writing code that also runs on 3.11. New syntax arrives months later, when rollback is no longer a concern.
- Libraries lag by design. A library that supports several Python versions cannot use 3.12-only syntax until it drops 3.11. Expect
TypeVarin library code for a long time. - Build tooling is audited separately. Packaging scripts, Docker base images, and CI helper scripts often contain the
distutilsandpkg_resourcesusage that application code does not. - A matrix job catches regressions. CI runs the tests on the old and new interpreters for several weeks before the deployed version changes.
- The first bugfix release is the common trigger. Many organizations wait for 3.12.1 before production use.
We once hit a bug when a routine CI image update moved a service’s test job to Python 3.12 a few days after release. The application code passed, but the job failed while installing dependencies, with ModuleNotFoundError: No module named 'distutils' from an old, unmaintained package with a hand-written setup.py. We pinned CI to 3.11 for the afternoon, replaced that package with a maintained alternative, and re-enabled 3.12 the following week.
Upgrading to Python 3.12: a decision framework
- Do all dependencies install cleanly on 3.12? If any compiled package lacks a wheel, or any build fails on
distutils, wait or replace it. - Does your build rely on implicit setuptools? Fix that first. It is a small change, and it also makes builds correct on older versions.
- Do you want the new generic syntax? Check that your type checker supports PEP 695 and that you no longer support 3.11.
- Is your code asyncio-heavy or comprehension-heavy? Benchmark it. You may see a real gain.
- Is the application stable and I/O-bound? Upgrade on your normal schedule for the error messages and the longer support window.
When NOT to upgrade yet
- When a dependency has no 3.12 release. Scientific and machine learning packages often need weeks or months after a new Python version. One blocker stops the whole upgrade.
- When your tooling cannot parse 3.12 syntax. A formatter or linter that crashes on new f-strings will block every commit. Upgrade the tools first.
- When you expect a speed-up to justify the work. This release offers no broad performance gain. If speed was your only reason, measure before you plan the migration.
Common mistakes
- Believing the GIL is gone. Threads still do not run Python bytecode in parallel. CPU-bound code moved to threads stays as slow as before.
- Using new generic syntax in a library. The module becomes a
SyntaxErroron every older Python, and users on 3.11 cannot import it. - Adopting PEP 695 before the type checker does. The code runs, and CI fails on type checking, or worse, checking is silently skipped.
- Assuming setuptools is installed. Scripts that import
pkg_resourcesfail in fresh 3.12 environments. - Nesting f-strings because you can. Three levels of nested quotes are legal now, and unreadable. Assign intermediate values to variables.
- Ignoring the utcnow deprecation.
datetime.utcnow()returns a naive datetime that is easy to misuse. Replace it now, while it is only a warning.
Key takeaways
- Python 3.12 adds bracket syntax for generics and a
typestatement for aliases. - F-strings can now reuse quotes, contain backslashes, and span lines with comments.
- Error messages suggest missing imports and missing
self.prefixes. itertools.batched,Path.walk(), andtyping.overrideare small, useful additions.- The GIL is still there. The per-interpreter GIL is C API groundwork only.
distutilsis removed, and new virtual environments no longer includesetuptools.- Upgrade the interpreter first, and adopt new syntax once your tools and supported versions allow it.
FAQ
What are the main new features in Python 3.12?
The main ones are a new syntax for generic functions and classes, the type statement for aliases, f-strings without quoting restrictions, improved error messages, typing.override, and itertools.batched.
Is Python 3.12 faster than Python 3.11?
In specific areas, yes. The release notes report comprehensions up to two times faster and gains in some asyncio benchmarks. They do not claim a general speed-up comparable to the 3.11 release.
Did Python 3.12 remove the GIL?
No. The global interpreter lock still applies to normal Python programs. Python 3.12 adds a per-interpreter GIL for sub-interpreters, which is available only through the C API in this release.
Why do I get “No module named distutils” on Python 3.12?
The distutils module was removed in Python 3.12. The error usually comes from an old package or build script. Upgrade the package, or switch the build to setuptools with a pyproject.toml file.
Should I upgrade to Python 3.12?
Upgrade once every dependency installs cleanly on 3.12 and your tests pass. If a dependency is not ready, add 3.12 to your CI matrix and wait.
Upgrade the runtime now, the syntax later
Python 3.12 rewards a two-step approach. Moving the interpreter is mostly a matter of dependencies and build tooling, and it gets you better errors and a longer support window. The new syntax can wait until your type checker and your oldest supported version catch up.
Rule of thumb: a feature is ready for your codebase when the interpreter, the type checker, the linter, and your oldest supported Python all accept it.
