Lipstick on a Snake

Lipstick on a Snake

Type hints, Pydantic and asyncio are valuable tools — but they do not turn CPython into a statically typed, automatically parallel runtime

TL;DR

  • Python is the right choice for scripting. It reads like pseudocode and gets you from an empty file to a working script in minutes. Teams reach for it, and they are right to.
  • The trouble starts when the script becomes production software. Instead of reconsidering the language, teams bolt on type hints, linters, Pydantic everywhere and asyncio across the call graph until it feels like a type-safe, multithreaded language. Applying lipstick on a snake still makes it a snake.
  • None of it reaches the runtime. mypy catches real bugs, but it is advisory: one # type: ignore silences it, it is weakest at the untyped boundaries where bad data arrives, and it is gone once the program runs. Pydantic validates only where you call it. asyncio is concurrency for waiting on I/O, not parallelism.
  • It costs the readability you chose Python for. Three-line signatures like AsyncIterator[tuple[int, Result[Order, ValidationError]]], half the imports from typing, and the business logic reduced to a minority shareholder in its own file.
  • And it costs more than the real thing. Making Python pretend to be a statically typed, compiled language takes more effort than using one in the first place — and still leaves you with weaker guarantees than a compiler gives you for free.
  • On CPU-bound work, C# was about 24x faster. Eight tasks took 2.569s on CPython 3.14 and 0.109s in C#, measured on one machine with sources published. asyncio.gather returned 1.00x. Free-threaded Python genuinely reaches 6.04x, but it is a separate interpreter, an ABI migration for every compiled dependency, and still ~20x slower than C#. In the cloud, that speed is money: compute is billed by the time it runs, and companies that moved CPU-heavy services off Python report compute bills falling threefold or more — though each was also a rewrite, and only part of a CPU saving reaches the bill.
  • If you are using Pydantic to fake a type system or asyncio to fake parallelism, you have outgrown Python. Used for what they are actually for — validating data at the edge of a program, waiting on sockets — both are Python working exactly as designed.
  • C# has closed the old gaps. A single file runs with a shebang, there is no virtual environment to manage, and Roslyn gives it a REPL that type-checks every line you type. CSnakes embeds CPython in-process, so NumPy and PyTorch stay available — and for everyday array and table work, native libraries such as NumSharp and Microsoft’s DataFrame mean you may not need Python in the process at all. AI-assisted coding has largely dissolved the syntax barrier — and static types make AI-assisted code safer, because a compiler reviews every line of generated code for free.
  • My rule of thumb: Rust instead of C; C# instead of C++ (unless you need Unreal Engine level performance) and instead of Python-pretending-to-be-C#; Python or Lua where scripting is genuinely the job. For notebook-shaped work Jupyter remains the safer institutional bet, though Verso has picked up the .NET notebook story Microsoft dropped.
  • This is not C# versus Python. Learn both. It is about using the right tool for the job.

There is a story I have watched play out in production teams more times than I can count. It always starts the same way, and it always ends the same way.

It starts with a genuine need for a scripting language. Someone has to stitch two systems together, parse a directory of CSVs, poke an API on a schedule, glue a model to a queue. Python is often the obvious, correct answer. It is widely available, it reads like pseudocode, and you can go from an empty file to a working thing in ten minutes.

Then the script survives. It gets a second feature. It gets a cron entry. It gets a second author. Somewhere in there, quietly, the requirement changes from a short-lived script to long-lived production software — the thing that needs to be correct at 3 a.m., maintained by someone who did not write it, and fast enough that the bill stops growing.

And this is where the trouble begins. Instead of admitting that the requirement changed, the team tries to change Python into something it is not.

Out come the band-aids. Type hints. mypy in strict mode. Pydantic models on everything. asyncio sprayed across the call graph. Protocols, TypedDict, Literal, generics, overloads, ruff, pyright, a pyproject.toml that is longer than most of the modules it governs. The team now feels like it is working in a type-safe, concurrent, enterprise-grade language.

It is not. It is working in Python with a lot of extra steps.

Applying lipstick on a snake still makes it a snake.

Every benchmark result and diagnostic quoted in this post was produced on the machine I wrote it on. The setup is at the end.


What the band-aids actually do

Every one of these tools is a good tool. That is not the argument. The argument is about what people believe they are buying versus what they are actually getting.

  • Type hints + mypy/pyright: They promise type safety. They provide a separate, advisory checker over annotations the runtime never enforces.
  • Pydantic: It promises typed data models. It provides runtime validation — and, by default, coercion — at the boundaries where you invoke it.
  • asyncio: It is often mistaken for multithreading. A single event loop provides cooperative concurrency; CPU work only goes parallel if you hand it to an executor.
  • multiprocessing: It promises cheap parallelism. It provides separate interpreters, serialized data, and a new class of bugs.
  • Dataclasses: They promise typed records. They generate __init__, __eq__, and __repr__, and optionally emulate immutability with frozen=True; field types are not enforced.
  • attrs: It offers the same record-building conveniences, plus genuine runtime validators and converters if you write them.
  • Protocol: It resembles an interface. It provides static structural typing; @runtime_checkable adds isinstance, but checks member presence rather than signatures or types.

Let us take the two big ones seriously.

Type hints do not make Python type safe

This is legal, ships to production, and runs:

def add(a: int, b: int) -> int:
    return a + b

add("1", "2")   # '12'

