DevelopmentAll levels

Python

Python is the language people reach for when they want to be reading working code within the hour, and the one that ended up owning data work and machine learning outright. It is also the runtime with the most misunderstood performance model: adding threads to a Python program usually makes it no faster at all, for a reason that is entirely predictable once you know it. This page covers the GIL, typing, packaging, Django against FastAPI, and where Python projects come apart.

Bytecode
One thread at a time
Types
Hints, checked by tools
Releases
Yearly, 5 years of support

Why more threads did not help

CPython protects its internals with a single lock — the global interpreter lock — and a thread must hold it to execute bytecode. Threads are real operating-system threads, they are scheduled normally, and only one of them is ever running Python code. Start four threads to crunch numbers on a four-core machine and you get the throughput of one core plus the overhead of switching between them.

Only one thread can execute Python bytecode at a time, so three CPU-bound threads take turns on one core no matter how many cores the machine has — the left panel has one filled slot per column. Three processes each own an interpreter and its lock, so all of their slots are filled at once. The exception is what makes threads worth keeping: a thread waiting on a socket, a file or a database releases the lock while it waits, so I/O-bound work does overlap.

The other half of the rule is what keeps threads useful. A thread that is waiting — on a socket, a file, a database answer — releases the lock while it waits, and so do the C extensions that do the heavy lifting in NumPy, pandas and the database drivers. So threads genuinely overlap for I/O work, and code that spends its time waiting on other systems scales on threads perfectly well. What does not overlap is Python bytecode executing.

asyncio deserves its own sentence, because it is often reached for as a general speed-up and it is not one. It gives you concurrency on a single thread: many operations in flight, one running at a time, switching whenever one awaits. That is an excellent fit for a service holding thousands of connections. It also has a sharp edge — a single blocking call inside an async function stalls the entire loop, so one library that does not support await can undo the whole design. Mixing async and blocking code carelessly is the most common way an asyncio service ends up slower than the threaded version it replaced.

This is changing, slowly. Python 3.13 shipped an experimental build with the GIL removed, and 3.12 added per-interpreter isolation that lets subinterpreters run in parallel. Neither is the default and neither is something to plan a production system around yet — but the direction is clear enough that "Python cannot use multiple cores" is already too strong a statement. For anything you are building this year, the model above is still the one that describes your process.

Dynamic typing, and how teams survive it

Python checks types when it runs, not when you save. That is what makes it quick to write and what makes a large Python codebase risky: a misspelled attribute is a AttributeError in production rather than a red underline. Type hints are the answer the language settled on — annotations that the interpreter ignores at runtime and that a checker like mypy or Pyright uses to find the mistake before it ships.

Hints are optional and gradual, which is what makes them adoptable — annotate the boundaries of a module today, its internals next month, and the checker gets stricter as you go. The place they pay for themselves first is exactly where data enters the system, and that is also where hints alone are not enough: a hint says what should arrive, not what did. Pydantic closes that gap by validating and coercing real data against the annotation at runtime, which is why it sits underneath FastAPI and most modern Python API code.

Packaging is the other thing a Python team has to get right early, because the failure is silent. Every project gets its own virtual environment; dependencies are declared in pyproject.toml; and the exact resolved versions go in a lockfile that is committed. Without a lockfile, two developers and the CI machine run three different dependency sets and the difference only shows up as an unreproducible bug. Modern tooling — uv, Poetry, or pip with pip-tools — does all of this; which one matters far less than having one.

Django, FastAPI, and what each assumes

Two frameworks cover most server-side Python, and they are built on opposite assumptions rather than being competitors at different quality levels. Django gives you a complete application — ORM, migrations, authentication, admin, forms, templates — and expects you to build inside it. FastAPI gives you routing, validation and documentation, and expects you to choose everything else. Flask still exists and is a reasonable minimal choice; FastAPI is where most new API work goes because the validation and the generated OpenAPI schema come from the same type hints you were writing anyway.

Django

  • Batteries included — admin, auth, ORM and migrations arrive on day one.
  • Synchronous by default; async support exists but the ORM is only partly there.
  • Best for products with a data model, an editor interface and real user accounts.
  • The risk is a "fat model" that grows until business rules can only be tested through views.

FastAPI

  • Async-first, built on ASGI — a natural fit for services that mostly wait on other services.
  • Validation and OpenAPI documentation are generated from your type hints.
  • Best for APIs, internal services and anything fronting a model or another system.
  • The risk is assembling your own stack — ORM, migrations, auth, admin — one decision at a time.

One practical warning that applies to both. A Python web application does not run itself in production — it runs under a server process, Gunicorn or Uvicorn, and that server's worker count is your real concurrency limit. Because of the GIL, the useful configuration is multiple worker processes rather than many threads in one, and the count is bounded by memory rather than by cores. Getting this wrong is the most common reason a Python service that benchmarks fine falls over under real traffic.

How this shows up in real delivery

Python's dominance in data and machine learning is not really about the language — it is about the libraries, and about the fact that their hot paths are not Python at all. NumPy, pandas, PyTorch and the rest are thin Python interfaces over compiled C, C++ and CUDA. This is worth knowing because it tells you how to make numeric Python fast: express the operation as an array operation the library performs in one call, rather than as a loop that steps through elements in Python. The same computation written both ways can differ by two orders of magnitude, and it is the same reason the GIL is not the obstacle it sounds like in that world.

Two habits keep a Python service maintainable at size, and both are about making the machine do the checking. Run a formatter and a linter — Ruff has largely replaced the older stack and is fast enough to run on save — so nobody spends review time on style. And gate merges on mypy or Pyright at whatever strictness the codebase currently passes, raising it deliberately. Without that, type hints decay into decoration: present, wrong, and trusted.

Where it degrades

  • Threads added to speed up CPU-bound work, which the interpreter lock guarantees will not help.
  • A blocking call inside an async handler, which stops the event loop for every other request too.
  • Mutable default arguments and shared class-level containers, which leak state between calls.
  • Dependencies without a lockfile, so the build that passed CI is not the build that reached the server.
  • Type hints written but never checked, which are worse than none because they are believed.
  • Element-by-element loops over arrays where a vectorised call would do the same work in compiled code.
  • Django ORM relations touched inside a template loop, which is the N+1 query in its Python dialect.

When to use it

Use it when

  • Data work, analytics and machine learning, where the ecosystem has no serious competitor.
  • APIs and internal services whose time goes on waiting — FastAPI plus async fits that shape exactly.
  • Products that need an admin interface, accounts and a data model quickly — that is what Django is.
  • Automation, scripting and glue between systems, where readability matters more than throughput.

Avoid it when

  • CPU-bound services expected to use every core in one process — that is the one thing the runtime will not do.
  • Latency-critical paths measured in microseconds, where interpreter overhead is the dominant cost.
  • Large codebases with no type checking in CI, where the absence of a compiler is felt every release.
  • Environments that cannot ship an interpreter and its dependencies — a single binary is not what this produces.

Found this useful?

Share it with someone who is working on the same problem.