Intermediate
Every October, Python ships a new version — and every October, developers face the same question: is this actually worth upgrading for, or just a round of deprecation warnings and one new syntax trick? Python 3.13 is different. It ships a completely redesigned interactive interpreter, error messages that finally tell you what went wrong instead of where it went wrong, and — for the first time in Python’s history — an experimental build that removes the Global Interpreter Lock entirely. These are not incremental changes. They affect how you write code, debug it, and think about parallelism.
The upgrade itself is low-friction. Python 3.13 maintains backward compatibility for the vast majority of code. The main breakage comes from the removal of a batch of long-deprecated standard library modules (cgi, telnetlib, aifc, and about a dozen others) that were marked for removal since Python 3.11. If you haven’t already switched away from them, you will need to now. Everything else — the new REPL, the error improvements, the free-threading build — is additive. You get the benefits immediately on upgrade with zero code changes.
This guide covers every significant change in Python 3.13 with working code examples you can run today. We’ll walk through the new REPL features, improved tracebacks, the copy.replace() protocol, typing improvements including TypeVar defaults and ReadOnly for TypedDict, the experimental free-threaded mode and JIT compiler, and what you need to remove from your code before upgrading. By the end you will know exactly what changed, why it matters, and whether you need to act on it.
Python 3.13 in 60 Seconds
Before diving into each feature, here is a taste of two of the most visible changes: better error messages and the new copy.replace() function.
# python313_quick.py
# Run with Python 3.13+
import copy
from dataclasses import dataclass
# --- Better error messages ---
# In Python 3.12 and earlier, a NameError just said the name wasn't defined.
# Python 3.13 suggests what you probably meant.
# Try running: python -c "pint(42)"
# Output: NameError: name 'pint' is not defined. Did you mean: 'print'?
# --- copy.replace() for dataclasses and custom objects ---
@dataclass
class ServerConfig:
host: str
port: int
debug: bool = False
prod = ServerConfig(host="prod.example.com", port=443, debug=False)
staging = copy.replace(prod, host="staging.example.com", port=8080)
print(prod)
print(staging)
Output:
ServerConfig(host='prod.example.com', port=443, debug=False)
ServerConfig(host='staging.example.com', port=8080, debug=False)
copy.replace() creates a modified copy of an object without mutating the original — behavior that dataclasses previously required .replace() from the dataclasses module. The new version works with any class that implements __replace__(), giving you a standard protocol for immutable-style updates across your entire codebase. The improved error message in the comment above is just one example — Python 3.13 surfaces these suggestions across NameError, AttributeError, and import errors throughout the language.
What Is Python 3.13 and What Changed?
Python 3.13 was released on October 7, 2024, as the latest stable version of Python. It follows the annual release cadence and enters its active support period immediately, with bug fixes until October 2026 and security fixes until October 2029. The headline changes fall into four buckets:
| Area | What Changed | Impact |
|---|---|---|
| Interactive REPL | Complete rewrite with color, multi-line editing, persistent history | Daily quality-of-life |
| Error messages | Suggestions for typos, clearer tracebacks, better context | Debugging speed |
| Standard library | copy.replace(), warnings.deprecated(), locals() defined behavior | Code patterns |
| Typing | TypeVar defaults (PEP 696), ReadOnly for TypedDict (PEP 705) | Type safety |
| Experimental | Free-threaded build (no GIL), JIT compiler | Performance research |
| Removals | ~18 deprecated stdlib modules removed | May require code changes |
This release is notable because two of its most significant changes — free threading and the JIT — are explicitly experimental. They ship in opt-in builds, are not enabled by default, and are expected to stabilize across 3.14 and 3.15. You can use them today for experimentation, but production deployments should wait for the stability signals from the CPython team.
The New Interactive REPL
The Python REPL — that >>> prompt you get when you run python with no arguments — has not changed significantly since Python was created. In Python 3.13, it was completely rewritten. The new REPL brings features that users of IPython and Jupyter have had for years into the standard distribution.
The most visible change is color. Keywords, strings, numbers, and errors are now syntax-highlighted in the terminal. Multi-line editing works properly — press Enter mid-function and the REPL indents correctly; press Backspace and you go back up a line instead of abandoning the expression. History is now persistent across sessions: commands you ran yesterday are still accessible with the up arrow today.
# Try these in the Python 3.13 REPL (run: python3.13)
# Multi-line function -- the REPL handles indentation automatically
def greet(name):
return f"Hello, {name}!"
greet("Python 3.13")
# Paste mode -- use Ctrl+O to paste a block of code without triggering early execution
# Exit the REPL with exit() or Ctrl+D
Output:
'Hello, Python 3.13!'
The new REPL is built on top of the same readline/editline infrastructure but replaces the old input loop with a purpose-built implementation that handles multi-line expressions as a unit. If you preferred the old behavior — for example, if you are piping input into the REPL — you can restore it with the PYTHON_BASIC_REPL environment variable set to 1. The old REPL is still there; the new one is just the default.
Improved Error Messages
Python 3.12 introduced better tracebacks for certain errors. Python 3.13 extends this across more error types and adds suggestion logic that catches the most common typos. The improvements center on three areas: NameError suggestions, AttributeError context, and import error hints.
When you mistype a variable or function name, Python 3.13 checks whether any defined name in the current scope is close enough to be the likely intended target, and if so, suggests it. This uses a Levenshtein-distance heuristic under the hood — no fuzzy logic, just edit distance on the candidates actually in scope.
# error_messages_demo.py
# These examples show the error output -- run each line individually
# NameError with suggestion
# python3.13 -c "mesage = 'hello'; print(mesage)"
# NameError: name 'mesage' is not defined. Did you mean: 'message'?
# AttributeError with available attributes listed
class Config:
timeout = 30
retries = 3
cfg = Config()
# python3.13 -c "cfg.timout" -- triggers:
# AttributeError: type object 'Config' has no attribute 'timout'.
# Did you mean: 'timeout'?
# Module import error with hints
# python3.13 -c "import numppy" -- triggers:
# ModuleNotFoundError: No module named 'numppy'. Did you mean: 'numpy'?
print("Error message demos -- run each line individually to see the suggestions")
Output (when triggered):
NameError: name 'mesage' is not defined. Did you mean: 'message'?
AttributeError: type object 'Config' has no attribute 'timout'. Did you mean: 'timeout'?
ModuleNotFoundError: No module named 'numppy'. Did you mean: 'numpy'?
The import error suggestions check not just installed packages but also common misspellings of standard library modules. Combined with the --tb=short traceback format, debugging a fresh script is significantly less frustrating. The suggestions are heuristic — they won’t always be right — but they are right often enough to save meaningful time.
copy.replace() — A Standard Protocol for Copies
Before Python 3.13, copying-with-modifications required different approaches for different types. Dataclasses had dataclasses.replace(). Named tuples had ._replace(). Custom classes had no standard interface at all — you rolled your own or used a third-party library. Python 3.13 standardizes this with copy.replace() and the __replace__() protocol.
# copy_replace_demo.py
import copy
from dataclasses import dataclass
# Works with dataclasses out of the box
@dataclass
class DatabaseConfig:
host: str
port: int
name: str
ssl: bool = True
prod_db = DatabaseConfig(host="db.prod.com", port=5432, name="myapp")
test_db = copy.replace(prod_db, host="localhost", port=5433, name="myapp_test")
print("Production:", prod_db)
print("Test: ", test_db)
# Custom class -- implement __replace__ for copy.replace() support
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def __replace__(self, **changes):
return Rectangle(
width=changes.get("width", self.width),
height=changes.get("height", self.height),
)
def area(self):
return self.width * self.height
def __repr__(self):
return f"Rectangle({self.width}x{self.height}, area={self.area()})"
original = Rectangle(10, 5)
wider = copy.replace(original, width=20)
taller = copy.replace(original, height=8)
print("Original:", original)
print("Wider: ", wider)
print("Taller: ", taller)
Output:
Production: DatabaseConfig(host='db.prod.com', port=5432, name='myapp', ssl=True)
Test: DatabaseConfig(host='localhost', port=5433, name='myapp_test', ssl=True)
Original: Rectangle(10x5, area=50)
Wider: Rectangle(20x5, area=100)
Taller: Rectangle(10x8, area=80)
The key detail is that copy.replace() works on anything that implements __replace__() — including dataclasses, which get it automatically. This gives you one mental model and one function call regardless of whether you are working with a dataclass, a named tuple, or a custom domain object. The protocol also integrates cleanly with type checkers since the return type can be annotated as Self.
Typing Improvements: TypeVar Defaults and ReadOnly
Python 3.13 ships two notable typing additions that were accepted as PEPs: TypeVar defaults (PEP 696) and ReadOnly for TypedDict (PEP 705). Both are aimed at reducing boilerplate in type-annotated codebases.
TypeVar Defaults (PEP 696)
TypeVar defaults let you specify a fallback type for a type variable when it is not explicitly provided. This is most useful in generic classes where one type parameter commonly defaults to a specific type — for example, a generic container that defaults to storing strings.
# typevar_defaults.py
from typing import TypeVar, Generic
# Before Python 3.13: no default -- callers must always specify T
# class Stack(Generic[T]): ...
# Python 3.13: default=str means Stack() without a type arg behaves like Stack[str]
T = TypeVar("T", default=str)
class Stack(Generic[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()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
# Stack() without a type arg defaults to Stack[str] for type checkers
string_stack: Stack = Stack() # treated as Stack[str]
string_stack.push("hello")
string_stack.push("world")
# Stack[int] when you need integers
int_stack: Stack[int] = Stack()
int_stack.push(42)
print(string_stack.pop())
print(int_stack.pop())
Output:
world
42
ReadOnly for TypedDict (PEP 705)
The ReadOnly modifier lets you mark individual fields of a TypedDict as read-only. This was not possible before — you either made the entire TypedDict read-only (via total=False workarounds) or not at all. With Python 3.13 you can be precise: some fields are mutable, others are not.
# readonly_typeddict.py
from typing import TypedDict, ReadOnly
class UserRecord(TypedDict):
id: ReadOnly[int] # Set once at creation, never changed
username: ReadOnly[str] # Immutable identifier
email: str # Mutable -- users can update email
login_count: int # Mutable -- updated on each login
# Type checkers will flag assignments to ReadOnly fields:
user: UserRecord = {"id": 101, "username": "alice", "email": "alice@example.com", "login_count": 0}
# This is fine -- email is mutable
user["email"] = "alice@newdomain.com"
user["login_count"] += 1
# A type checker would flag this:
# user["id"] = 999 # Error: Cannot assign to read-only field 'id'
# user["username"] = "bob" # Error: Cannot assign to read-only field 'username'
print(user)
Output:
{'id': 101, 'username': 'alice', 'email': 'alice@newdomain.com', 'login_count': 1}
Note that ReadOnly is a static typing construct — it is enforced by your type checker (mypy, pyright), not at runtime. At runtime, Python does not prevent you from mutating a ReadOnly field. The value comes in catching these violations during development rather than at runtime in production.
Experimental: Free-Threaded Python (No GIL)
The Global Interpreter Lock (GIL) has been Python’s single biggest constraint for CPU-parallel workloads. It is a mutex that prevents more than one thread from executing Python bytecode simultaneously, which means Python threads are limited to concurrent I/O — not parallel computation. PEP 703, accepted in Python 3.13, defines a path to making the GIL optional, with an experimental build available today.
To use the free-threaded build, you need to install a specific Python build. On most systems, pyenv handles this:
# free_threading_demo.py
# REQUIRES: free-threaded build (python3.13t)
# Install via pyenv: pyenv install 3.13t
# Or download from python.org -- look for the "free-threaded" installer
import sys
import threading
import time
def is_gil_enabled():
"""Check if the GIL is currently active."""
return sys._is_gil_enabled() if hasattr(sys, '_is_gil_enabled') else True
print(f"Python version: {sys.version}")
print(f"GIL enabled: {is_gil_enabled()}")
# CPU-bound work that runs truly in parallel on free-threaded build
def count_up(limit, label):
total = 0
for _ in range(limit):
total += 1
print(f"{label} finished: {total:,}")
start = time.perf_counter()
threads = [
threading.Thread(target=count_up, args=(10_000_000, "Thread A")),
threading.Thread(target=count_up, args=(10_000_000, "Thread B")),
]
for t in threads: t.start()
for t in threads: t.join()
elapsed = time.perf_counter() - start
print(f"Elapsed: {elapsed:.3f}s (parallel benefit only visible on free-threaded build)")
Output (standard build, GIL active):
Python version: 3.13.0 (main, ...)
GIL enabled: True
Thread A finished: 10,000,000
Thread B finished: 10,000,000
Elapsed: 1.842s
Output (free-threaded build, GIL disabled):
Python version: 3.13.0 experimental free-threading build (main, ...)
GIL enabled: False
Thread A finished: 10,000,000
Thread B finished: 10,000,000
Elapsed: 0.971s
The performance difference on truly CPU-parallel work is significant. However, the free-threaded build is explicitly experimental. Not all C extensions are thread-safe under the new model, and the CPython team has warned that some workloads may actually regress due to increased lock contention on the new per-object reference counting. Use this build for experimentation and benchmarking — not yet for production. Watch the Python 3.14 and 3.15 release notes for stability updates.
Deprecated Module Removals: What To Check
Python 3.13 removes a batch of standard library modules that have been deprecated since Python 3.11. If you import any of these, your code will raise an ImportError on 3.13. Run a quick grep of your codebase before upgrading:
# check_deprecated_imports.sh (run in your project root)
# grep -rn "import aifc\|import audioop\|import chunk\|import cgi\|import cgitb\|import crypt\|import imghdr\|import mailcap\|import msilib\|import nis\|import nntplib\|import ossaudiodev\|import pipes\|import sndhdr\|import spwd\|import sunau\|import telnetlib\|import uu\|import xdrlib" .
# Python version of the same check:
# check_deprecated.py
import ast
import sys
from pathlib import Path
REMOVED_MODULES = {
"aifc", "audioop", "chunk", "cgi", "cgitb", "crypt",
"imghdr", "mailcap", "msilib", "nis", "nntplib",
"ossaudiodev", "pipes", "sndhdr", "spwd", "sunau",
"telnetlib", "uu", "xdrlib",
}
def check_file(path):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except SyntaxError:
return []
hits = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
name = alias.name.split(".")[0]
if name in REMOVED_MODULES:
hits.append((node.lineno, name))
elif isinstance(node, ast.ImportFrom):
name = (node.module or "").split(".")[0]
if name in REMOVED_MODULES:
hits.append((node.lineno, name))
return hits
project_root = Path(".")
found_any = False
for py_file in sorted(project_root.rglob("*.py")):
hits = check_file(py_file)
if hits:
found_any = True
for lineno, module in hits:
print(f"{py_file}:{lineno}: imports removed module '{module}'")
if not found_any:
print("No removed modules found -- safe to upgrade to Python 3.13")
Output (sample — clean project):
No removed modules found -- safe to upgrade to Python 3.13
The most commonly encountered removals are cgi (replace with a WSGI framework or manually parse Content-Type headers), telnetlib (replace with the third-party telnetlib3 or direct socket code), and crypt (replace with passlib or hashlib). The others are niche enough that most projects won’t encounter them.
Real-Life Example: A Python 3.13 Feature Demo Script
This script combines the most practical Python 3.13 additions into a single runnable tool: a configuration manager that uses copy.replace() for environment switching, ReadOnly TypedDict for immutable fields, and warnings.deprecated() to mark old config patterns.
# config_manager_313.py
# Requires Python 3.13+
import copy
import warnings
from dataclasses import dataclass, field
from typing import TypedDict, ReadOnly
# --- ReadOnly TypedDict for immutable deployment metadata ---
class DeploymentInfo(TypedDict):
deploy_id: ReadOnly[str]
region: ReadOnly[str]
version: ReadOnly[str]
hotfix: bool # Mutable -- can be toggled post-deploy
# --- Dataclass using copy.replace() for environment switching ---
@dataclass
class AppConfig:
host: str
port: int
database_url: str
debug: bool = False
log_level: str = "INFO"
max_connections: int = 20
def for_environment(self, env: str) -> "AppConfig":
"""Return a new config adapted for the given environment."""
if env == "production":
return copy.replace(self, debug=False, log_level="WARNING", max_connections=100)
elif env == "staging":
return copy.replace(self, debug=True, log_level="DEBUG", max_connections=10)
elif env == "test":
return copy.replace(self, host="localhost", port=5434,
database_url="postgresql://localhost/testdb",
debug=True, log_level="DEBUG", max_connections=5)
else:
raise ValueError(f"Unknown environment: {env!r}")
# --- warnings.deprecated() to mark old patterns ---
@warnings.deprecated("Use AppConfig.for_environment() instead")
def make_test_config(base: AppConfig) -> AppConfig:
"""Old pattern -- preserved for compatibility, but deprecated."""
return copy.replace(base, host="localhost", debug=True)
# Build the base config once
base_config = AppConfig(
host="db.myapp.com",
port=5432,
database_url="postgresql://db.myapp.com/myapp",
)
# Derive environment-specific configs without mutating the base
prod_config = base_config.for_environment("production")
staging_config = base_config.for_environment("staging")
test_config = base_config.for_environment("test")
print("=== Configuration Manager Demo ===")
print(f"Base: host={base_config.host}, debug={base_config.debug}, max_conn={base_config.max_connections}")
print(f"Prod: host={prod_config.host}, debug={prod_config.debug}, max_conn={prod_config.max_connections}")
print(f"Staging: host={staging_config.host}, debug={staging_config.debug}, max_conn={staging_config.max_connections}")
print(f"Test: host={test_config.host}, debug={test_config.debug}, max_conn={test_config.max_connections}")
# Deployment metadata with ReadOnly fields
info: DeploymentInfo = {
"deploy_id": "d-abc123",
"region": "us-east-1",
"version": "2.4.1",
"hotfix": False,
}
info["hotfix"] = True # Allowed -- mutable field
print(f"\nDeployment: id={info['deploy_id']}, version={info['version']}, hotfix={info['hotfix']}")
Output:
=== Configuration Manager Demo ===
Base: host=db.myapp.com, debug=False, max_conn=20
Prod: host=db.myapp.com, debug=False, max_conn=100
Staging: host=db.myapp.com, debug=True, max_conn=10
Test: host=localhost, debug=True, max_conn=5
Deployment: id=d-abc123, version=2.4.1, hotfix=True
This pattern — one base config, derived copies for each environment — is cleaner than maintaining three separate config files or loading environment variables into mutable dicts. The for_environment() method returns a new AppConfig with only the relevant fields changed, and copy.replace() handles the copying without boilerplate. The ReadOnly TypedDict fields for DeploymentInfo let a type checker catch any accidental mutation of fields that should be set once and never changed. You can extend this by adding a from_env() classmethod that reads from environment variables and applies the same pattern.
Frequently Asked Questions
Should I upgrade to Python 3.13 right now?
For new projects, yes — start on 3.13 immediately. For existing projects, run the deprecated module check script above, verify your dependencies support 3.13 (check each package’s release notes or test with pip install --dry-run), and upgrade once you have confirmed compatibility. The only hard blocker is if you depend on one of the removed modules. Most projects will migrate in an afternoon.
Can I use the free-threaded build in production?
Not yet for critical systems. The free-threaded build ships as explicitly experimental in 3.13 — the CPython team is still working through edge cases in C extension thread safety and reference counting under concurrent access. If you want to benchmark it or contribute feedback, install python3.13t via pyenv and test it. For production workloads that need CPU parallelism today, multiprocessing, concurrent.futures.ProcessPoolExecutor, or tools like Ray are the reliable path.
How do I get the old REPL back?
Set the PYTHON_BASIC_REPL environment variable to 1 before starting Python: PYTHON_BASIC_REPL=1 python3.13. This restores the pre-3.13 behavior completely. You might need this if you pipe input into the REPL from a script, since the new REPL handles stdin differently. You can also set it permanently in your shell config file: export PYTHON_BASIC_REPL=1.
Does copy.replace() work with named tuples?
Yes. collections.namedtuple instances now support copy.replace() out of the box in Python 3.13, in addition to their existing ._replace() method. The new approach is preferred because it is type-checker-friendly and consistent with the broader protocol. For typing.NamedTuple, the same applies. Custom classes get the behavior by implementing __replace__(self, **changes) and returning a new instance with the changes applied.
Are TypeVar defaults backward compatible?
For runtime behavior, yes — TypeVar defaults are a typing construct and have no effect on what code runs. They only affect what static type checkers accept. The default= parameter to TypeVar is new in 3.13 and will raise a TypeError on older Python versions if you try to run the same code on 3.12 or earlier. Use a if sys.version_info >= (3, 13) guard or a TYPE_CHECKING conditional import if you need to support both versions simultaneously.
What is the JIT compiler in Python 3.13?
PEP 744 adds an experimental copy-and-patch JIT compiler to CPython. It is not enabled at runtime — it is a compile-time option when building CPython from source. The JIT is designed to work with the specializing adaptive interpreter introduced in Python 3.11 and generates native machine code for hot loops. Early benchmarks show modest single-digit percentage improvements on some workloads and regressions on others. It is included in Python 3.13 as an engineering preview for the CPython development team, not as a user-facing performance feature. Most users should ignore it until Python 3.15 or later.
Conclusion
Python 3.13 delivers genuine quality-of-life improvements that you will notice every day: a REPL that works the way you expect, error messages that tell you what you actually typed wrong, and copy.replace() as a clean standard for immutable-style updates across any class. The typing additions — TypeVar defaults and ReadOnly TypedDict — reduce boilerplate in annotated codebases without changing runtime behavior. And for those tracking the longer game, the experimental free-threaded build and JIT compiler in 3.13 are the first concrete steps toward a version of Python that can use multiple CPU cores for parallel computation without workarounds.
To go deeper on the changes covered here, the official What’s New in Python 3.13 document covers every change including minor ones not discussed here. For the free-threaded build specifically, the Free Threading HOWTO walks through the build process, known limitations, and how to test your C extensions for thread safety. Upgrade to 3.13 on your next project — the REPL alone makes it worth it.