The annotations are still right there at runtime — add.__annotations__ reports {'a': int, 'b': int, 'return': int} — and the interpreter does not consult them. They are metadata. mypy will complain; CPython will hand you a string.

That distinction sounds academic until you remember where bugs actually come from: the untyped third-party library, the dict that came off a queue, the json.loads that returns Any, the # type: ignore someone added at 6 p.m. on a Friday because the release was blocked.

And Any is contagious. Do not take my word for it — this passes mypy --strict, a common “we do typing properly” preset, with zero errors:

from typing import Any

def retries(n: int) -> int:
    return n * 2

def run(cfg: dict[str, Any]) -> int:
    return retries(cfg["retries"])
$ mypy --strict config.py
Success: no issues found in 1 source file

Then you run it against a config where that field happens to be a string:

run({"retries": "3"})     # '33'

retries is annotated int. It received a str, returned a str, and mypy’s strict preset pronounced the file clean. Nothing in CI turned red.

To be scrupulous, there are two honest objections to that example, and both are worth making because they sharpen the point rather than blunt it.

First, Any was my choice, not Python’s. If I annotate the parameter dict[str, object] instead, mypy catches it immediately:

error: Argument 1 to "retries" has incompatible type "object"; expected "int"  [arg-type]

That is the correct advice, and mypy’s own documentation gives it: prefer object when you do not know, because Any switches checking off locally while object forces you to narrow. If you take one practical thing from this post, take that.

Second, stricter flags exist. --disallow-any-expr rejects the original — though not, as I first thought, every expression that touches deserialised data; a cast or an assignment to an explicitly typed variable satisfies it.

So why does the example still matter? Because of what the standard library hands you:

$ mypy --strict revealed.py
note: Revealed type is "Any"          # json.loads("{}")

json.loads is annotated to return Any, and many untyped third-party APIs also appear as Any to a checker. Any is not an exotic thing a careless developer has to opt into — it is common after deserialisation or at an untyped boundary. You have to annotate, narrow, or validate that value deliberately to get useful static checking back.

And that conversion is not free. Doing it properly means narrowing at every single access:

def run(cfg: dict[str, object]) -> int:
    v = cfg["retries"]
    if not isinstance(v, int):
        raise TypeError(f"retries must be int, got {type(v).__name__}")
    return retries(v)

That passes --strict cleanly, and it is the right way to write it. Notice what it actually is, though: a hand-rolled runtime type check. Which is precisely the job Pydantic exists to automate — and precisely the admission underneath this whole section. The way to get type safety at a Python boundary is to check types at runtime, because the annotations will not do it for you.

If you want a sense of the direction of travel, look at what 3.14 did to annotations. PEP 649 made them lazily evaluated: they are no longer computed when the function is defined, only if something actually asks for them. You can watch it happen:

# Python 3.13
def f(x: ThisNameDoesNotExist) -> None: ...
# NameError at definition time

# Python 3.14
def f(x: ThisNameDoesNotExist) -> None: ...
# fine — nothing is evaluated until you touch f.__annotations__

That is a sensible change, and it makes forward references and import cycles much less painful. Note that 3.14 deliberately preserves runtime introspection — there is a whole new annotationlib module for reading annotations on purpose, and frameworks like Pydantic and FastAPI are built around that pattern. Annotations are available to runtime consumers; CPython itself does not enforce them.

What they are not is automatically enforced. The flagship typing change in the newest Python made the interpreter work harder to avoid evaluating your hints unless something asks. They became lazier, not binding. If you want them to mean something at runtime, a library has to reach in and make it so — which is the difference this whole post turns on.

Here is the crux:

A type system you can ignore is a style guide.

The difference between a linter and a compiler is not sophistication. It is who holds the veto. mypy files a complaint. A compiler refuses to produce a binary. One of those is a guarantee; the other is a strongly worded suggestion, and in a large team under deadline pressure, strongly worded suggestions lose.

Look at what strict typing does to the source, too:

async def process(
    items: Sequence[Mapping[str, Any]],
    *,
    handler: Callable[[Mapping[str, Any]], Awaitable[Result[Order, ValidationError]]],
    retries: int = 3,
) -> AsyncIterator[tuple[int, Result[Order, ValidationError]]]:
    ...

Whatever that is, it is not the language you chose because it read like pseudocode. You have paid much of the syntactic and maintenance cost of a static language without moving those type guarantees into the runtime or making the checker mandatory.

Pydantic is a validator, not a type system

Pydantic is genuinely excellent at its actual job: checking untrusted data at the edge of your program. But it is doing that at runtime, at a cost, and only where you put it. It is a border checkpoint, not a passport system.

It also coerces, which is not the same as validating:

class Order(BaseModel):
    id: int
    total: Decimal

o = Order(id="7", total="19.99")
# o.id    -> 7            (int, silently converted from str)
# o.total -> Decimal('19.99')

o.model_dump()
# {'id': 7, 'total': Decimal('19.99')}   <- a plain dict again

A string became an integer and nobody was told. In fairness, that is the default behaviour and Pydantic gives you a way out: strict mode, available per-model, per-field, or per-call, rejects "7" for an int instead of converting it. If you use Pydantic, turn it on. Its documented guarantee is about the types of the resulting model’s fields, not about preserving what you passed in — so lax mode is doing what it says, just not what most people assume it says.

