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,execand dynamicimportare out of scope by design. - A value must have a representation. An
intis a 64-bit machine word, a class instance is a struct, alistof floats is an array of doubles. A slot that genuinely holds two unrelated types, such asintin one branch andstrin 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.
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.
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) TrueGenerators
yield and yield from compile to native coroutines. An infinite generator is fine, because nothing is materialized until you ask for it.
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.
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.
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 = "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:
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 CPythonSee 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:
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 typeSome 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.
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
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 / JITThe 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.