Bytecode, the GIL and the Python way
Part of the free LogicWiz Python course, Module 13: Python under the hood. The examples and practice run in the app.
Riya's senior says "Python is interpreted, it never compiles". But Riya found a __pycache__ folder full of .pyc files next to her code. Who is right? (Both, a little.)
You'll learn to
- Explain how CPython turns source code into bytecode and runs it
- Compare CPython, PyPy and other Pythons
- Explain what the GIL is and when threads do or do not help
- Know the big Python 2 vs 3 changes, PEP 8 and the Zen of Python
Learn
Source → bytecode → virtual machine
When you run a .py file, CPython first compiles it to bytecode: small, simple instructions like "load this name", "add", "return". Then the Python virtual machine (a big loop written in C) runs those instructions one by one.
For imported modules, the bytecode is saved as .pyc files in __pycache__, so the next run can skip compiling. So Python is compiled (to bytecode) and interpreted (the bytecode is run by the VM, not turned into machine code).
Look at the bytecode with `dis`
import dis
def bill(price, qty):
return price * qty + 5
dis.dis(bill)
You will see instructions like LOAD_FAST price, BINARY_OP * and RETURN_VALUE. The exact names change between Python versions, so read them as a picture of the idea, not something to memorise.
A function carries its code object
def bill(price, qty):
total = price * qty
return round(total)
code = bill.__code__
print(code.co_argcount)
print(code.co_varnames)
print(code.co_names)
prog = compile("print(40 + 15)", "<till>", "exec")
exec(prog)
co_varnames lists local names (parameters first), co_names lists global and attribute names the code uses. compile() turns text into a code object; exec() runs it.
Many Pythons
"Python" is a language; there are several programs that run it:
- CPython: the standard one, written in C. What you get from python.org. What this course uses.
- PyPy: has a JIT (just-in-time compiler) that turns hot loops into machine code; pure-Python loops often run several times faster. Some C extensions work less well on it.
- Pyodide: CPython compiled to WebAssembly, so it runs in a browser. It is running your code on this page right now.
- MicroPython (small chips), Jython (Java), IronPython (.NET).
The GIL
CPython has a Global Interpreter Lock: in one process, only one thread runs Python bytecode at a time. It exists because reference counts (Lesson 13.3) are updated all the time; one lock keeps them safe and keeps single-threaded code fast.
What it means for you:
- I/O-bound work (downloads, files, waiting for a database): threads do help, because a thread releases the GIL while it waits.
- CPU-bound work (heavy maths in pure Python): threads don't speed it up. Use
multiprocessing(each process has its own GIL) or NumPy, whose C code releases the GIL.
Python 3.13 added an experimental "free-threaded" build without the GIL; the normal build still has it.
Threads on CPU-bound work
import threading, time
def count(n):
while n > 0:
n -= 1
N = 2_000_000
start = time.perf_counter()
count(N); count(N)
one = time.perf_counter() - start
start = time.perf_counter()
t1 = threading.Thread(target=count, args=(N,))
t2 = threading.Thread(target=count, args=(N,))
t1.start(); t2.start(); t1.join(); t2.join()
two = time.perf_counter() - start
print(f"one thread : {one:.2f}s")
print(f"two threads: {two:.2f}s")
The browser cannot run real threads, so this shows a laptop run. Two threads took about the same time as one: the GIL lets only one of them count at a time.
Python 2 vs Python 3
| Python 2 (ended in 2020) | Python 3 |
|---|---|
| print "hi" (a statement) | print("hi") (a function) |
| 7 / 2 gives 3 | 7 / 2 gives 3.5; 7 // 2 gives 3 |
| str is bytes; u"..." for Unicode | str is Unicode text; bytes is separate |
| rawinput() reads text, input() evaluates it | input() always returns text |
| range builds a list, xrange is lazy | range is lazy |
| d.keys() returns a list | d.keys() returns a live view |
PEP 8 and the Zen of Python
PEP 8 is Python's style guide. The rules interviewers expect you to know:
- 4 spaces per indent, no tabs
snake_casefor functions and variables,CapWordsfor classes,UPPER_CASEfor constants- lines up to 79 characters; spaces around
=and operators, none just inside brackets - imports at the top, one per line
Tools like black, ruff and flake8 check and fix this for you. And for the philosophy, run import this: "Readability counts", "Explicit is better than implicit".
The Zen of Python
import this
A module runs its code only on the first import. If you press Run again in the same session, nothing prints, because Python already has this in sys.modules.
In one line
CPython compiles to bytecode and runs it in a VM; the GIL lets one thread run bytecode at a time, so threads help I/O, not CPU work.
Interview corner
What is the GIL? Why does CPython have it?
The Global Interpreter Lock is a mutex that allows only one thread to execute Python bytecode at a time in a process. It makes reference counting and the interpreter's internals thread-safe cheaply and keeps single-threaded code fast. Threads still help I/O-bound work (the GIL is released while waiting); for CPU-bound work use multiprocessing or C extensions like NumPy.
Is Python compiled or interpreted?
Both. CPython compiles source to bytecode (cached as .pyc in __pycache__ for imported modules) and then the Python virtual machine interprets that bytecode. You can see it with the dis module.
What is the difference between CPython and PyPy?
CPython is the reference implementation written in C, with an interpreter loop. PyPy is an alternative implementation with a JIT compiler that compiles hot code paths to machine code, so long-running pure-Python code is often much faster; compatibility with some C extensions is weaker.
Name some key differences between Python 2 and Python 3.
print became a function; / is true division (7 / 2 == 3.5); str is Unicode and bytes is separate; input() returns text (no raw_input); range, zip and dict.keys() return lazy objects/views instead of lists. Python 2 reached end of life in 2020.