The deeper point survives strict mode, though, and it is the last line of that snippet. Inside the model, the data is validated and the types are real. One .model_dump() later it is an ordinary dict — the values are still the validated ones, nothing is undone — but the guarantee does not travel with them. Every downstream function that receives that dict is back to trusting annotations the runtime does not check, and Pydantic is no longer in the room.

asyncio is not multithreading

This is the most consequential misunderstanding of the set, because people reach for asyncio to make things fast.

So let us measure it. The workload is deliberately boring: eight independent CPU-bound tasks, each a five-million-iteration integer loop. Forty million iterations total, no I/O, no library calls.

N, K, M = 5_000_000, 8, 1_000_000_007

def crunch(n):
    s = 0
    for i in range(n): s = (s + i * i) % M
    return s

Run that eight times sequentially, then through threading, then through asyncio.gather, then through multiprocessing, on a machine with eight cores and sixteen hardware threads:

python 3.14.7
  sequential        2.569s  1.00x
  threading(8)      2.486s  1.03x
  asyncio.gather    2.566s  1.00x
  multiprocessing   0.510s  5.04x

That is Python 3.14, the newest release, on sixteen available hardware threads. Eight threads bought 1.03x. asyncio.gather over eight coroutines bought 1.00x — that is not a typo, and not a rounding artefact. It is the same 2.5 seconds whether you write the loop plainly or wrap it in coroutines and a gather call. The GIL means one bytecode-executing thread at a time, and asyncio does not even try: with no await inside crunch, the first coroutine runs to completion, blocking the entire event loop — and with it every other task, every timer, every socket read — before the second one starts.

Only multiprocessing delivers here, and it does so by giving up on threads entirely: eight separate processes and interpreters. In this ProcessPoolExecutor design, calls and results are serialized across process boundaries. Process startup, scheduling, IPC, and hardware effects all help explain why the result is about 5x rather than the ideal 8x; large or unpicklable inputs can make matters worse. shared_memory, Value, and Array can avoid some copying, but require an explicit shared-data layout. Managers provide proxies, but still involve IPC and serialization.

asyncio is cooperative concurrency for I/O waiting. It lets one thread hold ten thousand idle sockets. That is a real and valuable thing, and for an I/O-bound web service it is the right tool.

To be precise, because this gets stated too loosely: asyncio can absolutely coordinate CPU-bound work — asyncio.to_thread, or loop.run_in_executor with a thread, process, or interpreter pool. What it will not do is make CPU-bound work parallel by itself, which is what the benchmark above measures and what most people assume gather does. The parallelism, when you get it, comes from the executor you reached for; await only decides when your coroutine yields.

So I installed the free-threaded build and measured it. CPython is finally addressing this. The free-threaded build from PEP 703 was experimental in 3.13, and PEP 779 promoted it to officially supported in 3.14. The single-threaded penalty is way down: CPython’s own documentation now puts the free-threaded build’s overhead on the pyperformance suite at about 1% on macOS aarch64 to 8% on x86-64 Linux, against roughly 40% in 3.13. That is real progress and I am glad it exists.

But look again at the version in that output. Those numbers are from 3.14 — the release where free-threading is officially supported — and threading still bought 1.03x. Here is why:

>>> import sysconfig, sys
>>> sysconfig.get_config_var("Py_GIL_DISABLED")
0
>>> sys._is_gil_enabled()
True

Officially supported is not the same as on. Free-threading is a separate build of the interpreter. Default CPython distributions still commonly ship with the GIL; the ordinary Anaconda 3.14 build above was built in August 2026 and does.

So I installed the real thing — the same 3.14.7, built free-threaded — and ran the identical script:

python 3.14.7t   (Py_GIL_DISABLED=1, sys.abiflags='t')
  sequential        2.319s  1.00x
  threading(8)      0.384s  6.04x   <-
  asyncio.gather    2.252s  1.03x
  multiprocessing   0.509s  4.56x

Threading went from 1.03x to 6.04x. I want to be completely straight about this, because it is the strongest counter-argument to everything above: free-threading is not vapourware. It works. On this workload it delivers roughly six-fold parallelism across eight cores, with shared memory and no pickling — exactly what people have wanted from Python threads for twenty years.

And you can isolate the cause exactly, because the same binary lets you put the GIL back:

$ PYTHON_GIL=1 python bench.py
  sequential        2.475s  1.00x
  threading(8)      2.333s  1.06x

Same interpreter, same code, one environment variable. 6.04x becomes 1.06x. The GIL state was the controlled variable.

So: credit where it is due. But three things did not change, and they are why the rest of this post still stands.

First, asyncio still bought 1.03x — with no GIL at all. That is the cleanest possible demonstration that async was never a parallelism story. Take away the thing everyone blames, and asyncio.gather over eight CPU-bound coroutines is still exactly as fast as a plain sequential loop, because it still runs on one thread by design. If you reached for async to use your cores, you were wrong about what it does, and free-threading does not rescue you.

Second, it is still slow. Free-threaded Python at full parallel tilt takes 0.384s. C# takes 0.019s. That is still 20x. Single-threaded, it is still 21x. Free-threading closes the scaling gap; it does not touch the speed gap.

Third, switching is a migration, not a flag. The free-threaded interpreter has a different ABI, visible in the t suffix above. Extension modules built for the conventional CPython 3.14 ABI are not interchangeable with cp314t artifacts, so every compiled dependency needs a compatible build for your platform. The ecosystem increasingly publishes those builds — NumPy is one example — but the dependency that does not is the one that stops you.

There is a sting in the tail: CPython’s own documentation is explicit that importing an extension which does not declare free-threading support raises a warning and enables the GIL at runtime. A half-migrated environment can hand you back the very thing you were trying to escape, at import time, behind a warning that is easy to miss in a busy startup log.

And none of it does anything whatsoever about dynamic typing — the other half of this post, and the half no interpreter build can fix.


The bill comes due

Here is the part that actually hurts. Each band-aid is defensible on its own. Together, they destroy the exact property you chose Python for.

You lose readability. Signatures grow to three lines. Half of every module is imports from typing. The business logic — the reason the file exists — is now a minority shareholder in its own source file.

You lose simplicity. A new hire cannot run the project until they have understood your pyproject.toml, your virtualenv strategy, which of mypy/pyright/ruff/black you use and in what order, why some modules are async and some are not, and where the # type: ignore landmines are.

You get two languages in one repo. The strictly-typed, Pydantic-everything modules written by the engineer who wanted a real language, and the loose duck-typed modules written the way Python is meant to be written. Every boundary between them is a defect waiting to happen.

And you keep every weakness. After all of it, Python is still dynamically typed at runtime. CPython still executes most user code through its bytecode interpreter. It can still ship type errors that a compiler would have caught before producing an executable.

That extra machinery can be worthwhile, but it does not change CPython’s execution model. You take on part of the cost of a static language while deliberately retaining the trade-offs of a dynamic one.

Add it all up and the trade is worse than it looks. Annotating every signature, configuring and sequencing the checkers, writing a validation model for every boundary, learning which work belongs on an executor, policing the seam between the typed and untyped halves of the repo — each piece is reasonable, and together they are a serious engineering effort spent simulating what a statically typed, compiled language does by default. The effort it takes to make Python pretend to be a statically typed, compiled language is greater than the effort of using one in the first place — and at the end of it you still have weaker guarantees, because the compiler you were imitating would have enforced them, and your checker only advises.


What a type checker actually catches — and where it stops

Abstract arguments about type safety go nowhere, so here are four bugs real codebases ship. And I am going to start by doing the thing the enthusiastic version of this argument often skips: running mypy on all of them.

add("1", "2")                       # wrong argument type
o.totl                              # typo'd field on a dataclass
name.upper()                        # name is str | None
def describe(s: str) -> str: ...    # match with no fallback case

mypy, default settings, no flags:

error: Argument 1 to "add" has incompatible type "str"; expected "int"  [arg-type]
error: Argument 2 to "add" has incompatible type "str"; expected "int"  [arg-type]
error: "Order" has no attribute "totl"; maybe "total"?              [attr-defined]
error: Item "None" of "str | None" has no attribute "upper"          [union-attr]
error: Missing return statement                                          [return]

It catches all four faulty sites. The first call produces two diagnostics, one per argument. If you run mypy in CI and require it to pass, these bugs do not reach production, and any article that tells you otherwise is selling you something.

Python’s tooling is better at exhaustiveness than it usually gets credit for, too. Close the domain and ask for the check explicitly and you get it:

Status = Literal["pending", "shipped", "cancelled"]

def describe(s: Status) -> str:
    match s:
        case "pending": return "waiting"
        case "shipped": return "on its way"
        case _: assert_never(s)
error: Argument 1 to "assert_never" has incompatible type
       "Literal['cancelled']"; expected "Never"  [arg-type]

So let me state the argument precisely, because the sloppy version of it — “Python can’t catch this” — is simply false.

The problem is not detection. It is that detection is advisory, and detached from the thing that actually runs. Three consequences follow, and they are the whole of my case.

One: anybody can switch it off, one line at a time

add("1", "2")  # type: ignore
$ mypy silenced.py
Success: no issues found in 1 source file

That is mypy issuing a clean bill of health on a call it flagged seconds earlier. The comment costs fourteen characters, needs no justification, and survives code review because the diff looks like someone fixing a type error rather than hiding one.

C#’s #pragma warning disable can suppress warnings, not compiler errors. Try it on an error:

#pragma warning disable CS1503
int Add(int a, int b) => a + b;
Console.WriteLine(Add("1", "2"));
error CS1503: Argument 1: cannot convert from 'string' to 'int'
error CS1503: Argument 2: cannot convert from 'string' to 'int'
The build failed. Fix the build errors and run again.

The warning pragma cannot make that exact program build. C# has escape hatches — dynamic, casts, and reflection — but using one requires changing the code and giving up static checking at that point. That is what a veto over ordinary statically typed code means, and it is the actual difference between the two systems — not which bugs each one can see.

Two: it can fail open at untyped boundaries

The mypy --strict example from earlier is the whole problem in six lines: a fully annotated function, a clean strict run, and '33' where an integer was promised. Static checking degrades to nothing at Any, and Any is common when deserialised or untyped data reaches otherwise typed code.

Your type checker is strongest in the code you wrote and weakest at the boundary — which is the exact inverse of where the errors are.

Three: the checker does not enforce types at runtime

The checker is a separate program you have to remember to run. Production runs CPython, which enforces argument counts and keyword rules perfectly well, and enforces annotated types not at all. So when a wrong type does get through — from an untyped dependency, a JSON payload, a queue message, a # type: ignore added at 6 p.m. on a Friday — you find out the way you always did:

AttributeError: 'NoneType' object has no attribute 'upper'

