mypyc: Compile Your Typed Python to C Extensions Without Rewriting a Single Line

You’ve profiled your Python service. The hot path is clear. It’s pure Python doing real work — no numpy, no C library underneath to save you. Your options feel awful: rewrite in Cython (learning a whole new dialect), drag in Rust via PyO3 (context-switching your brain entirely), or just… ship slow code and pretend it’s fine.

There’s a fourth option most people walk right past: mypyc. It compiles type-annotated Python directly to CPython C extensions using the exact same type annotations you (should) already have. No new syntax. No new language. No separate runtime. The mypy team built it and then immediately used it to compile mypy itself — they got a ~4x speedup on the mypy codebase. Dropbox runs it in production.

The catch? You need real, honest type annotations. If your codebase is littered with Any and # type: ignore, mypyc can’t help much. But if your types are solid, this tool is genuinely underrated.

GitHub: https://github.com/python/mypy (mypyc lives inside the mypy repo under mypyc/)


What mypyc Actually Does

Normal CPython executes bytecode through an interpreter loop. Every attribute access, every function call, every arithmetic operation goes through layers of dynamic dispatch — checking types, looking up method resolution order, incrementing reference counts. That’s the price of Python’s flexibility.

mypyc takes your type annotations and uses them to generate C code that skips most of that machinery. When mypyc knows a variable is an int, it uses C integers directly. When it knows a function returns str, it doesn’t need to check. The compiled .so file loads into CPython as a regular extension module — the Python VM still runs, but the hot code runs as compiled C.

This is fundamentally different from:

  • PyPy: a JIT-compiled alternative runtime. Fast, but you can’t easily mix it with CPython C extensions (numpy, pandas, etc.)
  • Cython: requires you to learn .pyx syntax and typed declarations in a different dialect
  • Numba: JIT for numerical code, terrible fit for general-purpose business logic
  • Nuitka: whole-program compiler with a different trade-off profile

mypyc targets the "I have typed Python and I want it faster without a rewrite" use case. It’s not a universal answer — for numerical work, numpy vectorization or Numba will still beat it. But for parsers, serializers, state machines, and general CPU-bound logic? It punches hard.


Installation

mypyc ships with mypy. You probably already have it:

pip install mypy
# That's it. mypyc is bundled.
mypyc --version

You also need a C compiler. On Linux that’s gcc or clang, already present on any dev machine. On macOS, xcode-select --install. On Windows, you need the Visual C++ Build Tools — and honestly, if you’re doing this on Windows in production, I respect the determination.


Your First Compiled Module

Let’s take a concrete example. Here’s a pure Python function that processes a list of records:

# processor.py
from typing import List

def sum_scores(records: List[dict]) -> float:
    total = 0.0
    for record in records:
        score = record.get("score", 0.0)
        if isinstance(score, (int, float)):
            total += score
    return total

This won’t get much from mypyc because dict is opaque and record.get() returns Any. Let’s write it in a way mypyc can work with:

# processor.py
from typing import List

def sum_scores(scores: List[float]) -> float:
    total = 0.0
    for score in scores:
        total += score
    return total

def clamp(value: float, low: float, high: float) -> float:
    if value < low:
        return low
    if value > high:
        return high
    return value

Now compile it:

mypyc processor.py

mypyc generates a .so (Linux/macOS) or .pyd (Windows) file in the current directory. On Python 3.11 it’ll be something like processor.cpython-311-x86_64-linux-gnu.so. Import it exactly like normal Python:

import processor
result = processor.sum_scores([1.5, 2.3, 4.0])

The source .py file can stay right there — Python will prefer the .so if it’s present in the same directory. No import changes anywhere.


The Type Annotation Contract

mypyc’s performance is directly proportional to how specific your types are. This hierarchy matters a lot:

Fastest — native C types used directly:

  • int, float, bool
  • str (optimized in CPython’s internal representation)
  • List[int], Tuple[str, int] with concrete element types
  • Custom classes (mypyc generates efficient vtables for them)

Still fast — known Python objects, no dynamic dispatch:

  • List[Any] — mypyc knows it’s a list, avoids list type-checks, but element access is still dynamic
  • Named classes you’ve defined in the same compiled module

Basically no benefit:

  • Any everywhere
  • Union with many arms
  • dict with untyped values used as the primary data structure

The rule: if mypy type-checks your code cleanly with --strict, mypyc will compile it well.


Compiling Multiple Modules Together

Compiling modules together is critical. When mypyc sees a call between two modules it compiled in the same batch, it generates direct C-level calls instead of going through Python’s import machinery. Cross-module calls inside a compiled group are dramatically faster than even compiled-to-compiled calls between separately compiled files.

# Compile a whole package at once
mypyc mypackage/

# Or list specific files
mypyc mypackage/core.py mypackage/utils.py mypackage/models.py

For a proper package, the standard approach is wrapping the compilation in setup.py or a build backend. Here’s a setup.py that compiles your package on pip install:

# setup.py
from setuptools import setup, find_packages
from mypyc.build import mypycify

setup(
    name="mypackage",
    version="0.1.0",
    packages=find_packages(),
    ext_modules=mypycify(
        [
            "mypackage/core.py",
            "mypackage/utils.py",
            "mypackage/models.py",
        ],
        # opt_level controls C compiler optimization: "3" is -O3
        opt_level="3",
        # debug_level="0" strips debug symbols in production
        debug_level="0",
    ),
    zip_safe=False,
)

Build it:

pip install --no-build-isolation -e .
# or for a release wheel
python setup.py bdist_wheel

For pyproject.toml-based projects, mypyc doesn’t yet have first-class support in the major build backends (setuptools, hatch). The practical answer today is keeping a setup.py alongside your pyproject.toml just for the mypyc compilation step. Not ideal, but it works and it’s what the mypy project itself does.


