Python for JS Devs · Under the Hood

How the Python engine works

You know V8 cold. Here's CPython's actual pipeline, GC, and threading model — and exactly where your V8 intuition holds and where it breaks.

Lesson 00Time ~16 min Type Mental model (knowledge)Prereq None

"Python" the language has several engines. The one you almost always run is CPython — the reference implementation written in C, the analogue of V8 for Node. When you type python (or uv run), you're launching CPython. Everything below is CPython unless noted.

1 · Both are bytecode VMs — but only one JITs by default

Here's the surprise: Python is not "interpreted line by line." Like V8, it compiles your source to bytecode first, then executes that on a virtual machine. The pipelines rhyme:

V8 (Node)

Source.js
Parser→ AST
Ignitionbytecode + interpreter
TurboFan / MaglevJIT: compiles hot functions to machine code

CPython

Source.py
Compilertokenize → AST → bytecode
.pyc cachebytecode saved in __pycache__
ceval loopthe interpreter executes bytecode on a stack machine
The core difference V8's whole reason for existing is its optimizing JIT: it watches which functions run hot, then recompiles them to specialized machine code (using hidden classes and inline caches). CPython, by default, has no such JIT — the ceval loop interprets the same bytecode every time. That single fact explains most of "why is Python slower than Node for CPU work?" You're comparing an interpreter to a tracing optimizing compiler.

You can see the bytecode — it's not hidden like V8's:

uv run python -c "import dis; dis.dis(lambda a, b: a + b)"
# LOAD_FAST a → LOAD_FAST b → BINARY_OP + → RETURN_VALUE

The .pyc files in __pycache__/ (you saw them in the FastAPI project) are just cached bytecode, so re-running a module skips recompilation — the analogue of V8's compilation cache.

2 · CPython has been closing the gap (3.11 → 3.14)

The "no JIT" story is changing, and it's worth knowing because you're on Python 3.14:

Takeaway Treat CPython as "a fast interpreter that's growing a JIT," not "a JIT VM like V8." For a backend/API service (I/O-bound), raw CPU speed rarely matters — the network and DB dominate. For CPU-heavy numeric work, people drop into C-backed libraries (numpy, pydantic-core in Rust) rather than relying on the interpreter.

3 · The GIL — the biggest mental-model shift from JS

JavaScript has no threads in the language: one call stack, one event loop, period (Web Workers / worker_threads are fully separate isolates that don't share memory). Python is different: it has real OS threads that do share memory — but a lock called the GIL (Global Interpreter Lock) means only one thread executes Python bytecode at a time.

CPU-bound work + threads
Thread A running bytecode
Thread B waiting on GIL
Thread C waiting on GIL
Threads take turns. No CPU parallelism — 4 cores don't help.
I/O-bound work + threads
Thread A awaiting socket → releases GIL
Thread B running
A thread releases the GIL while blocked on I/O, so others run. Threads DO help here.

Consequences you must internalize:

Why does the GIL even exist? It's the simplest way to make CPython's memory management (next section) thread-safe. Every object's reference count would otherwise need atomic updates on every operation — the GIL made refcounting correct and the interpreter simple. Removing it (PEP 703) required years of careful work for exactly this reason.

4 · Memory: reference counting vs V8's tracing GC

V8 uses a generational, tracing garbage collector (mark-sweep-compact, young/old generations) — it periodically pauses to trace live objects. CPython uses a fundamentally different scheme:

V8
// no per-object counter
// GC traces reachable objects
// young gen (scavenge) + old gen
// stop-the-world pauses, then
// mostly incremental/concurrent
CPython
# 1) reference counting (primary)
#    every object has a refcount
#    hits 0 → freed IMMEDIATELY
# 2) cyclic GC (secondary)
#    catches reference cycles
#    refcounting alone can't free

Two-part system:

  1. Reference counting — every object carries a count of references to it. Assigning or passing bumps it; going out of scope drops it. When it hits 0, the object is freed right then — deterministic, no waiting for a GC pass. (This is why with open(...) / __del__ cleanup feels prompt.)
  2. Cyclic garbage collector — refcounting alone can't reclaim cycles (A → B → A never reaches 0). A separate generational collector periodically finds and frees unreachable cycles. You can even see it: import gc.
Practical difference Refcounting means memory is usually reclaimed the instant the last reference drops (predictable), but it costs a counter update on nearly every operation — part of why pure-Python is slower, and the reason the GIL exists. V8 defers cleanup to GC cycles (less predictable timing, but no per-op counter cost).

5 · "Everything is an object" — literally

In V8, a small integer is a tagged value (SMI) and objects get hidden classes so property access compiles to a fixed offset. In CPython, every value is a heap-allocated PyObject — even 3 is a full object with a type pointer and a refcount. Attribute access typically goes through a dict (__dict__), not a fixed slot.

x = 42
id(x)        # the object's address (like a pointer)
type(x)      # <class 'int'> — even ints have a type object
sys.getrefcount(x)  # its current reference count

This flexibility (monkey-patching, dynamic attributes, everything introspectable) is exactly what makes attribute access slower than a V8 hidden-class offset — a real trade of speed for dynamism. (3.11's specialization adds inline caches to soften this.)

6 · Many engines, like JS

JS enginePython equivalentNote
V8 (Node/Chrome)CPythonThe reference impl — what you run
SpiderMonkey / JSCPyPyAlternative impl; PyPy has a real tracing JIT — often much faster
—MicroPython, GraalPy, JythonEmbedded / JVM / GraalVM targets

🎯 Your win — see the engine

In the REPL, run import dis; dis.dis(lambda a, b: a + b) to read the bytecode your source compiles to. Then run import sys; sys.getrefcount([]) and import gc; gc.get_count(). Seeing bytecode + a live refcount + the cyclic GC counters makes the whole model concrete — that's the win.

Check yourself

Q1. Compared to V8, CPython's default execution model is:
CPython compiles to bytecode then interprets it; there's no optimizing JIT in the default build (3.13+ adds an experimental one).
Q2. The GIL means CPython threads cannot give you:
Only one thread runs bytecode at a time → no CPU parallelism (use multiprocessing). Threads still overlap I/O, since the GIL releases during blocking calls.
Q3. CPython's primary memory-management mechanism is:
Refcounting frees objects the instant the count hits 0; a separate cyclic GC reclaims reference cycles.
Primary sources CPython Developer's Guide — the interpreter · PEP 659 (specializing interpreter) · PEP 703 (free-threading) · Free-threading HOWTO (3.14) · dis.
💬 Ask your teacher: "when should I actually reach for multiprocessing vs asyncio vs threads?" — the decision table is worth a whole conversation, and it's exactly the kind of thing this model lets you reason about.