at the moment that line executes, which may be in a rarely-hit error handler, at 3 a.m., in production, with a traceback that points at the victim rather than the culprit.

For the ordinary, statically typed C# equivalents above, the messages arrive before the program can be built. That is not because the C# compiler is cleverer than mypy — on the typo case, mypy’s message is better written, and it suggested the correct field name. It is because one of them is the thing that builds your program and the other is a program you can decline to run.

The speed argument, measured

The same forty-million-iteration workload, same machine, same algorithm, verified to produce an identical checksum in both languages:

  • Single-threaded: Python 3.14.7 with the GIL, 2.569s; free-threaded Python 3.14.7t, 2.319s; C#/.NET 10, 0.109s.
  • Eight worker threads/tasks: Python with the GIL, 2.486s (1.03x); free-threaded Python, 0.384s (6.04x); C# Task.Run, 0.019s (5.74x).
  • asyncio.gather: Python with the GIL, 2.566s (1.00x); free-threaded Python, 2.252s (1.03x).
  • Eight Python processes: Python with the GIL, 0.510s (5.04x); free-threaded Python, 0.509s (4.56x).

Two separate results in those numbers compound.

Single-threaded, C# is roughly 24x faster than the GIL build and 21x faster than the free-threaded one. Same loop, same arithmetic, same answer — and that is against current CPython 3.14.7 builds.

Threading works in both — once Python is free-threaded. C# gets 5.74x from eight Task.Run work items scheduled on the .NET thread pool. Free-threaded Python gets a comparable 6.04x. The scaling story is now genuinely similar; what remains is the constant factor, and it is large: 0.384s against 0.019s, or 20x, for the same eight parallel tasks.

Put end to end, stock CPython to JIT-compiled .NET code: 2.569s to 0.019s — a factor of 135. That is not a tuning difference, it is a different category of machine.

The caveats, because they matter. This is a tight scalar integer loop — the single most favourable possible case for compiled code, and the least favourable for CPython. Swap in NumPy and the array version of this workload drops into C and the gap narrows dramatically, because you are no longer running Python in the hot loop; you are running C with Python holding the coat. That is precisely the point I am making, though: Python is fast when it is delegating to a compiled language. The question is what language you should be writing your own logic in.


What the speed costs you in the cloud

On your own machine, a slow program costs you patience. In the cloud it costs you money, because compute is billed by the time it runs: per second on a virtual machine, per millisecond of memory-time on a serverless function, and per instance on anything that autoscales on CPU load. A job that takes ten minutes in Python and one minute in a compiled language does not just finish sooner. You stop paying nine minutes earlier.

That is the simple version, and it is roughly right for CPU-bound work. A compiled language adds a few advantages on top of raw speed:

  • You can use the cores you are paying for. A cloud instance bills you for every vCPU it has. A .NET process spreads its work across all of them on ordinary threads. A standard CPython process runs Python bytecode on one core at a time, so to use a multi-core instance you run several worker processes instead — and each one carries its own interpreter and its own copy of much of the application’s memory, which you also pay for.
  • Fewer instances for the same load. If each request costs less CPU, an autoscaled fleet needs fewer machines to serve the same traffic, and smaller ones.
  • Smaller, faster-starting deployments. .NET’s Native AOT publishes a single self-contained executable, with no interpreter or package tree to ship in the container image, and it starts fast enough to make scale-to-zero practical.

The published numbers point the same way, though none of them is a controlled experiment. The most-cited measurement, Pereira et al., puts Python at about 23 times C#’s execution time across their benchmark suite — close to the 24x above. A 2024 re-examination by van Kempen et al. concludes that language choice has no significant effect on energy beyond execution time, which is exactly the point: what you pay for is how long the code runs. Companies that moved services off Python report the same direction in their bills. Cultured Code rewrote the Things Cloud backend from Python 2 to Swift and reported a more than threefold reduction in compute costs. Verihubs reported a 78% cut in infrastructure costs after moving to Go and Rust. Lovable went from 200 server instances to 10 after moving to Go.

Read those with care. Every one of them was a rewrite as well as a language change — new infrastructure, a redesigned pipeline, accumulated technical debt paid off — and none of them separates how much of the saving came from the language. And a CPU saving does not reach the bill in full. When Microsoft Teams moved services to .NET 6, CPU use fell 40% and compute cost fell 24%: same company, same code, a faster runtime, and only part of the saving arrived as money. “Nine minutes faster means nine minutes cheaper” is the best case, not the expected one.

There are also cases where the advantage disappears entirely:

  • Waiting, not computing. A request that spends 80 ms waiting on a database and 5 ms in your code saves 4.5 ms if the code runs ten times faster. The bill barely moves. This is the same I/O-bound case where asyncio is the right tool.
  • Python that is really C or a GPU. In NumPy, pandas or PyTorch, the hot loop is already compiled code, and in machine learning the GPU time dominates and costs the same whichever language launched it.
  • Capacity you pay for anyway. An always-on or reserved instance costs the same whether it is busy or idle. Speed only saves money if it lets you run fewer or smaller machines.
  • Short, rare serverless calls. Serverless bills memory as well as time, and .NET’s baseline memory and cold start are heavier than Python’s unless you publish with Native AOT. A function that runs briefly and rarely can cost more in C#.
  • People cost more than servers. For most teams, salaries dwarf the compute bill. A saving of a few hundred dollars a month does not pay for a team that moves more slowly. That cuts both ways, though: the band-aid stack this post describes is also paid for in engineering time.

