The Python subset

pyscriptc compiles the typed subset of Python 3.14.
Code inside the subset becomes a native binary with no interpreter inside.
Code outside it either stops the build with a PSxxxx code that names the exact line, or runs through embedded CPython (fallback mode) when you enable it.
The compiler never guesses.

Check whether your script is inside the subset before compiling:

pyscriptc coverage script.py

coverage needs no C toolchain and reports, per line, what would stop the build.
pyscriptc explain PSxxxx looks up any code it prints and shows the smallest rewrite that clears it.

What runs native and what runs on the island

A compiled program has two execution planes and every definition lands on one of them.
Definitions inside the subset lower to native machine code.
Everything else either stops the build with a PSxxxx code, or, when fallback mode is on, runs inside the embedded CPython island (see Libraries and fallback mode).

The split is decided per module and then propagates: a module whose top level imports something the subset cannot compile has its whole top level planned for the island, and functions in that module that name island-planned imports or unannotated values reaching them are planned there too, transitively through calls (divergence 59).
On a framework-heavy app this compounds fast: on one measured Flask-plus-LLM backend, 41% of the definitions passed the per-definition check in isolation, yet the run-time plane routed 100% of them to the island because untyped framework imports sat at the module top level of every file.
A definition that “compiles in isolation” is not a definition that runs native.

How to tell before you build:

pyscriptc imports app/        # per import: native, fenced, or island
pyscriptc assess app/         # per definition counts, refusals, and the island policy to add
pyscriptc assess app/ --json  # adds the plane map: island route per function and what demoted it
pyscriptc run --fallback=cpython --report=report.json script.py  # the executed plane map after a run

assess prints a “Path to the island” section: the exact [tool.pyscriptc.imports] lines that route each unsupported import to embedded CPython, each with the import site that forced it.
The --json plane map reports, per function, native or island, plus the demotedBy function that pulled each island demotion.
Read the plane map, not the per-definition share: the per-definition share answers “how much of this tree is inside the subset”, the plane map answers “what actually runs native”, and on a framework-heavy app those differ by the whole framework boundary.

When pyscriptc fits and when it does not

pyscriptc fits typed application code: CLIs, data pipelines, file and protocol processing, parsers, simulation and math cores, background jobs.
The pattern that works is a typed core with all annotations, closed data shapes (dataclass, dict[str, int], list[T]), imports from the closed stdlib table, and third-party interaction pushed to explicit boundaries.

pyscriptc does not fit, as native code, applications whose structure is runtime reflection:

  • pydantic / attrs / dataclasses-with-validators: these read annotations at run time to build schemas and validators. A native binary has no typing object and no annotation surface to read. Route the models to the island and keep the typed logic around them.
  • Flask, FastAPI and other decorator-routing frameworks: context proxies (request, g), decorator-registered route tables and **kwargs dispatch are dynamic-object-model patterns the subset refuses, and the route table only exists at run time. The framework part runs on the island or stays on CPython; compile the handlers’ typed cores instead.
  • Agent and plugin frameworks (dynamic registries, getattr dispatch, loosely typed message dicts): the dispatch is the program. There is no static call graph to lower.
  • C-extension-heavy code (PyMuPDF, NumPy, database drivers): there is no Python source to lower; these cross the island boundary per call and pay marshalling costs.

The honest test is pyscriptc assess on your tree and then the plane map.
If the plane map routes your hot code to the island, pyscriptc gives you a single-file binary that embeds CPython, not a speedup.
If it routes your hot code native, the binary has no interpreter inside and the subset’s guarantees hold.

How to write code that compiles

Annotate every variable and function parameter.
The annotations are the input the compiler works from: a type-checker preflight (pyrefly) checks them first, then the compiler uses them to build the binary.

def sum_squares(xs: list[int]) -> int:
    total: int = 0
    for x in xs:
        total = total + x * x
    return total


def find(xs: list[str], t: str) -> str | None:
    for x in xs:
        if x == t:
            return x
    return None


def main() -> None:
    counts: dict[str, int] = {"a": 1, "b": 2}
    best: int | None = None
    for v in counts.values():
        if best is None or v > best:
            best = v
    print(sum_squares([1, 2, 3]), best, find(["x"], "x"))

The rules that matter most:

  • Assign to annotated names.
    counts: dict[str, int] = {} compiles; counts = {} stops with PS1001, and every later read of the name stops with PS2002 because of it.
  • Narrow optionals with is None.
    best: int | None = None followed by if best is None or v > best: compiles, including inside loops.
  • Return T | None instead of a sentinel value, then narrow at the call site.
  • Type function values with Callable: c: Callable[[int], int] = lambda v: v + 1 compiles and calls through c are direct native calls.
  • Write generic helpers in the PEP 484 shape: T = TypeVar("T") at module level, then def first(xs: list[T]) -> T.
    The helper is specialized per call-site type, including over your own classes.
  • Use if TYPE_CHECKING: for imports that only feed annotations (for example collections.abc shapes or third-party types).
    The branch body is skipped by the compiler and the checker still checks it.
  • Prefer from __future__ import annotations for self-referencing classes, so annotations resolve without forward-reference refusals.
  • Read schema-like data into annotated locals: d: dict[str, int] = json.loads(s), then pull typed fields out of d.