Gotchas

Monkey-patching breaks entirely. mypyc-compiled classes use C-level vtables. If you patch a method at runtime — obj.method = lambda: ... — it silently does nothing or raises AttributeError. Test frameworks that rely on monkeypatching (some pytest fixtures, mock.patch on compiled methods) will surprise you here. The fix: mock at the boundary, not inside the compiled module.

__init__.py in compiled packages needs care. If your __init__.py does dynamic imports or __all__ manipulation, mypyc will compile it too, and some dynamic patterns it just can’t handle. Keep __init__.py thin — just re-exports, no logic.

Dataclasses work, but with limits. @dataclass is supported, but if you use field(default_factory=...) with mutable defaults or do __post_init__ tricks, check carefully. The safer pattern is plain __init__-based classes in code you’re specifically targeting for compilation.

TypedDict is not a speedup. You can annotate with TypedDict, but at runtime it’s still a dict. mypyc will compile the calling code, but dict access is still dict access. For hot loops, define a proper class or use a NamedTuple instead.

Incremental compilation doesn’t exist. Change one file in a compiled group, rebuild the whole group. For large codebases, this means your CI build time increases. Structure your package so the hot, stable code is in a small compiled subpackage — don’t mypyc your entire codebase unless it’s genuinely all hot.

Python 3.12+ free-threaded mode is unsupported. If you’re experimenting with python3.12t (no-GIL builds), mypyc-compiled modules may not work correctly. This is a known limitation being tracked upstream.


Benchmarking: What to Actually Expect

Here’s a real micro-benchmark setup you can run yourself:

# bench.py
import time
from typing import List

# This is what we'll compile
def fib(n: int) -> int:
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

def process_list(items: List[int]) -> List[int]:
    result: List[int] = []
    for item in items:
        if item % 2 == 0:
            result.append(item * item)
        else:
            result.append(item * 3 + 1)
    return result

if __name__ == "__main__":
    # Fibonacci
    start = time.perf_counter()
    for _ in range(1000):
        fib(28)
    elapsed = time.perf_counter() - start
    print(f"fib(28) x1000: {elapsed:.3f}s")

    # List processing
    data = list(range(100_000))
    start = time.perf_counter()
    process_list(data)
    elapsed = time.perf_counter() - start
    print(f"process_list(100k): {elapsed:.3f}s")

Run once in pure Python, then compile with mypyc bench.py and run again. Typical results on Python 3.11:

Benchmark Pure Python mypyc compiled Speedup
fib(28) ~320ms ~85ms ~3.8x
process_list(100k) ~18ms ~5ms ~3.6x

These numbers are representative for the sweet spot: tight loops with known numeric types. For IO-bound code or code that spends most of its time in C libraries (numpy, SQLAlchemy’s core), you’ll see almost nothing. Profile first, always.


Production-Ready Setup

For a real service, you want mypyc to run at build time — not at developer install time — and only for the modules that are actually hot. Here’s a pattern that works:

# setup.py
import os
from setuptools import setup, find_packages

# Only compile if explicitly requested (e.g., in CI / Docker build)
# Developers installing with `pip install -e .` get pure Python for fast iteration
ext_modules = []
if os.environ.get("MYPYC_BUILD"):
    from mypyc.build import mypycify
    ext_modules = mypycify(
        [
            "myservice/parser.py",
            "myservice/scoring.py",
            "myservice/engine.py",
        ],
        opt_level="3",
        debug_level="0",
    )

setup(
    name="myservice",
    packages=find_packages(),
    ext_modules=ext_modules,
    zip_safe=False,
)

In your Dockerfile:

FROM python:3.11-slim AS builder

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY . .

# Trigger mypyc compilation during wheel build
RUN MYPYC_BUILD=1 pip wheel --no-deps --wheel-dir /wheels .

FROM python:3.11-slim AS runtime
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-index /wheels/*.whl
COPY . .

CMD ["python", "-m", "myservice"]

This keeps the development loop fast (no compilation on pip install -e .) while shipping compiled code in production. The runtime stage doesn’t need gcc.

One more thing: always keep the .py sources alongside the compiled .so in your package. If mypyc compilation silently breaks on a future Python version, Python will fall back to the source file. This graceful degradation has saved production deployments more than once.


When mypyc Is the Wrong Tool

Don’t use mypyc if:

  • Your bottleneck is IO (database, network, disk). Compiling IO code is theater.
  • You need heavy numpy/pandas operations. Those are already C — mypyc can’t make C faster.
  • Your codebase has near-zero type coverage. Adding annotations just for mypyc is almost always worse ROI than Cython for a single hot function.
  • You need to support PyPy. mypyc produces CPython-specific .so files.
  • You’re hitting the GIL and need true parallelism. mypyc doesn’t remove the GIL; use multiprocessing or go async.

For genuinely numerical hot loops with array operations, Numba’s @jit still wins. For a single function that needs maximum performance with complex C interop, Cython or PyO3/Rust is the right move. mypyc’s niche is "typed business logic in pure Python that runs too slowly."


The Bottom Line

mypyc is the most underused performance tool in the Python ecosystem. It requires no new syntax, produces real C extensions, and if your types are solid you’ll walk away with a 2–5x speedup on CPU-bound code with maybe an hour of work. The mypy team uses it on mypy itself. That’s the most honest benchmark there is.

Add types. Run mypy --strict. Fix the errors. Then run mypyc. If the profile improves meaningfully, wire it into your Docker build with an environment flag and forget about it.

The time you’d spend learning Cython’s cdef syntax or fighting PyO3’s borrow checker can be spent actually shipping features. That’s the pitch.

👁 Views: 202,848 · Unique visitors: 57,406