So the honest claim is narrower than “compiled is always cheaper”, and still worth making: for CPU-bound work billed by the second, a compiled language can cut that part of the bill several-fold — and at scale, that saving can outweigh the cost of the change. Measure your own service before you count on it.


If you want a real programming language, use one

C# is the one I would point most teams at, and the objection I always hear — “it’s heavyweight, I just want to write a file and run it” — is out of date.

Write a file and run it. As of .NET 10, C# has first-class file-based apps. No project file, no scaffolding, no dotnet new:

#!/usr/bin/env -S dotnet --

var orders = File.ReadLines("orders.csv").Skip(1).Select(Order.Parse).ToList();
Console.WriteLine($"{orders.Count} orders, total {orders.Sum(o => o.Total):F2}");

record Order(int Id, decimal Total)
{
    public static Order Parse(string line)
    {
        var p = line.Split(',');
        return new Order(int.Parse(p[0]), decimal.Parse(p[1]));
    }
}
$ chmod +x report.cs && ./report.cs

That is a script. It is also statically typed, compiled, and it refuses to build the moment you hand p[0] — a string — to something expecting an int. Need a NuGet package? One line in the file: #:package System.CommandLine@2.0.0. Need an MSBuild property? #:property. When it outgrows a single file, dotnet project convert report.cs creates a conventional project directory containing copies of the source and generated .csproj, leaving the original file untouched.

And no virtual environment. A Python project needs a venv because pip installs into one site-packages folder per interpreter, and two projects that want different versions of the same package will overwrite each other’s. .NET was designed without that problem. Packages download once into a versioned cache under ~/.nuget/packages, and each project — or each file-based script, through its #:package lines — references exactly the versions it declared. Two projects on the same machine can depend on different versions of the same library at the same time, and restoring one cannot break the other. There is nothing to activate, nothing to forget to activate, and no requirements.txt drifting out of step with what is actually installed.

The parts of a venv that are genuinely useful have direct equivalents, and they live in files you commit. A global.json pins the SDK version for a directory tree — dotnet new globaljson --sdk-version 10.0.100 — the way you would pin a Python version. A local tool manifest keeps command-line tools per repository rather than per machine: dotnet new tool-manifest, then dotnet tool install csharpier, and the version is tracked in .config/dotnet-tools.json alongside the code.

In fairness to Python, uv and the inline script metadata of PEP 723 have made this far less painful: a script can now declare its own dependencies in a comment block, and uv run resolves them into an isolated environment on the fly. But notice what that is — a virtual environment, built for you behind the scenes. In Python, isolation is a tool layered over the interpreter. In .NET it is simply how package resolution works.

Interactive work used to be the weak spot. If you came here expecting me to recommend Polyglot Notebooks, that ship has sailed. Microsoft announced the deprecation on 11 February 2026; the extension was deprecated on 27 March and .NET Interactive, the engine underneath it, on 24 April, when the repository was archived. Anyone still linking to it has not checked recently.

Microsoft’s own suggested replacement for C# users was file-based apps — the feature at the top of this section. Their reasoning is that what most people actually wanted from a notebook was a lightweight way to try some code and a NuGet package without ceremony, and a single .cs file you can run now does that. That is mostly right, and it matches how I work: edit the file, dotnet run it, iterate. But “mostly right” is not “the same thing” — you get no cell-by-cell state and no inline plots.

What makes interactive C# possible at all is Roslyn. Microsoft rebuilt the C# compiler as an open-source library rather than a black box that turns files into binaries: parsing, semantic analysis and emitting IL are each a public API, and the scripting layer on top, Microsoft.CodeAnalysis.CSharp.Scripting, evaluates a snippet at a time and carries its variables into the next. That is the engine under C# Interactive, dotnet-script and csharprepl, and under the C# kernels of the notebook tools below.

And a REPL built on a compiler is still a compiler. Every line you enter is compiled before it runs, with everything you defined earlier in the session in scope and fully typed. Define Add(int a, int b) at a C# prompt, call it with two strings, and you get CS1503 on the spot — the same error the build gave above, not a string called "12". Python’s REPL is exactly as forgiving as Python. C#’s is exactly as strict as C#, which is what you want in the place where you are trying things out.

The gap did not stay open for long. Verso picked up both halves of what Microsoft dropped: a notebook platform and an embeddable execution engine. It is not a fork but a successor — MIT licensed, built by Datafication, and actually shipping: verso.cli is on NuGet at 1.2.2 as of September 2026. It runs as a VS Code extension, as a browser-based editor via verso serve, or headless from the command line, and it imports your existing .ipynb and .dib files, mapping Polyglot magics such as #!fsharp and #!sql onto native cells. If you have a drawer of Polyglot notebooks, they are not stranded.

Two details make it more than a like-for-like replacement. It ships eight language kernels — C#, F#, Python, JavaScript, TypeScript, PowerShell, SQL and HTTP — and every kernel in a session reads and writes one shared variable store, so a value produced in Python is directly addressable from C# without an explicit hand-off. That is a stronger cross-language story than Jupyter’s, where moving between languages generally means moving between processes.

