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.

Executive Summary: Python 3.12 brings a shorter generics syntax, more flexible f-strings, better error messages, and small additions like itertools.batched. This guide explains what changed and what breaks, mainly the removal of distutils and setuptools from new virtual environments, and when to upgrade based on your dependencies and type checker.

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 asyncio benchmarks show a 75 percent speed-up, according to the notes.
  • The tokenize module 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

  1. Install 3.12 side by side and confirm it with python3.12 --version.
  2. 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
  1. Read the install log. A Building wheel for ... line means no 3.12 wheel exists for that package. A distutils error means the package’s build script is outdated. Upgrade the package or wait for a release.
  2. Search your own code for removed modules.
grep -rnE "import (distutils|imp|asyncore|asynchat|smtpd)|from (distutils|imp) " src/ tests/
  1. Run the tests with warnings as errors.
python -W error -m pytest
  1. Upgrade linters and type checkers. Older versions may fail to parse the new f-string and generic syntax.
  2. 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 TypeVar in library code for a long time.
  • Build tooling is audited separately. Packaging scripts, Docker base images, and CI helper scripts often contain the distutils and pkg_resources usage 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

  1. 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.
  2. Does your build rely on implicit setuptools? Fix that first. It is a small change, and it also makes builds correct on older versions.
  3. Do you want the new generic syntax? Check that your type checker supports PEP 695 and that you no longer support 3.11.
  4. Is your code asyncio-heavy or comprehension-heavy? Benchmark it. You may see a real gain.
  5. 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 SyntaxError on 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_resources fail 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 type statement 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(), and typing.override are small, useful additions.
  • The GIL is still there. The per-interpreter GIL is C API groundwork only.
  • distutils is removed, and new virtual environments no longer include setuptools.
  • 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.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *