Python in 1024 Bytes: How a Tiny Interpreter Executes Source Directly


How much machinery does a programming language really need?

Production Python answers with a formidable stack. CPython decodes source, tokenizes it, builds an abstract syntax tree, performs symbol-table analysis, emits bytecode, constructs code objects and frames, and runs instructions in an adaptive evaluation loop. Those layers buy a real language: precise errors, rich objects, closures, exceptions, imports, debugging, portability, and speed.

Austin Z. Henley approached the question from the opposite direction. His Python-in-1024-bytes experiment asks what can be removed while the result still feels like Python. The answer is a 1,024-byte GNU C program that reads a Python-shaped language from standard input and directly executes enough of it to run FizzBuzz.

It is not a small CPython, and it is not compatible with arbitrary Python. It is a deliberately narrow interpreter whose design is governed by a source-code byte budget. That constraint produces a fascinating architecture: the source text doubles as the instruction stream, C recursion doubles as block state, byte offsets double as function objects and loop targets, and a 256-entry integer array doubles as both variable storage and a function table.

The final program is hard to read because code golf is the last stage, not because the underlying interpreter is mysterious. The accompanying readable implementation exposes a compact, coherent machine. Reconstructing that machine is a useful lesson in parsers, interpreters, representation, and the true cost of correctness.

Choose the language by its silhouette

Henley’s first target was 512 bytes. A conventional recursive-descent parser quickly handled arithmetic such as 1 + 2 * 3 and assignment. It also quickly consumed the budget while producing something that looked more like a calculator than Python.

The breakthrough was to stop asking, “Which parts of Python are smallest?” and ask, “Which visible features make a program recognizable as Python?” The resulting subset keeps:

  • Single-letter lowercase integer variables and integer literals.
  • Assignment and arithmetic with +, -, *, and %.
  • One comparison using <, >, <=, >=, or ==.
  • Integer truthiness.
  • if and else.
  • while loops, including else blocks.
  • for x in range(y) loops, including else blocks.
  • No-argument function definitions and calls, including recursion.
  • Indentation-based blocks without lexical scope.
  • print with either one string literal or one integer expression.
  • Comments.

The omissions are equally important. There are no objects, lists, dictionaries, floats, imports, exceptions, parameters, return values, local scopes, meaningful diagnostics, or general identifier rules. Parenthesized arithmetic is absent. String handling is only sufficient for a double-quoted argument to print. The input must already be valid in the tiny dialect.

This is the first lesson of extreme constraints: preserve a language’s identity, not an arbitrary percentage of its specification. Colons, indentation, familiar control-flow headers, def, and unparenthesized conditions contribute more to Python’s silhouette than a much larger expression grammar would.

Collapse the normal compiler pipeline

CPython’s compiler documentation describes distinct parsing, AST, symbol-table, control-flow, assembly, and code-object stages. Its interpreter documentation then describes frames, an evaluation stack, bytecode decoding, exception unwinding, calls, and instruction specialization.

The tiny interpreter removes nearly every boundary. It normalizes the input into a fixed buffer, advances one character at a time, recognizes syntax, and executes each construct immediately. There is no token array, AST, bytecode, heap-allocated value, or separate virtual machine.

A comparison between CPython's staged source-to-token-to-AST-to-bytecode pipeline and the tiny interpreter's normalized source buffer, recursive-descent parser, direct execution, and shared 256-entry table.
The byte limit is met by making one representation—the source buffer—serve as input, syntax, control-flow graph, and executable program.

Only a handful of global variables carry the machine:

char src[999];
int vars[256];
int pos;
int ch;
int line_start;

src stores the whole normalized program. pos is the next byte to read, ch is the current byte, and line_start lets block execution rewind when it encounters a dedent. vars is indexed directly by a character value. For a variable named n, vars['n'] is its integer slot. For a function named b, the same slot stores the byte offset where the function body begins.

That reuse would be unacceptable in a general implementation: assigning to a name can overwrite its function target, every name is global, and only one primitive value type exists. Under a fixed-language challenge, however, one array and one indexing operation replace identifier strings, hashing, entries, tagged values, scopes, and callable objects.

The input pass is a destructive tokenizer

The program reads standard input character by character into src. Tabs become spaces. A one-bit state tracks whether the reader is inside a double-quoted string. Another tracks whether the line has passed its indentation margin.

Leading spaces are preserved because indentation carries block structure. Spaces after the first non-space character are dropped unless they occur inside a string. Newlines are retained. Static storage supplies the final zero byte automatically because global arrays are zero-initialized.

For example, a line conceptually written as:

    if n % 3 == 0:

is stored approximately as:

····ifn%3==0:\n

where the four leading spaces remain and interior formatting disappears.