Temper it with the obvious caveat, because I am not going to oversell a young project: this is a small team’s work rather than a Microsoft product with a published LTS horizon, and its download counts are in the low thousands, not the millions. If notebook-shaped work is central to your day, Jupyter is still the safer institutional bet and Python is still its native language. For a terminal REPL the engine is Microsoft’s but most of the front ends are not: C# Interactive (csi) ships with Visual Studio, which means Windows, and cross-platform you are choosing between community tools such as dotnet-script, dotnet-repl and csharprepl — all of them running on the same Roslyn scripting engine. But “C# has no notebook story” stopped being true this year, and the honest position is that the gap is now covered by a project worth evaluating rather than a hole worth conceding.

You can still use Python’s libraries. This is usually the real objection, and it is the best one: NumPy, pandas, PyTorch, scikit-learn, and a hundred thousand packages, many of which have no equivalent anywhere else. CSnakes embeds CPython directly in a .NET process through the C API — not a subprocess, not a REST hop — so packages with compiled extensions work as they do in Python.

And the detail I find genuinely funny: CSnakes reads supported Python function annotations and generates typed C# wrappers from them. Those supported annotations become compile-checked signatures when they cross into C#.

(CSnakes documents support for .NET 8–10 and Python 3.9–3.14, including free-threaded builds from 3.13 on. Check current compatibility before you build on it.)

That division of labour is the right one, and it is the same one Python itself uses when it drops into C: the numerical core stays in the ecosystem that has the libraries, and your application layer, your concurrency, and your business rules live somewhere that has a compiler.

And you do not always need the bridge. CSnakes is the answer when you need the Python library itself, but for everyday array and table work .NET has native equivalents, with no CPython in the process at all. NumSharp mirrors NumPy’s API for n-dimensional arrays closely enough that much NumPy code ports line for line, and it is actively maintained — 0.70 shipped in September 2026. For tables, Microsoft’s own DataFrame, the Microsoft.Data.Analysis package that ships with ML.NET, covers the everyday pandas ground: loading CSVs, filtering, grouping, joining, and handing the result straight to a model. SciSharp’s stack examples show the wider family in use, from NumSharp to TensorFlow.NET.

Be clear-eyed about maturity, though. None of these has the depth, documentation or community of the originals. The DataFrame is still on a pre-1.0 version number. Pandas.NET, the closest thing to a direct pandas port, is an early project that has not had a commit since August 2024 — for tables, reach for Microsoft’s DataFrame instead. For mainstream numerical work, native .NET is a genuine option. For anything at the frontier of the Python ecosystem, CSnakes is how you keep it.

And a standard .NET installation gives you things Python offers only on a different interpreter — or not at all. Parallel execution of your own code on ordinary threads, with no alternate ABI and no waiting on free-threaded wheels. (To be exact about what free-threading changed: CPython’s threads have always been real OS threads sharing one address space, and C extensions have always been able to release the GIL. What was missing, until the free-threaded build, was two of those threads running Python bytecode at the same time.) Records, Span<T>, LINQ, and optional Native AOT publishing to a self-contained executable. C# also has nullable-reference analysis and diagnostics for many non-exhaustive switch expressions; those are compiler warnings, not runtime enforcement, and teams that want a hard gate should treat warnings as errors. .NET 10 is an active LTS release supported through November 14, 2028 — the kind of published support horizon procurement departments actually ask about.

And note the direction of the default. C# has dynamic when you genuinely want late binding — dynamism is the opt-in escape hatch from a static language. Python has type hints — static checking is the opt-in escape hatch from a dynamic language, and it is one the runtime never enforces. Defaults decide what happens in the code nobody had time to polish, which is most of the code.


“But the syntax is harder”

It used to be a fair point. Python’s low barrier to entry was a genuine strategic advantage: you could hand it to an analyst, a scientist, a junior, and get useful work back that afternoon.

It is also worth noticing how much of that gap has already closed from the other direction. This:

top = sorted((o for o in orders if o.total > 100), key=lambda o: -o.total)[:10]

and this:

var top = orders.Where(o => o.Total > 100).OrderByDescending(o => o.Total).Take(10);

are the same thought. One of them is checked before it runs.

And AI-assisted coding has flattened most of what remains. Boilerplate, ceremony, unfamiliar generics, the shape of an interface you half remember — that is precisely the category of work that assistants are best at, and it is precisely the category that made statically typed languages feel expensive. Meanwhile the thing AI is worst at — reasoning reliably about code with no enforced contracts — is exactly what you get more of in a large dynamically typed codebase.

Static types are, if anything, worth more in an AI-assisted workflow than they were before. The compiler is the cheapest reviewer you have, it never gets tired, and it reviews every line of generated code for free.


Answering the strongest objections

“The ecosystem is the whole argument.” For numerical and ML work, yes. Nothing else comes close, and pretending otherwise is silly. That is an argument for calling into Python, not for writing your entire production system in it — and for everyday array and table work, not always even that. See CSnakes and the native .NET libraries above.

“My team already knows Python.” Real cost, honestly. But weigh it against the cost you are already paying: the type-annotation tax, the runtime validation tax, the incident tax from errors a compiler catches. Teams that know Python well enough to write strictly-typed, async-heavy, Pydantic-modeled Python have already done most of the conceptual work of learning a static language. They just did it in a language that cannot reward them for it.

“Pydantic and FastAPI are a great stack.” They are. For an I/O-bound service, at moderate scale, with a small team, it is a completely reasonable choice, and I would not rewrite a working one. Notice that the benchmark above says nothing bad about that case: if your threads are waiting on sockets rather than executing bytecode, the GIL is not your bottleneck and asyncio is genuinely the right tool. The critique is not “never use Python.” It is “notice when you are simulating a different language, and consider using that language instead.”

