I've done this migration enough times to know what to expect. Here's what actually happens when you upgrade to Python 3.12, not what the docs promise.
The Performance Gains Are Real (But Not Universal)
Asyncio is legitimately faster: Our Celery workers went from processing maybe 200 tasks a minute to something like 300+ after upgrading. The asyncio improvement isn't marketing—it actually shows up in production. But only if you're doing I/O-bound work. CPU-heavy stuff? Maybe 5-10% faster, nothing to write home about.
FastAPI response times dropped significantly: Average response time went from around 280ms down to like 160ms on our API that handles a decent amount of traffic daily. The improvement is real, but it took forever to get there because half our dependencies didn't have Python 3.12 wheels yet. I spent an entire weekend in December 2023 compiling lxml from source because their wheel was fucked.
Memory usage is weird now: Immortal objects mean memory doesn't grow as much over time, but initial memory usage is higher. Our Docker containers needed more RAM on startup, but stopped leaking memory after a day or two of runtime. Got paged at 2am because our Kubernetes nodes thought the pods were OOM'ing on startup—that was fun to debug.
F-Strings Finally Work Like You'd Expect
PEP 701 fixed the dumbest Python limitation—nested f-strings that should have worked from day one:
## This shit finally works in Python 3.12
user = {"name": "Alice", "role": "admin"}
message = f"Welcome {f'{user["name"].upper()}'} - Role: {user['role']}"
## No more stupid workarounds
logs = [{"level": "ERROR", "msg": "DB connection failed"}]
formatted = f"Alert: {logs[0]['level']} - {logs[0]['msg'][:50]}"
The f-string parser rewrite eliminates 90% of the "fuck it, I'll use .format()" moments. It's not revolutionary, but it removes daily frustration.
Shit That Will Definitely Break
distutils is gone: If your build scripts import distutils, they'll fail with ModuleNotFoundError
. I spent a whole weekend rewriting our deployment scripts because they relied on distutils.version.StrictVersion
. Use packaging instead. Except I forgot about our CI/CD pipeline which also used distutils, so Monday morning was fun when all deployments started failing.
Half your packages won't work: NumPy didn't support Python 3.12 until early 2024. Pillow took until like November or something. If you use any C extensions, plan on waiting months for wheels or compiling from source.
Virtual environments are fucked: You can't reuse existing venvs. Every deployment script that assumes source venv/bin/activate
works will break. You have to recreate from scratch. Learned this one the hard way when our staging environment started throwing import errors even though nothing had changed—turns out the Python version mismatch was causing weird module resolution issues.
## This will fail
python3.11 -m venv myenv
source myenv/bin/activate
python3.12 script.py # Nope, wrong Python version
## You have to do this
rm -rf myenv
python3.12 -m venv myenv
source myenv/bin/activate
pip install -r requirements.txt # And pray everything has wheels
The Real Migration Pain: Dependencies
Check compatibility first or suffer: Use some dependency checking tool to audit your requirements.txt before migrating. Don't trust PyPI's "Programming Language :: Python :: 3.12" classifier—lots of packages claim support but are actually broken.
C extensions are the worst: psycopg2-binary took months to support 3.12. lxml wheels weren't available until way later. Have backup packages ready or you'll be compiling shit from source.
Docker builds will fail: Official Python Docker images supported 3.12 right away, but your requirements.txt probably won't install. Expect weeks of delays while you find alternatives or figure out why everything's broken.
New Typing Syntax (Actually Useful)
The new generic syntax eliminates TypeVar boilerplate:
## Old way (still works, but verbose)
from typing import Generic, TypeVar
T = TypeVar('T')
class Cache(Generic[T]):
def get(self, key: str) -> T | None: pass
## Python 3.12 way (cleaner)
class Cache[T]:
def get(self, key: str) -> T | None: pass
## Type aliases are simpler
type UserID = int
type APIResponse[T] = dict[str, T | None]
This isn't going to change your life, but mypy loves it and IDE support is better.
Per-Interpreter GIL: Future-Proofing, Not Performance
PEP 684 adds per-interpreter GIL but doesn't remove the global GIL. You won't see parallelism improvements now, but it's groundwork for Python 3.13's free-threading mode.
Current impact: Zero. Future impact: Potentially huge.
The performance improvements are real, but every migration turns into a dependency nightmare. Plan accordingly.