Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
pythonbackendperformancetoolingdata

Python in 2026: optional GIL, real types, and packaging that finally works

A working guide to modern Python - uv, type hints, asyncio, free-threading, performance, production tooling, and an honest look at where it falls short.

T

team

16 min read
A stylised illustration of a Python snake, with a network of pipes and gears surrounding it. The pipes represent the flow of data through the language, and the gears represent the internal workings of Python.

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 youIn exchange for
Extremely fast developmentSlower execution - often 10× to 100× versus compiled languages
Dynamic typing and duck typingType errors that surface at runtime, not compile time
Automatic memory managementHigher memory use and less predictable timing
A vast ecosystem for almost anythingDependency management that was genuinely painful until recently
Readable code by defaultWhitespace sensitivity that some people never stop resenting
Every row is a real trade. Whether it is a good one depends entirely on whether developer time or CPU time is your scarcer resource.

Versions and support

VersionStatusNotes
3.15Release candidate, final due 1 October 2026UTF-8 as the default encoding, a built-in sampling profiler, explicit lazy imports
3.14Current stable, released October 2025Free-threading officially supported, expanded JIT, t-strings
3.13Bug fixesIntroduced the experimental free-threaded build and the new REPL
3.12 and earlierSecurity fixes only, then end of lifeCheck the support window before starting anything new on these
Target 3.13 or 3.14 for new work. Libraries take a few months to fully support a new release, so the newest version is not automatically the right one on day one.

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.

bash
# 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.
pyproject.toml
[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"
One file for dependencies, dev groups, linting and type checking configuration. This consolidation is the other half of what changed - setup.py, requirements.txt, .flake8 and setup.cfg are no longer needed.

If you are not using uv

bash
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 intent
This still works and is what most existing projects use. The pip 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.

python
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
Implement __len__ and len() works. Implement __iter__ and for works. Nothing is special-cased for built-in types.

Comprehensions and generators

python
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
The square brackets versus parentheses distinction matters more than it looks: [x for x in huge] builds the whole list in memory; (x for x in huge) does not.

Dataclasses instead of dictionaries

python
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

python
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")
This is structural pattern matching, not a switch statement. It destructures, binds names, checks types and supports guards - closer to Rust's match than to C's switch.

Context managers

python
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()
The 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.

python
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.
Protocols are the feature worth learning first. They let you type an interface without forcing every implementation to inherit from a base class.
python
# 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)
bash
uv run mypy src/          # the established checker
uvx ty check src/         # Astral's Rust checker, much faster, newer
uvx pyright src/          # Microsoft's, strong inference
Adopt typing gradually. Start with mypy 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.

WorkloadReach for
Waiting on network, disk or a databaseasyncio, or threads - the GIL is released during the wait
CPU-bound with the standard buildmultiprocessing or concurrent.futures.ProcessPoolExecutor
CPU-bound in NumPy, pandas or PyTorchNothing special - those release the GIL and run native code
CPU-bound pure Python, free-threaded buildThreads, now that they actually parallelise
Thousands of concurrent connectionsasyncio - threads do not scale to that count

asyncio

python
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.

python
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.

QuestionWhere 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
The important nuance: the GIL is now removable, not removed. Those are different claims, and a lot of writing conflates them.
bash
# 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 GIL
If that prints True 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.

ApproachWhat it looks like
Use the right data structureA set membership test instead of a list scan - the most common real fix
VectoriseNumPy and pandas push loops into C; a vectorised operation can be 50× a Python loop
Native librariesPolars, DuckDB, PyArrow - Rust and C++ engines with Python front ends
Write the hot path in another languagePyO3 for Rust, Cython, or a C extension
The 3.14 JITExperimental copy-and-patch JIT; 10–30% on compute-heavy code, nothing on I/O
A different interpreterPyPy is much faster on long-running pure-Python workloads, with weaker C extension support
bash
# 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.py
The rule is unchanged: measure first. The slow part is almost never where you assumed, and in Python it is very often an accidental O(n²) rather than the language itself.

Where Python genuinely wins

DomainWhy Python owns it
Machine learning and AIPyTorch, TensorFlow, JAX, transformers - this is not close, and it is not changing
Data analysis and sciencepandas, Polars, NumPy, SciPy, Jupyter; the tooling non-programmers actually use
Automation and scriptingBatteries included, readable, installed on every Linux box
Web backendsDjango for batteries-included, FastAPI for typed async APIs, Flask for small services
Scientific computingDecades of domain libraries - astronomy, bioinformatics, chemistry, climate
Glue code and integrationsAn SDK exists for essentially every service you might need to talk to
Teaching programmingThe lowest distance between an idea and a running program
python
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)
FastAPI reads your type hints to validate requests, serialise responses and generate an OpenAPI schema. This is the clearest example of typing paying for itself.

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

bash
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 coverage
Ruff is one Rust binary replacing five Python tools, and it is fast enough that running it on save is unnoticeable.
tests/test_orders.py
import 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.
Dockerfile
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"]
Copying the lockfile and syncing before copying the source keeps the dependency layer cached across source changes.

Mistakes that keep recurring

python
# 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 basket
This is the single most famous Python gotcha and it still catches experienced developers. ruff flags it as B006.
MistakeWhat happens
Mutable default argumentsState leaks between calls
except: with no exception typeSwallows KeyboardInterrupt and SystemExit too. Use except Exception:
Modifying a list while iterating itItems silently skipped. Iterate over a copy, or build a new list
== where is was meant, or the reverseis compares identity; small ints and short strings are cached, so it appears to work until it does not
Star importsfrom module import * makes the origin of every name unknowable
A blocking call inside an async functionThe entire event loop stalls
Circular importsUsually a sign two modules should be one, or a third should exist
pip freeze > requirements.txtRecords your whole environment rather than your actual dependencies
Catching an exception and only logging itThe failure disappears and the caller proceeds on bad data
Relying on dict ordering before 3.7Guaranteed since 3.7, so this one is finally safe

Python against the alternatives

LanguageIt wins onPython wins onChoose it when
GoConcurrency, raw speed, single-binary deploymentEcosystem breadth, ML, data tooling, expressivenessYou are building networked services and want easy deployment
RustPerformance, memory safety, predictable latencyDevelopment speed, learning curve, hiring, librariesCorrectness and speed are worth a much slower build
TypeScript / NodeOne language across frontend and backend, async ergonomicsData science, ML, scientific computing, scriptingThe team is already a web team
Java / C#JVM and .NET ecosystems, enterprise tooling, static typingIteration speed, conciseness, scientific ecosystemYou are inside an existing enterprise stack
RStatistics, specialist academic packagesGeneral programming, production deploymentThe work is pure statistics and stays in analysis
JuliaNumerical performance without leaving the languageEcosystem size, hiring, maturityHeavy numerical work and you can accept a smaller ecosystem

A learning path

  1. 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.
  2. Weeks three to four. Classes, the data model, modules and packages. uv for projects from the start rather than learning venv habits you will replace. The standard library: pathlib, datetime, collections, itertools, json, re.
  3. Month two. Type hints and mypy. pytest and writing tests as you go. ruff for linting and formatting. Pick a domain - a FastAPI service, a pandas analysis, a scraper - and build something end to end.
  4. Month three. Concurrency: understand the GIL properly, then asyncio, then when to use processes. Profiling with cProfile. Packaging your project so someone else can install it.
  5. Ongoing. Read the standard library source - it is readable, and collections, contextlib and dataclasses are an education in idiomatic Python. Read PEPs for features you use; they explain the reasoning, not just the syntax.

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.

Our standing advice on Python projects

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

Back to Blog
Share:

Related Posts