“Free-threading fixes the GIL.” It genuinely does, and I checked rather than assumed: on the free-threaded 3.14.7 build, the same benchmark scales 6.04x on eight cores instead of 1.03x. If your problem is CPU-bound parallelism in Python, that is the real answer and you should be watching it closely. Three things survive it, though. asyncio.gather still gains nothing on this benchmark without a GIL, so the async-equals-fast confusion is untouched. Free-threaded Python is still around 20x slower than C# on this workload, because removing the GIL does not make an interpreter a compiler. And switching is an ABI migration across every compiled dependency you have, not a configuration change. Also, of course, it does nothing about dynamic typing.


The rule of thumb

My own default, after watching this cycle repeat:

  • Rust instead of C. Systems-level work where memory safety is worth a strict compiler.
  • C# instead of C++ (unless you need Unreal Engine level performance) and instead of Python-pretending-to-be-C#. Applications, services, and anything that has to be maintained: compiled speed, real type safety, genuine threading, enterprise robustness.
  • Python, Lua, and shell where scripting is actually the job. Glue, automation, notebooks, one-off analysis, experiment code, the edges of an ML pipeline. Keep it small, keep it simple, and keep it untyped — that is the strength you came for.

None of this is about C# being better than Python. Python is superb at the thing it is for, and I would tell anyone to learn both: they are good at different things, and knowing both is what lets you notice the moment a requirement moves from one to the other — which is the moment this whole post is about. It is about using the right tool for the job. The rule is this:

If you are using Pydantic to fake a type system or asyncio to fake parallelism, you have outgrown Python.

Using them for what they are for is Python working as designed: Pydantic at the edge of the program, checking data you did not produce; asyncio holding ten thousand idle sockets on one thread. The trouble starts when Pydantic models become the type system for code that never touches a boundary, or asyncio.gather gets wrapped around CPU-bound work in the hope of using your cores — the benchmark above shows exactly what that buys.

The moment you find yourself adding a fourth tool to make Python feel like a static, compiled, multithreaded language, stop. You have not outgrown your tooling. You have outgrown your language.

Reach for the real thing instead of another tube of lipstick.


How the numbers were produced

AMD Ryzen 7 5825U, 8 physical cores / 16 hardware threads, Linux 6.12. CPython 3.14.7 (stock Anaconda build, Py_GIL_DISABLED=0, GIL enabled); conda-forge python-freethreading 3.14.7 (Py_GIL_DISABLED=1, sys.abiflags='t'), the same CPython version, running the identical unmodified script; .NET SDK 10.0.302 running .NET 10.0.10, Release configuration, with a warm-up call before timing. Pydantic 2.13.5, mypy 1.17.1.

The workload. Eight tasks; task k computes (s + i*i) % 1_000_000_007 iteratively over range(5_000_000 + k). Roughly 40 million loop iterations in total. Both implementations return the same checksum, 562483196, which is how I know they are doing the same work. That figure is the sum across all eight tasks — a single crunch(5_000_000) on its own returns 922875.

Why the modulus is there. It keeps the accumulator inside a machine integer: without it the sum overflows Int64 at this size, so default unchecked C# arithmetic wraps while Python continues with arbitrary-precision integers. The two programs would no longer be doing equivalent arithmetic. The modulo reduction is also expensive in compiled code: in a separate modulus-free C# trial, the sequential loop took 0.0125s rather than 0.113s. JIT disassembly confirms that the published Crunch loop is scalar, so attributing that difference to vectorisation was wrong; it primarily reflects removing a remainder calculation from every iteration.

(An earlier draft also claimed the eight tasks use different ranges to stop the JIT eliding repeated identical calls. That was wrong, and testing killed it: identical accumulated calls take the same 0.112s. The suspiciously fast result came from removing the modulus, not from call elision.)

How the runs were selected. Each implementation’s displayed figures come from one complete run, so every multiplier divides timings from that same run rather than mixing separately selected best times. Across five runs the spread was: CPython 3.14.7 sequential 2.569–2.704s, threads 2.486–2.571s; free-threaded sequential 2.314–2.615s, threads 0.384–0.401s; C# sequential 0.109–0.115s, Task.Run 0.019–0.021s. The conclusions do not depend on which run you pick. For reference, CPython 3.13.9 on the identical script gives 2.846s sequential and 1.13x on eight threads.

A caveat on the two Python columns. They are different vendors’ builds (Anaconda and conda-forge), so the single-threaded difference between them is not a controlled measurement and I draw no conclusion from it. CPython’s documentation puts the free-threaded build’s own overhead at about 1% to 8% depending on platform. The controlled comparison is the PYTHON_GIL=1 toggle on the free-threaded binary, where only the GIL changes: 1.06x with it, 6.04x without.

Everything else. Every compiler message, mypy result, traceback, and benchmark result quoted in this post was produced by running it, not from memory. The typing examples behave identically on 3.13.9 and 3.14.7; the one behavioural difference between those versions is deferred annotation evaluation, which is shown explicitly.

One benchmark on one machine is not a benchmark suite, and a scalar integer loop flatters compiled code about as much as any workload can. Treat the magnitudes as indicative and the shapes — threads scaling or not scaling — as the real finding. Both benchmark implementations and a README are published with this post so you can check rather than trust.


Primary references