A tour of pyc

Every example on this page compiles with pyc --strict, and its output is byte-for-byte what CPython prints.

The model

pyc is a whole-program compiler. It reads your program and everything it imports, and works out the set of concrete types that can reach every variable, field, parameter and container element. Then it compiles one native version of each function and class for each distinct combination of types it found.

The rules follow from that:

  • Code is not loaded at run time. eval, exec and dynamic import are out of scope by design.
  • A value must have a representation. An int is a 64-bit machine word, a class instance is a struct, a list of floats is an array of doubles. A slot that genuinely holds two unrelated types, such as int in one branch and str in the other, has no representation, and pyc refuses it.
  • Inside those limits the semantics are CPython's. The test suite checks every program against CPython's output.

Inference, not annotation

You write no types. The same class can hold different types in different objects. pyc gives each one its own layout, and calls the right specialized method directly.

box.py
class Box:
    def __init__(self, value):
        self.value = value

    def describe(self):
        return f"Box({self.value!r})"

print(Box(42).describe())
print(Box("hello").describe())
print(Box([1.5, 2.5]).describe())
$ pyc --strict box.py && ./box
Box(42)
Box('hello')
Box([1.5, 2.5])

The analysis behind this is demand-driven. It starts with one version of every function and one of every class, and splits a version only when something needs the split: an operation that can't proceed on a union of types, a dispatch that can't be resolved statically. Here an int, a str and a list have no common representation, so Box gets one layout for each.

Classes and operators

Inheritance, super(), class attributes, dunder methods and read-only @property work as in CPython. An operator on objects compiles to a direct call to the right method.

vec.py
class Vec:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        return Vec(self.x + other.x, self.y + other.y)

    def __mul__(self, k):
        return Vec(self.x * k, self.y * k)

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __repr__(self):
        return f"Vec({self.x}, {self.y})"

v = Vec(1, 2) + Vec(3, 4) * 2
print(v, v == Vec(7, 10))       # Vec(7, 10) True

Generators

yield and yield from compile to native coroutines. An infinite generator is fine, because nothing is materialized until you ask for it.

gen.py
def fib():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

def take(n, gen):
    out = []
    for x in gen:
        if len(out) == n:
            break
        out.append(x)
    return out

def evens(xs):
    for x in xs:
        if x % 2 == 0:
            yield x

def chain(a, b):
    yield from a
    yield from b

print(take(10, fib()))      # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
print(list(chain(evens(range(10)), evens(range(20, 26)))))
                            # [0, 2, 4, 6, 8, 20, 22, 24]

Exceptions

try/except/else/finally, user exception hierarchies, and CPython's built-in exceptions raised by the runtime itself: ValueError from int("x"), KeyError, StopIteration and the rest.

parse.py
class ParseError(Exception):
    pass

def parse(text):
    try:
        return int(text)
    except ValueError:
        raise ParseError("not a number: " + text)

for s in ["12", "x7", "40"]:
    try:
        print(parse(s) * 2)
    except ParseError as e:
        print("error:", e)
    finally:
        print("checked", s)

Pattern matching

match/case supports every PEP 634 pattern kind: literal, capture, wildcard, sequence, mapping, class, OR and guard.

where.py
def describe(point):
    match point:
        case (0, 0):
            return "origin"
        case (0, y):
            return f"on the y axis at {y}"
        case (x, 0):
            return f"on the x axis at {x}"
        case (x, y) if x == y:
            return "on the diagonal"
        case _:
            return "somewhere else"

for p in [(0, 0), (0, 3), (2, 0), (4, 4), (1, 2)]:
    print(p, describe(p))

Collections and strings

list, tuple, dict, set, str, bytes and bytearray each compile to a specialized, element-typed native structure. Comprehensions, generator expressions, slicing, f-strings and %-formatting all work. Dicts keep insertion order, as in CPython.

words.py
words = "the quick brown fox jumps over the lazy dog the end".split()
freq = {}
for w in words:
    freq[w] = freq.get(w, 0) + 1

top = sorted(freq.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
print(top)                      # [('the', 3), ('brown', 1), ('dog', 1)]
print({w: len(w) for w in words if len(w) > 4})
print(", ".join(w.upper() for w in words[:3]))

The standard library

Imports resolve at compile time. The modules in pyc_lib/ are written in Python and compiled with your program, so they cost nothing you don't use. random is the real Mersenne Twister, so a seeded program draws the same numbers CPython does:

dice.py
import random
from collections import defaultdict

random.seed(2026)
counts = defaultdict(int)
for _ in range(1000):
    counts[random.randint(1, 6)] += 1
print(sorted(counts.items()))
print(random.random())          # 0.7894507294053692, as in CPython

See Libraries for what each module covers.

What pyc refuses

When a program needs a value that no static type can represent, pyc says so at compile time, names the expression, and shows the call that led there:

pick.py
import sys

def pick(flag):
    if flag:
        return 1
    return "one"

print(pick(len(sys.argv) > 1))
$ pyc pick.py
pick.py:3:9: error: expression has mixed basic types:( int64 str )
    def pick(flag):
            ^
  called from pick.py:8
...
fail: program does not type

Some unions are only an artifact of how a program is written. For a few of them, such as a numeric slot that holds both int and float, the default --permissive mode applies a named accommodation and warns at the line. --strict turns every accommodation off.

The contract.

Under --strict, a program has CPython's semantics or it does not compile. Any gap in that guarantee is a bug. See the strict dialect.

How it works

pipeline
Python source (.py)
  → DParser (python.g)      parse to an AST; no CPython involved
  → build_syms              symbols, scopes, classes
  → build_if1               lower to IF1, the analysis IR
  → IFA flow analysis       type inference with demand-driven splitting
  → clone                   one concrete function / type per contour
  → optimize                dead code, inlining, constant folding
  → C codegen → cc          or LLVM codegen → binary / JIT

The core is IFA, a simultaneous data- and control-flow analysis by abstract interpretation over a type-value lattice. It analyses contours: versions of functions (EntrySets) and versions of data types (CreationSets). It starts from the minimum number of each and splits one only on demand, never because a neighbouring contour split. Every pass re-derives the types from scratch and keeps only the decisions, so a merge made early can be taken back later.

IFA is a standalone library under ifa/. pyc drives it through a callback interface that keeps everything Python-specific out of the analysis. The repository documents it in depth: start with PIPELINE.md and ifa/INDEX.md.