This pass is not a lexer in the sense described by Python’s lexical analysis reference. Real Python converts indentation levels into INDENT and DEDENT tokens, handles encodings, logical and physical lines, escaped newlines, comments, string prefixes, tabs, and invalid indentation. The tiny version preserves only the bytes needed by its later routines.

The destructive normalization makes the parser smaller because it no longer needs a general skip_whitespace operation between tokens. It also creates strict, sometimes surprising rules. Tabs count as one space rather than advancing to a tab stop. Only double quotes toggle string mode. Escapes are not understood. Long input can overflow the fixed buffer. These are not hidden implementation bugs so much as features that never fit into the contract.

Four functions are enough for expressions

The expression parser is a classic recursive-descent hierarchy, compressed into four levels:

atom       := integer | one-letter-variable
term       := atom (('*' | '%') atom)*
sum        := term (('+' | '-') term)*
expression := sum (('<' | '>' | '<=' | '>=' | '==') sum)?

Each function returns an integer immediately. atom reads a decimal literal or looks up a one-character variable. term handles multiplication and remainder. sum handles addition and subtraction. expression optionally compares two sums and returns C’s integer zero or one.

The call structure provides precedence. When parsing 2+3*4, sum cannot consume the right operand until term has finished 3*4, so the result is 14. No precedence table or syntax tree is required. This aligns with Python’s documented operator precedence for the supported operators, although the tiny grammar does not reproduce Python’s comparison chaining or full unary-expression behavior.

Parsing and evaluation are fused. A production compiler would preserve an expression as nodes or instructions that could be analyzed and executed later. Here, returning the integer is the semantic action. That is a major saving, but it explains why loops must reparse their source on every iteration: there is no durable representation of the expression to revisit.

The grammar also spends correctness to save bytes. An atom treats any character above ASCII 96 as a variable candidate, the parser assumes delimiters are where they should be, and one comparison is the maximum. Unary + or - only happens to work at the start of an expression because the initial zero value combines with the following term. There is no error path because reporting which expectation failed would require both checks and messages.

Indentation becomes the block machine

run_block(min_indent) is the center of the interpreter. It repeatedly measures the leading spaces on the next line. Blank lines are skipped. If the indentation is below the block’s required level—or the source ends—the function rewinds pos to the start of that line and returns to its caller.

That rewind is subtle and essential. The nested block must stop before consuming the first line that belongs to its parent. Restoring pos lets the outer invocation inspect that same line, perhaps as an else clause or the next statement.

The implementation does not maintain Python’s explicit indentation stack. Instead, each recursive call to run_block carries one minimum indentation value. The C call stack therefore records nested suite execution. A child suite runs with indent + 1; any greater indentation satisfies it. This is looser than Python, which requires dedents to match an earlier indentation level, but it is sufficient for correctly formed input.

Python’s compound-statement grammar calls the indented body of a clause a suite. The tiny interpreter arrives at roughly the same structural idea with far less validation: measure, recurse, and return on dedent.

Control flow is movement through source text

Without bytecode or an AST, the instruction pointer is simply pos, an index into src. Loops and functions work by saving and restoring indexes.

A flowchart showing run_block measuring indentation, dispatching a statement, evaluating a condition, entering or skipping a child block, rewinding loops to their condition, and jumping to and returning from stored function body positions.
There is no compiled control-flow graph. Back edges and calls are assignments to the source cursor.

For an if, the parser evaluates the condition once. A true value recursively executes the indented body; a false value scans past it. If the next same-indent line begins with else, the interpreter does the opposite for that suite.

For a while, it saves the source offset of the condition. After the body returns, it assigns that offset back to pos, parses the condition again, and repeats. A false condition skips the body and may enter the associated else suite.

For for n in range(limit), the keyword reader assumes the exact phrase is present and jumps across its fixed characters. It sets vars['n'] to zero, saves the limit-expression position, and repeatedly reparses that expression. The body runs while the loop variable is smaller than the result; the variable is incremented after each iteration.

The consequence is semantically different from Python. Real range(y) computes a range object before iteration, while this interpreter reevaluates y every trip. If the body changes y, the loop boundary changes with it. The syntax resembles Python, but the source-driven execution model leaks through.

Functions use the same trick. Reading def b(): stores the byte offset of its body in vars['b'] and skips the definition during normal top-level execution. Calling b() saves the caller’s pos, jumps to the stored offset, invokes run_block, then restores the caller position. Recursion works because each C invocation holds its own saved return offset.

There are no arguments, return values, or local namespaces. A function is just a remembered location, and a call is a temporary cursor jump. Yet the essential call-and-return shape is real.

Dispatch works because the input is trusted

At the start of a statement, one character usually identifies the form:

  • w means while.
  • i means if.
  • f means for.
  • d means def.
  • Another lowercase letter begins assignment, print, or a function call.

