← History

Compiled, Interpreted, or Both: execution models and what they cost

From Perl's one-pass interpreter to Rust's LLVM backend and guji's single native binary — the spectrum of execution models and the trade-offs each one buys you.

gujiGoOCamlPerlRakuRustPython

Compiled, Interpreted, or Both: execution models and what they cost

"Compiled vs. interpreted" is one of the oldest dividing lines in programming, and one of the least useful as stated. Almost no popular language is purely one or the other anymore. The honest question is not which side a language is on but where the work happens — at the keyboard, at build time, at startup, or in a hot loop — and what that timing costs in startup latency, peak speed, deployment size, and the errors you catch before shipping.

Seven languages map the spectrum nicely. Let us walk from the most dynamic to the most static.

Perl: parse it, run it, throw the tree away

Perl is the canonical "interpreted" language, and it earns the label honestly. Larry Wall released Perl 1.0 to the comp.sources.misc Usenet group on December 18, 1987. When you run a Perl script, perl compiles the whole program to an in-memory tree of opcodes and then walks that tree — there is no separate build step and no artifact left on disk. Compilation and execution are one command:

my @n = (1..6);
say join ",", map { $_ * 2 } grep { $_ % 2 == 0 } @n;
# prints: 4,8,12

That run-it-now immediacy is the whole point. Wall's design slogan, from Programming Perl, was that "easy things should be easy, and hard things should be possible" — and an edit-run loop with zero ceremony is the easiest thing of all. The cost is that the compile-to-opcodes work happens every time the script starts, and that errors of meaning surface only when execution reaches them.

Python: compiled to bytecode, then interpreted (and now, sometimes, JIT-ed)

People call Python "interpreted," but CPython actually compiles your source to bytecode first and then runs that bytecode on a virtual machine. You can see the compiler's output directly:

def greet(name):
    return f"hello, {name}"

print(greet("world"))   # hello, world

Disassemble greet and CPython shows you LOAD_FAST, BINARY_OP, RETURN_VALUE — real instructions for a stack machine, not your source text being re-read line by line. The bytecode is cached in __pycache__/*.pyc so the compile step is skipped next time. As of Python 3.13, CPython even ships an experimental just-in-time compiler (PEP 744), built with a "copy-and-patch" technique and disabled by default, that can turn the hottest bytecode into machine code at runtime. So Python is best described as bytecode-compiled and VM-interpreted, with optional JIT — three execution stages living under one "scripting language" label.

Raku: a real compiler in front of a real VM

Raku (designed beginning in 2000 as "Perl 6," and renamed in October 2019) looks like a scripting language but is implemented like a serious managed runtime. The reference compiler, Rakudo, parses your program through several explicit stages and emits MoarVM bytecode (mbc), which MoarVM then executes. Crucially, MoarVM ships a runtime bytecode optimizer and a JIT compiler enabled by default, so hot Raku code is genuinely compiled to machine instructions while the program runs.

my @n = 1..6;
say @n.grep(* %% 2).map(* × 2).join(",");   # 4,8,12

The same one-liner the Perl version expressed, but running on a JIT-backed VM. Raku pays a noticeably higher startup cost than Perl (there is a large runtime to spin up) in exchange for that adaptive optimization and a far richer object and grammar system.

OCaml: the same source, two backends

OCaml makes the spectrum explicit by handing you two compilers for one language. ocamlc compiles to bytecode that runs on the ocamlrun interpreter; ocamlopt compiles the very same source to native machine code as a standalone executable. Internally both lower your program — after type checking — to an untyped intermediate "lambda" form where pattern matches become optimized automata, and then either emit bytecode or feed a native code generator that does cross-module inlining the bytecode interpreter skips.

let () =
  [1; 2; 3; 4; 5; 6]
  |> List.filter (fun x -> x mod 2 = 0)
  |> List.map (fun x -> x * 2)
  |> List.map string_of_int
  |> String.concat ","
  |> print_endline   (* 4,8,12 *)

The trade is the classic one, made selectable: bytecode compiles fast and runs portably; native code compiles slower and runs faster. Same program, your choice of where the cost lands.

Go: compile fast, ship one static binary

Go (open-sourced November 10, 2009; 1.0 in March 2012) is ahead-of-time compiled to native code, but its defining choice is what it produces: by default the gc toolchain links a single statically-linked binary with the garbage collector and goroutine scheduler baked in. There is no VM to install on the target machine and, typically, no shared-library dependencies to chase.

package main

import (
	"fmt"
	"strings"
)

func main() {
	var out []string
	for _, x := range []int{1, 2, 3, 4, 5, 6} {
		if x%2 == 0 {
			out = append(out, fmt.Sprint(x*2))
		}
	}
	fmt.Println(strings.Join(out, ",")) // 4,8,12
}

Go deliberately trades peak optimization for compile speed and operational simplicity — its compiler is famously fast, and "copy the binary, run it" is the whole deployment story. The runtime is still there (it manages goroutines and collects garbage), it just travels inside the executable.

Rust: maximal static work, no runtime to carry

Rust (first stable release 1.0 on May 15, 2015) pushes the work as far toward build time as a mainstream language goes. It type-checks, borrow-checks, lowers your program to its own mid-level IR (MIR) for ownership analysis and optimization, and then hands a lower IR to LLVM, which produces heavily optimized native code ahead of time. There is no garbage collector and no language runtime to ship.

fn main() {
    let out: Vec<String> = (1..=6)
        .filter(|x| x % 2 == 0)
        .map(|x| (x * 2).to_string())
        .collect();
    println!("{}", out.join(",")); // 4,8,12
}

The bill is paid up front and loudly: Rust compiles slowly and refuses to build code it cannot prove memory-safe. In return you get native speed with no runtime overhead and a large class of bugs eliminated before the program ever runs.

guji: tree-walked today, single native binary by design

Which brings us to guji, the language this guide is built around. By design, guji is firmly on Rust's and Go's end of the spectrum: it is statically typed and fully type-checked at compile time, and its specification states the compiler "emits a single self-contained native executable" with "no separate runtime to install" — even the concurrency scheduler lives inside that one binary. Execution begins at sub main(), and the documented pipeline runs source → tokens → syntax tree → typed tree → intermediate code → native executable.

There is a wrinkle worth being honest about, because guji is young. The v0 implementation is built in stages, and only the early stages exist today: a lexer, a parser, and a tree-walking evaluator that executes the syntax tree directly. Native code generation is a later milestone. So the language is specified as ahead-of-time compiled, but the tool you can run right now interprets the tree — exactly the early life cycle Rust itself went through before its LLVM backend.

The semantics, happily, do not depend on the backend. Run this through the current evaluator and it prints the same [4, 8, 12] its cousins above produced:

[1, 2, 3, 4, 5, 6].filter({ $_ % 2 == 0 }).map({ $_ * 2 })
# => [4, 8, 12]

That data-first chain — filter then map, each taking a topic-block lambda whose parameter is the implicit $_ — is verified output from guji's v0 REPL. When the native backend lands, the only thing expected to change is where the cost goes: a slower build in exchange for a fast, dependency-free executable, just like its statically-compiled neighbors.

What it all costs

Line the seven up and the spectrum is really a question of when the work happens:

No row is "the right answer." Each is a different bet about where you would rather spend time. Knowing which bet a language has made tells you more than the word "compiled" ever could.