Python has been described as the second-best language for everything, and that is usually meant as a criticism. It is actually the explanation. Being second-best at web backends, data analysis, automation, scientific computing and machine learning simultaneously turns out to be worth more than being first at one of them - because the same person can do all five without learning five languages.
Two things happened recently that make this a good moment to reassess. The Global Interpreter Lock - Python's most-cited limitation for thirty years - is now optional. And the packaging story, its second-most-cited limitation, was fixed by a tool written in Rust that most of the ecosystem adopted in about two years.
What Python is, and what it optimises for
Guido van Rossum released Python in 1991 with a design goal that sounds banal and turned out to be strategic: code is read far more often than it is written, so readability should win over cleverness. Significant whitespace, one obvious way to do things, no braces, no semicolons.
Three decades later, that decision is why Python is the language biologists, financial analysts, sysadmins and machine learning researchers all use. It is learnable by people whose job is not programming, and it stays maintainable when written by people whose job is not programming. No other general-purpose language has managed both.
The trade it makes
| Python gives you | In exchange for |
|---|---|
| Extremely fast development | Slower execution - often 10× to 100× versus compiled languages |
| Dynamic typing and duck typing | Type errors that surface at runtime, not compile time |
| Automatic memory management | Higher memory use and less predictable timing |
| A vast ecosystem for almost anything | Dependency management that was genuinely painful until recently |
| Readable code by default | Whitespace sensitivity that some people never stop resenting |
Versions and support
| Version | Status | Notes |
|---|---|---|
| 3.15 | Release candidate, final due 1 October 2026 | UTF-8 as the default encoding, a built-in sampling profiler, explicit lazy imports |
| 3.14 | Current stable, released October 2025 | Free-threading officially supported, expanded JIT, t-strings |
| 3.13 | Bug fixes | Introduced the experimental free-threaded build and the new REPL |
| 3.12 and earlier | Security fixes only, then end of life | Check the support window before starting anything new on these |
Getting started, the 2026 way
If you learned Python before about 2024, this section is the one worth reading. The old advice - install Python from python.org, use venv, use pip, maybe use pyenv, possibly use Poetry - has been largely replaced by a single tool.
uv
uv is a package and project manager written in Rust by Astral, the team behind the Ruff linter. It replaces pip, pip-tools, virtualenv, pyenv and pipx, reports installs one to two orders of magnitude faster than pip, and manages Python interpreter versions itself. It went from launch in early 2024 to being the default recommendation for new projects in about two years.
# Install uv - no existing Python required
curl -LsSf https://astral.sh/uv/install.sh | sh
# Start a project
uv init shop-api
cd shop-api
# Add dependencies - this creates the venv, resolves and locks in one step
uv add fastapi uvicorn sqlalchemy
uv add --dev pytest ruff mypy
# Run things inside the environment without activating it
uv run python main.py
uv run pytest
# Install and pin a specific interpreter
uv python install 3.14
uv python pin 3.14
# Reproduce the exact environment elsewhere - this is what CI should run
uv sync --frozen
# Run a tool without installing it into your project
uvx ruff check .uv add writes to pyproject.toml and updates uv.lock. Commit both. There is no separate requirements.txt step and no manual activate.[project]
name = "shop-api"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.120",
"sqlalchemy>=2.0",
]
[dependency-groups]
dev = ["pytest>=8", "ruff>=0.14", "mypy>=1.15"]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM", "RUF"]
[tool.mypy]
strict = true
python_version = "3.14"setup.py, requirements.txt, .flake8 and setup.cfg are no longer needed.If you are not using uv
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
pip freeze > requirements.txt # note: this captures your whole environment, not your intentpip freeze habit is the weak point - it records what happens to be installed rather than what you actually depend on.The language, for people who already program
Everything is an object, and the data model is the point
Functions, classes, modules and types are all first-class objects. What makes Python feel coherent rather than arbitrary is the data model: built-in behaviour is defined by dunder methods, and your own types can implement the same ones.
class Money:
def __init__(self, cents: int, currency: str = "BDT") -> None:
self.cents = cents
self.currency = currency
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("cannot add different currencies")
return Money(self.cents + other.cents, self.currency)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, Money)
and (self.cents, self.currency) == (other.cents, other.currency)
)
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
def __format__(self, spec: str) -> str:
return f"{self.cents / 100:,.2f} {self.currency}"
total = Money(4999) + Money(2500)
print(f"{total}") # 74.99 BDT__len__ and len() works. Implement __iter__ and for works. Nothing is special-cased for built-in types.Comprehensions and generators
orders = [{"id": 1, "total": 4999, "paid": True}, {"id": 2, "total": 2500, "paid": False}]
# List comprehension
paid_ids = [o["id"] for o in orders if o["paid"]]
# Dict comprehension
totals = {o["id"]: o["total"] for o in orders}
# Generator expression - lazy, constant memory, use it for large data
revenue = sum(o["total"] for o in orders if o["paid"])
# A generator function streams instead of materialising
def read_large_file(path: str):
with open(path) as f:
for line in f: # files iterate line by line, not all at once
yield line.rstrip()
for line in read_large_file("10gb.log"):
process(line) # memory stays flat regardless of file size[x for x in huge] builds the whole list in memory; (x for x in huge) does not.Dataclasses instead of dictionaries
from dataclasses import dataclass, field
from datetime import datetime, UTC
@dataclass(frozen=True, slots=True)
class Order:
id: int
customer_id: int
total_cents: int
tags: list[str] = field(default_factory=list)
placed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@property
def total(self) -> float:
return self.total_cents / 100
order = Order(id=1, customer_id=7, total_cents=4999)frozen=True makes instances immutable and hashable. slots=True cuts memory use and speeds attribute access. default_factory is mandatory for mutable defaults - see the mistakes section for why.Pattern matching
def handle(event: dict) -> str:
match event:
case {"type": "payment", "amount": int(amount)} if amount > 100_000:
return f"large payment: {amount}"
case {"type": "payment", "amount": amount}:
return f"payment: {amount}"
case {"type": "refund", "order_id": str() | int() as oid}:
return f"refund for {oid}"
case {"type": str(kind)}:
return f"unhandled: {kind}"
case _:
raise ValueError("malformed event")match than to C's switch.Context managers
from contextlib import contextmanager
import time
@contextmanager
def timed(label: str):
start = time.perf_counter()
try:
yield
finally:
print(f"{label}: {time.perf_counter() - start:.3f}s")
with timed("import"), open("data.csv") as f:
rows = f.readlines()finally matters - cleanup runs even if the body raises. This is why with open(...) is the only correct way to open a file.Type hints changed the language
Python's biggest shift in the last decade was not a syntax feature. It was the gradual arrival of a type system that is optional, checked by external tools, and ignored entirely at runtime - which turns out to be the right design for a dynamic language with thirty years of untyped code behind it.
from collections.abc import Iterable, Callable
from typing import Protocol, TypedDict, Literal
# Builtins are generic - no more typing.List or typing.Dict
def total(values: list[int]) -> int:
return sum(values)
# Unions with |, optional with | None
def find_user(user_id: int) -> dict[str, str] | None: ...
# Literal types for a fixed set of values
Status = Literal["pending", "paid", "shipped", "cancelled"]
# TypedDict for structured dictionaries you cannot turn into classes
class OrderRow(TypedDict):
id: int
status: Status
total_cents: int
# Protocols: structural typing, no inheritance required
class Storage(Protocol):
def get(self, key: str) -> bytes | None: ...
def put(self, key: str, value: bytes) -> None: ...
def cache_result(storage: Storage, key: str) -> None: ...
# Anything with those two methods satisfies Storage. Duck typing, checked statically.# Generics with the 3.12+ syntax - no TypeVar declaration needed
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
class Repository[T]:
def __init__(self) -> None:
self._items: dict[int, T] = {}
def add(self, key: int, item: T) -> None:
self._items[key] = item
def get(self, key: int) -> T | None:
return self._items.get(key)uv run mypy src/ # the established checker
uvx ty check src/ # Astral's Rust checker, much faster, newer
uvx pyright src/ # Microsoft's, strong inferencemypy on new modules only, then tighten. Turning strict = true on a large untyped codebase produces thousands of errors and gets abandoned.Concurrency, and the end of the GIL era
This is the part of Python that has been misunderstood for longest, and the part that has genuinely changed.
What the GIL actually does
The Global Interpreter Lock is a mutex ensuring only one thread executes Python bytecode at a time. It exists because CPython's memory management uses reference counting, and making every reference count update atomic without a global lock is both hard and slow.
The practical consequence: threads give you nothing for CPU-bound work, because only one runs at a time. They work fine for I/O-bound work, because the GIL is released while waiting on a network call or a disk read.
| Workload | Reach for |
|---|---|
| Waiting on network, disk or a database | asyncio, or threads - the GIL is released during the wait |
| CPU-bound with the standard build | multiprocessing or concurrent.futures.ProcessPoolExecutor |
| CPU-bound in NumPy, pandas or PyTorch | Nothing special - those release the GIL and run native code |
| CPU-bound pure Python, free-threaded build | Threads, now that they actually parallelise |
| Thousands of concurrent connections | asyncio - threads do not scale to that count |
asyncio
import asyncio
import httpx
async def fetch(client: httpx.AsyncClient, url: str) -> int:
response = await client.get(url)
return len(response.content)
async def main() -> None:
urls = [f"https://example.com/page/{i}" for i in range(50)]
async with httpx.AsyncClient() as client:
# TaskGroup cancels siblings if one fails - prefer it over gather()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(client, url)) for url in urls]
total = sum(task.result() for task in tasks)
print(f"{total:,} bytes from {len(urls)} pages")
asyncio.run(main())TaskGroup is the modern replacement for asyncio.gather. It provides structured concurrency: if one task fails, the rest are cancelled and the errors surface together.The catch with async is the same one every language with coloured functions has: a blocking call inside an async function stalls the entire event loop. time.sleep, a synchronous database driver, or a CPU-heavy loop will freeze every other coroutine. Use asyncio.to_thread to push blocking work out of the loop.
import asyncio
def parse_big_csv(path: str) -> list[dict]: ... # synchronous, slow
async def handler(path: str):
rows = await asyncio.to_thread(parse_big_csv, path) # off the event loop
return len(rows)Free-threading, honestly
Python 3.13 shipped an experimental free-threaded build. Python 3.14 made it officially supported under PEP 779. In that build the GIL is gone and CPU-bound threads genuinely run in parallel across cores.
| Question | Where things stand |
|---|---|
| Is it in the default build? | No. It is a separate build - the interpreter is named python3.14t |
| What is the single-threaded cost? | Roughly 5–10% in 3.14, down from around 40% in 3.13 |
| Do my libraries work? | C extensions must opt in. One that has not will silently re-enable the GIL for the whole process |
| Do pure-Python libraries work? | Generally yes, but thread-safety bugs that the GIL was hiding become real |
| When does it become the default? | No PEP and no timeline for that phase yet |
# Install and check
uv python install 3.14t
python3.14t -c "import sys; print(sys._is_gil_enabled())"
# False means you are genuinely running without the GILTrue on a free-threaded build, some extension you imported forced the GIL back on. Bisect your imports to find which.Performance: what is slow and what to do
Python is slow in a specific way. Interpreted bytecode, dynamic dispatch on every operation, boxed objects for every integer, and reference counting on every assignment. A tight numeric loop in pure Python can be a hundred times slower than the equivalent C.
It matters less than that number suggests, because most Python programs spend their time waiting - for a database, an API, a disk. And where it does matter, the established answer is that the hot path is not written in Python at all.
| Approach | What it looks like |
|---|---|
| Use the right data structure | A set membership test instead of a list scan - the most common real fix |
| Vectorise | NumPy and pandas push loops into C; a vectorised operation can be 50× a Python loop |
| Native libraries | Polars, DuckDB, PyArrow - Rust and C++ engines with Python front ends |
| Write the hot path in another language | PyO3 for Rust, Cython, or a C extension |
| The 3.14 JIT | Experimental copy-and-patch JIT; 10–30% on compute-heavy code, nothing on I/O |
| A different interpreter | PyPy is much faster on long-running pure-Python workloads, with weaker C extension support |
# Profile before optimising anything
python -m cProfile -s cumtime script.py | head -30
# Line-level detail
uvx line_profiler script.py
# Sampling profiler for a live process - new in 3.15, and much lower overhead
python -m profiling sample <pid>
# Memory
uvx memray run script.pyWhere Python genuinely wins
| Domain | Why Python owns it |
|---|---|
| Machine learning and AI | PyTorch, TensorFlow, JAX, transformers - this is not close, and it is not changing |
| Data analysis and science | pandas, Polars, NumPy, SciPy, Jupyter; the tooling non-programmers actually use |
| Automation and scripting | Batteries included, readable, installed on every Linux box |
| Web backends | Django for batteries-included, FastAPI for typed async APIs, Flask for small services |
| Scientific computing | Decades of domain libraries - astronomy, bioinformatics, chemistry, climate |
| Glue code and integrations | An SDK exists for essentially every service you might need to talk to |
| Teaching programming | The lowest distance between an idea and a running program |
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
class CreateOrder(BaseModel):
customer_id: int
total_cents: int = Field(gt=0, le=10_000_000)
note: str | None = Field(default=None, max_length=500)
@app.post("/orders", status_code=201)
async def create_order(payload: CreateOrder) -> dict:
if not await customer_exists(payload.customer_id):
raise HTTPException(404, "customer not found")
return await save_order(payload)Where Python is the wrong choice
CPU-bound work where CPU is the bill
If your service is compute-heavy and running constantly, the interpreter overhead is a real infrastructure cost. Free-threading helps with parallelism; it does not make single-threaded Python fast. Go, Rust or a JVM language will do more per core.
Anything with hard latency requirements
Reference counting plus a cycle-collecting garbage collector means unpredictable pauses. For trading systems, real-time control or audio processing, that unpredictability disqualifies it regardless of average throughput.
Mobile and browser
There is no serious story for iOS or Android apps, and while Pyodide runs Python in the browser via WebAssembly, downloading a multi-megabyte interpreter is not a general web solution.
Distributing to end users
Shipping a Python application to someone who does not have Python is genuinely awkward. PyInstaller and Nuitka work, produce large artifacts, and break in ways that are tedious to debug. A Go or Rust binary is one file.
Large codebases without discipline
Dynamic typing scales badly with team size and codebase age. A 200,000-line untyped Python codebase is genuinely harder to refactor than the equivalent in a statically typed language, because the compiler cannot tell you what you broke. Type hints plus a checker in CI closes most of that gap - but only if you adopt them early enough that the annotation debt stays manageable.
Where the ecosystem is thin
Systems programming, game engines, embedded work below a Raspberry Pi, and high-frequency anything. Python can call into all of these; it is rarely the language they are written in.
Production Python
uv sync --frozen # exact locked environment
uv run ruff format --check . # formatting
uv run ruff check . # linting - replaces flake8, isort, pyupgrade and more
uv run mypy src/ # type checking
uv run pytest --cov=src # tests with coverageimport pytest
from app.orders import Order, apply_discount
@pytest.fixture
def order() -> Order:
return Order(id=1, customer_id=7, total_cents=10_000)
@pytest.mark.parametrize(
("percent", "expected"),
[(0, 10_000), (10, 9_000), (100, 0)],
)
def test_discount(order: Order, percent: int, expected: int) -> None:
assert apply_discount(order, percent).total_cents == expected
def test_rejects_negative_discount(order: Order) -> None:
with pytest.raises(ValueError, match="must be between"):
apply_discount(order, -5)parametrize turns three near-identical tests into one. pytest.raises with match checks that the right error was raised, not just any error.FROM python:3.14-slim AS build
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY . .
RUN uv sync --frozen --no-dev
FROM python:3.14-slim
WORKDIR /app
COPY --from=build /app /app
ENV PATH="/app/.venv/bin:$PATH"
RUN useradd -m app && chown -R app /app
USER app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Mistakes that keep recurring
# Mutable default arguments - evaluated ONCE, at function definition
def add_item(item, basket=[]): # every call shares the same list
basket.append(item)
return basket
add_item("a") # ['a']
add_item("b") # ['a', 'b'] ← not what anyone expects
# Correct
def add_item(item, basket: list | None = None):
basket = [] if basket is None else basket
basket.append(item)
return basketruff flags it as B006.| Mistake | What happens |
|---|---|
| Mutable default arguments | State leaks between calls |
except: with no exception type | Swallows KeyboardInterrupt and SystemExit too. Use except Exception: |
| Modifying a list while iterating it | Items silently skipped. Iterate over a copy, or build a new list |
== where is was meant, or the reverse | is compares identity; small ints and short strings are cached, so it appears to work until it does not |
| Star imports | from module import * makes the origin of every name unknowable |
| A blocking call inside an async function | The entire event loop stalls |
| Circular imports | Usually a sign two modules should be one, or a third should exist |
pip freeze > requirements.txt | Records your whole environment rather than your actual dependencies |
| Catching an exception and only logging it | The failure disappears and the caller proceeds on bad data |
| Relying on dict ordering before 3.7 | Guaranteed since 3.7, so this one is finally safe |
Python against the alternatives
| Language | It wins on | Python wins on | Choose it when |
|---|---|---|---|
| Go | Concurrency, raw speed, single-binary deployment | Ecosystem breadth, ML, data tooling, expressiveness | You are building networked services and want easy deployment |
| Rust | Performance, memory safety, predictable latency | Development speed, learning curve, hiring, libraries | Correctness and speed are worth a much slower build |
| TypeScript / Node | One language across frontend and backend, async ergonomics | Data science, ML, scientific computing, scripting | The team is already a web team |
| Java / C# | JVM and .NET ecosystems, enterprise tooling, static typing | Iteration speed, conciseness, scientific ecosystem | You are inside an existing enterprise stack |
| R | Statistics, specialist academic packages | General programming, production deployment | The work is pure statistics and stays in analysis |
| Julia | Numerical performance without leaving the language | Ecosystem size, hiring, maturity | Heavy numerical work and you can accept a smaller ecosystem |
A learning path
- Weeks one to two. Syntax and data structures - lists, dicts, sets, tuples, and when each is right. Functions, comprehensions, files, error handling. Write small scripts that do something real: rename files, parse a CSV, call an API.
- Weeks three to four. Classes, the data model, modules and packages.
uvfor projects from the start rather than learningvenvhabits you will replace. The standard library:pathlib,datetime,collections,itertools,json,re. - Month two. Type hints and
mypy.pytestand writing tests as you go.rufffor linting and formatting. Pick a domain - a FastAPI service, a pandas analysis, a scraper - and build something end to end. - Month three. Concurrency: understand the GIL properly, then
asyncio, then when to use processes. Profiling withcProfile. Packaging your project so someone else can install it. - Ongoing. Read the standard library source - it is readable, and
collections,contextlibanddataclassesare an education in idiomatic Python. Read PEPs for features you use; they explain the reasoning, not just the syntax.
- The Python Tutorial - the official one, better than most paid courses
- Real Python - consistently good practical articles
- Fluent Python - the book that turns competent Python into idiomatic Python
- Astral's uv documentation - the current packaging story in one place
- PEP index - the reasoning behind every language decision
Verdict
Python's reputation lags what it actually is now. The version most people picture - untyped, slow, with dependency management that ruins afternoons - has been superseded in every one of those respects. Type hints with a checker in CI give you most of the safety of a static language. uv made packaging genuinely pleasant. The GIL is optional. The JIT is real, if early.
What has not changed is the trade at the centre. Python buys you development speed and an ecosystem nothing else matches, and it charges you execution speed and runtime type safety. If your bottleneck is a database query, an API call or a researcher's time, that is an excellent deal. If your bottleneck is CPU cycles under sustained load, it is a bad one, and no amount of free-threading changes the arithmetic.
Use uv, turn on type hints and a checker from the first commit, and profile before you optimise anything. Those three habits separate Python codebases that age well from the ones people are afraid to touch.
For machine learning, data work, automation and most web backends, Python remains the obvious default, and the tooling is better this year than it has ever been. For anything where the CPU is the constraint, use Python as the orchestration layer and put the hot path somewhere else - which, conveniently, is exactly what the entire scientific stack already does.
Sources
- Python documentation - the language reference, tutorial and standard library
- Status of Python versions - release phases and support windows
- Free-threading how-to - the official guidance on the no-GIL build
- PEP 779 - official support for the free-threaded build
- PEP 790 - the Python 3.15 release schedule
- uv documentation and Ruff - the current toolchain
- PEP index - every language decision, with its reasoning