The code does not verify complete keywords. It advances by known byte counts: skip the rest of while, skip or, skip inrange(, and so on. A name followed by ( is a call. The name p is treated as print; any other name is treated as a user function. A non-call name is assignment.

This is the most aggressive general design choice in the program: invalid syntax is outside the state space. A conventional parser asks whether each token matches the grammar and reports a useful error when it does not. This parser assumes the happy path and uses fixed offsets as compressed grammar rules.

That technique is valid only because the project defines its input narrowly. It would be dangerous in a tool that accepted untrusted or user-authored programs. Missing bounds checks can corrupt memory; a missing quote can scan beyond the program; an undefined function can jump to offset zero; division-by-zero-like cases around % can fail at runtime. Smallness is not sandboxing.

Golfing removes syntax, names, and duplicated state

Once the readable interpreter worked, the second optimization problem began: reduce its C source to exactly 1,024 bytes without macros or unusual external libraries.

The final source uses GNU C89 and relies on several old or implementation-specific conveniences:

  • Function and variable types default to int where the dialect accepts implicit declarations.
  • Global integers need no explicit initializer because static storage begins at zero.
  • Single-character names replace descriptive identifiers.
  • Parameters double as local temporaries and naturally occupy recursive call frames.
  • ASCII numbers replace character literals when shorter.
  • Comma, conditional, bitwise, and short-circuit operators replace statements.
  • libc functions are called without including headers, while compiler warnings are disabled.

GCC’s language-standard documentation explains the C90 lineage, while current warning documentation shows why implicit int and implicit function declarations are rejected or diagnosed in later dialects. The repository specifically compiles with GCC in GNU89 mode; this is intentionally not portable modern C.

A readable recursive line skipper illustrates the transformation:

void skip_to_eol(void) {
    if (ch != 0 && ch != '\n') {
        next();
        skip_to_eol();
    }
}

Its golfed counterpart compresses the same control into short-circuit expressions and nested calls. That saves bytes in the file, not necessarily instructions in the executable. Source size, binary size, memory consumption, execution speed, portability, and safety are different optimization targets. The challenge optimizes exactly one of them.

Some features were cut after they had already worked. Comparison support nearly lost its place because truthiness can express FizzBuzz tests without equality: if n % 15: selects the opposite branch. Keeping comparisons made the dialect more legible and general, but the trade shows how a byte budget forces language design to become explicit.

What the kilobyte excludes

The 1,024-byte headline counts the golfed C source. It does not count GCC, libc, the operating system, the C runtime, or the machine’s call stack. printf, puts, putchar, and getchar arrive from the host. Integer arithmetic, recursion, program startup, standard streams, and memory initialization are also supplied externally.

This does not diminish the result. It clarifies it. Tiny software is often an elegant arrangement of borrowed guarantees. The program’s real achievement is choosing a narrow seam where the host can carry most of the weight.

The distinction also explains why comparing 1,024 bytes to the size of CPython would be misleading. CPython provides an industrial language implementation and runtime. This program provides one carefully chosen experience under trusted input. The fair comparison is architectural: which representations and checks appear when requirements grow?

Add real identifiers and the direct array becomes a symbol table. Add local variables and calls need frames. Add strings as values and integers need tags or objects. Add helpful errors and every assumption becomes a branch with source locations. Add repeated execution without reparsing and the source wants an AST or bytecode. Add break, continue, and return and block execution needs structured control signals. Add untrusted input and bounds checks become non-negotiable.

In other words, the familiar layers of a production interpreter are not accidental bloat. They are the accumulated price of semantics, diagnostics, performance, tooling, and safety.

The deeper lesson is representation

The best part of the project is not the punctuation tricks in the golfed line. It is the readable architecture underneath them.

One source buffer serves four roles: normalized text, syntax stream, executable representation, and control-flow address space. One integer table represents variables and functions. One cursor represents parsing state and instruction position. One recursive block function represents both indentation nesting and execution. The system is small because its representations overlap.

That is a powerful technique well beyond code golf. When designing a language tool, protocol, build system, or data pipeline, the largest reductions often come from eliminating a representation boundary rather than shortening individual functions. The trade is coupling: once source position is the instruction address, editing or normalizing source changes runtime behavior; once functions share the variable table, the type system cannot distinguish them.

The 1,024-byte interpreter makes that bargain visible enough to study. Start with a recognizable experience. Define the valid world tightly. Make representations do several jobs. Borrow mechanisms from the host. Then be honest about every guarantee that disappeared.

That is how a kilobyte becomes a language—not all of Python, but enough structure to make indentation execute, loops turn, functions recurse, and a familiar little program come alive.

Further reading

100%