Nil Python (.npy)

Nil Python is python-ish. It is a Python-shaped language that PXX compiles ahead of time to native code, through the same backend as Pascal and C. It is not a Python implementation and there is no interpreter at run time. A useful core of everyday Python behaves as it does under CPython; outside that core, Nil Python is known to have plenty of issues. If a program depends on Python's dynamism, or on the standard library in depth, run it on CPython.

Source files may use .npy or .py; both go through the same frontend.

Every claim on this page was checked on 2026-09-24 by compiling a snippet with the compiler at commit 8e68f024e2 (binary sha256 b51d542b4e1f) and, where CPython can run the same source, comparing the output with CPython 3.14.4.

Where it runs today

The core that works

These give CPython's output:

Known limits

Each of these was reproduced with the compiler named above. On 2026-09-28 every row was checked again with pin v447 (compiler sha256 fad87004e4e8), built for x86-64, i386 and arm32 (the last two run under QEMU), against CPython 3.14.4: each row behaves as written, and the three targets print the same. On 2026-09-29 every row was run again with pin v450 (compiler sha256 c19cc2d531e4, called directly), on the same three targets: each behaves as written, including the last three rows.

What you write CPython Nil Python
def f(n: int) -> int: return n * n, then f(2 ** 40) 1208925819614629174706176 0 — an -> int result is a 64-bit machine integer and wraps silently. Leave the return type unannotated to keep arbitrary precision.
hex(2 ** 64 + 1) 0x10000000000000001 0x1 — hex, octal and binary text of an int wider than 64 bits is wrong; format(x, "x") on one raises ValueError. Decimal str() is correct.
inside a function, b = len(sys.argv) - 1 (0 with no arguments), then 7 // b inside try/except ZeroDivisionError caught Runtime error 200, exit code 200; the handler never runs. The same for a big int that reaches zero and for divmod(7, b). Some shapes do raise and are caught (a literal 1 // 0, a module-level b = 0, a zero divisor passed in as a parameter), so do not rely on the difference; test the divisor first.
for v in (x * 2 for x in src()): lazy the generator expression is evaluated in full before the loop starts
a.nope() where no class declares nope AttributeError at run time compile error: A has no method nope
A.f = g (replacing a method) allowed compile error, because classes are fixed at compile time; the message, cannot call non-static method on class type directly, does not say so
match x: / async def supported compile error (undefined variable (match))
from math import * (a star import, from any module) imports the module's public names compile error: expected expression, on line 1. Write import math and math.sqrt, or name what you use: from math import sqrt, pi. Measured on x86-64 with v441, v450 and v451 (2026-09-29); see Known issues
f"""a {v} b""" supported compile error: triple-quoted f-strings are not supported
import threading works needs the --threadsafe compiler flag; the error says so
def mk(): return gen(5), then list(mk()), where gen is a generator [5, 6] [] on every target: a generator returned from a def yields nothing, and next(mk()) does not compile. Create the generator where it is consumed.
on i386, arm32 or riscv32, exec calling a method whose parameters are annotated (name: str, n: int, flag: bool) works the call runs with wrong arguments: p.greet('x', 3, True) prints hi 12884901888 True on i386 and hi x 12884901888 False on arm32 and riscv32 (pin v450; pin v446 crashed on i386 and arm32). Annotating the parameters as object works, and so do unannotated parameters on a method the program never calls directly; see Known issues
on arm32, from time import sleep as pause, then pause(0) returns never returns; from time import sleep and sleep(0) work

A generator abandoned before it is exhausted, for example by break, does not release the class instances held in its local variables.

A Pascal array of T that a Pascal routine returns can be indexed straight off the call (d.MakeArr(4)[3]). Since v450 (63c957f093 and a49f6f12c6) it can also be passed to a Pascal array parameter by value, const or open array, inline (d.SumV(d.MakeArr(4))) or through a name (a = d.MakeArr(4), then d.SumV(a)). v449 refuses both; v448 refuses the inline call and accepts the name. Anything else on such a name, a[i], len(), for, print, b = a or return a, is refused at compile time. With v448 and earlier, indexing it, len() and for compiled and read wrong values. A Pascal var or out object parameter needs a name that already holds an object. See Known issues for what was measured.

Where it differs on purpose

Memory in Nil Python

Reference counting, no collector. Every object counts the names and containers that refer to it, and it is freed the moment that count reaches zero. There is no garbage collector running beside the program, so there are no pauses, and memory comes back at a predictable point. On an ESP32 that is what keeps a long-running program's heap flat.

When memory comes back. The table gives what was still allocated (live) just before the function returned. The function filled a list with 30 strings and then let go of it in the way shown. Measured on x86-64 with tools/census_at_exit.sh: pin v445 (caf21ac399f1) on 2026-09-27, and pin v446 (ae3466a018d8) and the compiler after it (built at e072d579b0, ccd62c91f30e) on 2026-09-28, and pin v448 (b2b325036c3b) and the compiler after it (built at a2614fcb8b, 5dea028059af) later that day:

