Libraries and fallback mode
pyscriptc compiles the typed subset of Python 3.14.
Every dependency gets one of two execution modes: it compiles to native code when it fits the subset, or it runs through embedded CPython (fallback mode) when it does not.
An untyped dependency is never impossible to use: it runs in fallback mode.
The two planes
Every compiled program is split into two execution planes, decided per module and shown by
pyscriptc assess (the plane map):
| Plane | What runs there | What it costs |
|---|---|---|
| native | compiled to machine code; no interpreter in the loop | the subset’s speed and binary size; anything outside the subset fences the build |
| island | an embedded CPython 3.14 interpreter in the same process | interpreter speed for those parts, plus a marshalling cost on every native-island call crossing |
The split is not a per-package verdict of “works or not”: a program can run entirely on the island
tier and still produce correct output, at interpreter speed, in a single binary that needs no
system Python.
What the split decides is how much of the program gets native speed.
pyscriptc assess --json prints the planned route per definition (planeMap, with demotedBy
naming the import or call that pulled a function onto the island) and
pyscriptc run --report=report.json prints the route that a run actually took.
A fence (a PSxxxx diagnostic) is the compile-time signal that a construct cannot be lowered;
the island is the run-time answer for the code that a fence would otherwise stop.
Support status per package, including which packages are measured to run on which plane, is on
the compat site; this page states the mechanics and the costs.
Route a module to fallback mode
Fallback mode links your binary against libpython and runs the unsupported parts in an in-process CPython 3.14 interpreter, while the typed functions still compile to native code.
Enable it on the command line:
pyscriptc compile script.py --fallback=cpython -o script_bin
pyscriptc run --fallback=cpython script.py
--island is an alias for --fallback=cpython.
You can also set it in pyproject.toml so no flag is needed:
[tool.pyscriptc.fallback]
definitions = true
Consider a script that mixes a typed function with an untyped one:
import json
def helper(x: int) -> int:
return x * 2
def parse_data(raw):
return json.loads(raw)
def main() -> None:
data = parse_data('{"count": 21}')
result: int = helper(data["count"])
print(f"result: {result}")
if __name__ == "__main__":
main()
Without the flag the compile stops on the unannotated raw parameter (PS1001).
With --fallback=cpython the unsupported definitions run in the embedded interpreter.
Note that helper runs there too: when an unsupported function calls a typed one, the compiler moves the callee into the same interpreter so both run together.
See what compiles and what does not
pyscriptc assess reports which definitions compile and which do not, with the fix for each refusal:
pyscriptc assess script.py
Its summary counts definitions inside the subset; that is not the share that runs native.
On a fallback build the deciding report is the plane map: assess --json prints the planned route per function plus demotedBy (the call that pulled a function onto the island), and run --report=report.json prints the executed route after a run.
A module whose top level imports an untyped dependency has its whole top level routed first (divergence 42), so framework-heavy trees can show a high per-definition share with everything island at run time; see What runs native and what runs on the island.
pyscriptc imports SOURCE classifies every imported module as native or as requiring libpython.
pyscriptc run --fallback=cpython --report=report.json script.py writes the same classification as JSON after a run.
The imports table
The [tool.pyscriptc.imports] table in pyproject.toml routes each top-level module name to a lane: compile (default), refuse (always a compile error naming the policy site), or opt-in fallback entries.
Set it when you want the decision recorded in the project instead of on the command line.
The full key reference is Config.
How values cross
- Scalars (
int,float,bool),str,bytesand lists of scalars cross by value.
A list is copied, so a change made on the embedded side is never visible to native code. - Dictionaries and class instances cross as opaque handles supporting attribute access, subscript lookups, method calls and
str()formatting. - Exceptions raised in the embedded interpreter are catchable from native code: a native
try ... except ValueError:catches aValueErrorraised there.
Unknown exception types surface asRuntimeErrorcarrying the original class name and message. raise SystemExit(code)out of the embedded side exits the process with that status.
def risky(x):
y = [x, "tag"]
if x < 0:
raise ValueError("negative value")
return len(y) + x
def main() -> None:
try:
val: int = risky(-5)
print(f"val: {val}")
except ValueError as exc:
print(f"handled ValueError: {exc}")
if __name__ == "__main__":
main()
pyscriptc run --fallback=cpython script.py
# handled ValueError: negative value
Call compiled code from Python with bindings
The other direction works across module boundaries with --emit binding, which produces a shared library plus a ctypes loader module:
pyscriptc compile typed_math.py --emit binding -o typed_math
from typed_math_binding import add
print(add(20, 22))
Use this when a plain CPython script, or code running in fallback mode, needs to call compiled functions from another module.
Environment and limits
Fallback mode picks up the host Python environment:
- If
VIRTUAL_ENVis set, itssite-packagesis on the path. PYTHONPATHentries are added.- The directory containing the source file is on
sys.path, and dependency roots frompyproject.tomlor--dependency-rootjoin both the type-checker search path and the embedded interpreter’s path.
Limits to know:
- Fallback mode provides compatibility, not native speed: the embedded parts run under the interpreter, calls crossing the boundary pay a marshalling cost, and native threads serialize at the GIL.
- Closures and generators cannot be passed across the boundary.
- Cross-compiling a libpython-linked binary to a foreign target is refused (divergence 31); fallback mode needs the host libpython and its installed packages.
- Uncaught exceptions from the embedded side print the type and message but not a traceback (divergences 12 and 42).
- Unsupported module-level statements plan the whole module top level for the embedded interpreter (divergence 42).
- The island plan propagates through calls: a native-eligible function that names an island-planned import or an unannotated value reaching one is routed there too (divergence 59). On one measured Flask-plus-LLM backend this took the run-time native share from 41% (definitions passing in isolation) to 0% (functions planned island).
The complete crossing rules and numbered divergences: Divergences from CPython.
Per-module and per-library support status: the compat site.