typing and typing_extensions are annotation-only modules.
Importing them is free, but a typing name used as a value (cast(...), get_type_hints(...), NewType(...)) stops the build with PS2002.
There is no typing module object in the binary.

Annotations and idioms

Write What happens
list[int], dict[str, int], set[int], tuple[int, str], tuple[int, ...] compiles; the annotation fixes the element layout
T | None, Optional[T] compiles as a tagged optional; is None narrowing drives the branches
A | B (neither arm None), Union[A, B, None] unsupported (PS1101 / PS1102)
Final, Final[int], ClassVar[T] the inner annotation compiles; the checker enforces no reassignment
Annotated[int, "..."] the first argument compiles, the rest is dropped
Callable[[int], int] compiles over functions and lambdas; bare Callable is unsupported (PS1104)
Iterator[int], Iterable[int], Generator[...], Sequence[int] compiles (Sequence[T] reads as list[T]) from typing or collections.abc, imported directly, as collections.abc.X, or under if TYPE_CHECKING:
Mapping[K, V], MutableMapping[K, V], MutableSequence[T], Collection[T], AbstractSet[T], MutableSet[T] compiles as the concrete container (dict[K, V], list[T], set[T]); the argument must be that container, so a set passed to a Collection[T] parameter is refused (PS1003)
collections.abc or typing names used as values (isinstance(v, Sequence), print(Mapping)) unsupported (PS2002, or PS2001 inside isinstance); they are annotation-only
TypeVar("T") generic functions and Generic[T] classes compiles, specialized per call site, including T | None, Callable[[T], U] and a TypeVar key in dict[K, V] or Mapping[K, V]; a bound TypeVar erases to its bound; constraints and variance flags are accepted and not enforced (as in CPython)
PEP 695 def f[T], ParamSpec, TypeVar(..., default=...) unsupported (PS1106, PS2003)
Protocol, ABC, @abstractmethod, @overload the annotation is checked, then dropped; no runtime enforcement (divergence 14)
NamedTuple, TypedDict, NewType, Literal[...], Self unsupported; use a @dataclass, a plain dict[K, V], an alias, a plain str, or the class name
Any as a container element (dict[K, Any], list[Any]) compiles in the builtin and the typing spelling; reads need a proven type at the read site (annotation or checker fact), an unproven read fences PS1107; bare Any or object as an annotation is unsupported (PS1104)
async def and await compiles
dataclasses.dataclass with no options compiles as a native record; frozen=True and other decorators are unsupported (PS1201)
cast(t, v), get_type_hints, __annotations__ reads unsupported (PS2002); write the type on an annotated local instead
isinstance(v, list[int]) unsupported (PS2001); runtime checks take scalar and class names only
quoted forward references ("Node") unsupported (PS1105); define the class first and write the name unquoted

Constructs with runtime meaning beyond their annotation (pydantic models, attrs validators, anything that reads annotations at run time) are not typing failures: run them through embedded CPython (fallback mode), see Libraries and fallback mode.

For background on the typing system itself, the upstream references are the best source and pyscriptc does not re-explain them:

Networked and concurrent subsets

The stdlib subsets that touch the outside world keep to a closed surface; each names its divergences in Divergences from CPython.

  • selectors: DefaultSelector over the compiled event loop, with SelectorKey, EVENT_READ/EVENT_WRITE and register/unregister/modify/select/get_key/close. register takes a bare int fd, not a fileobj (the typed socket handle is issue #229), select() returns list[SelectorKey] rather than CPython’s (key, events) pairs, and get_key of an unregistered fd returns None where CPython raises KeyError.

What stays unsupported

  • Runtime reflection: inspect.signature, getattr dispatch, **kwargs forwarding, annotation reads.
  • Typing names used as values: cast, NewType, get_type_hints, Annotated outside an annotation.
  • Dynamic object model features: issubclass, type(v), vars, __import__.
  • Anything not covered above stops the build with a named PSxxxx code, never a guess.
    The full divergence list is in Divergences from CPython.

Where the live status lives

Per-construct, per-builtin, per-module and per-library support status is tracked on the compat site, fed by the nightly CI workflow, so it cannot go stale the way a page would.
This page states the rules and the known gaps only.

Scripts as a target

pyscriptc aims at real CLI and automation scripts, and is measured against third-party scripts vendored from public repositories (benchmarks/real_scripts/).
A script that stops with a PSxxxx code is treated as a signal about the compiler, so corpus scripts are never edited to make them compile.

Common failure modes on real scripts:

  • An assignment without an annotation (files = os.listdir(d)) stops with PS1001, and every later read of the name stops with PS2002; the first one is the cause and the rest follow from it.
  • A keyword argument to a stdlib call (shutil.copy(src, dst, follow_symlinks=False)) stops with PS2003.
  • A module outside the closed import table (platform, calendar, zipfile, tarfile, sqlite3) stops with PS2002 on the import.
    Run the module through embedded CPython (fallback mode) instead, see Libraries and fallback mode.

Related commands:

pyscriptc assess script.py        # what compiles, what does not, and why
pyscriptc imports script.py       # how each imported module is classified
pyscriptc verify script.py --call main   # run the binary and CPython side by side and diff the output