how the list was dropped v445 v446 after v446 v448 after v448
kept (the control) 32 32 32 32 32
kept = None 32 1 1 1 1
del kept 32 32 32 32 1
a comprehension, then rows = None 32 32 1 1 1
at module level, kept = None 32 1 1 1 1

Cycles are never freed. Two objects that refer to each other (a.other = b; b.other = a) keep each other's count above zero, and with no collector nothing reclaims them. This is the design for this beta; CPython and MicroPython both collect cycles. Break the cycle before letting go:

class Node:
    def __init__(self):
        self.other = None

def pair():
    a = Node()
    b = Node()
    a.other = b
    b.other = a         # a cycle
    a.other = None      # break it before letting go
    a = None
    b = None            # both are freed

pair()

Measured with v445 and with the compiler after it: this program leaves nothing allocated, and without the a.other = None line it leaves both nodes (2 allocations).

gc has nothing to collect. From commit c64b304036, import gc runs on the PC and compiles for the ESP32-C3 and the ESP32-S3; it has not been run on an ESP32 for this page. It is library code, so the v445 pin compiles it from such a tree. gc.collect() returns 0, and it does not collect cycles. gc.mem_free() and gc.mem_alloc() report the heap: ESP-IDF's byte-addressable heap on the ESP32, and PXX's own heap on a PC, where mem_free() can be 0 in a small program. enable(), disable() and threshold() change nothing, and threshold() returns -1. In a tree before that commit, import gc does not build (no member collect came of the qualifier gc); use import 'espsys.pas' as sys and sys.free_heap() on the ESP32. Don't name your own file gc.py or gc.npy: it shadows the module.

Measuring it

ESP: math errors do not halt

On ESP targets the rule is that a math error must not stop the device. In the owner's words, an embedded device "should (try) to keep running, even if whatever unexpected input (sensor etc) produces a math error. we should not halt." Desktop builds are unaffected.

On both ESP chips, // and % by zero give 0, and / by zero gives an IEEE infinity or NaN, with no ZeroDivisionError. That was checked with pin v445 on 2026-09-27 under QEMU on the ESP32-C3 and the ESP32-S3: print(a // b, a % b) and print(x / y, y / y) with b = 0 and y = 0.0 printed 0 0 and inf nan, and the program carried on. On desktop targets / by zero raises ZeroDivisionError, as CPython does, and so do // and % in most shapes; some shapes stop with runtime error 200 instead (see Known limits). The full table, with Pascal and C, is in Known issues.

Imports

A bare import name means a Python module (name.py or name.npy), or one of the PXX units that carry a Python surface, such as math, re, json or zlib. To reach a Pascal or C file, quote it and give it an alias:

import 'sysutils.pas' as su
print(su.Trim("  hi  "))          # hi

Finding a third-party Python package: -Fu

-Fu<dir> adds a search root. Point it at the directory that contains the package:

pxx -Fu/path/to/site-packages drv.npy drv

Without it, from mypkg import greet fails with import: no unit named mypkg and no shim mimic_mypkg.

Shims: standing in for a Python package

Some package names resolve to a PXX unit named mimic_<module> that implements part of that package's API. The build says so:

note: reportlab_lib_pagesizes -> mimic_reportlab_lib_pagesizes (shim, subset)

A shim covers what PXX needed, not the package. --no-shims refuses every substitution, so a build that passes with it contains no stand-in code.

The shims in pin v451, each checked with a one-line import on 2026-09-29:

Some of these have entries with an example and their limits in More units.

Python extension modules import bare

A Pascal unit that declares {$PYEXTENSION} and binds the cpyext runtime is a Python extension module, the way _json is to CPython, so a bare import reaches it. The declaration is required; binding the runtime alone does not make a unit importable this way.

C libraries

Nil Python can import a C header and call the library directly. A trailing T** out-parameter becomes the return value, Python strings are passed as C strings and char* results are copied back, and integer #defines become constants.

A shim of the same name wins over a C header. import sqlite3 therefore reaches a small DB-API shim: sqlite3.connect(...) and con.execute(...) work, but rows print as lists rather than tuples and con.cursor() does not exist. Build with --no-shims to call SQLite's C API instead:

import sqlite3                     # built with --no-shims
db = sqlite3_open("/tmp/users.db")
sqlite3_exec(db, "CREATE TABLE users(id INT, name TEXT);", 0, 0, 0)

That C route, with file-backed CRUD, is part of the test suite.

Reporting a problem

Report it at https://github.com/yoctobyte/pxx. Include the smallest program that shows it, CPython's output, PXX's output, and what pxx --version prints. Expect gaps: a report is most useful when it comes from a real program rather than a probe of an edge case.