What’s one thing you learned? What’s still confusing?
Async/Await & Concurrency: Parallel Python
Threading, multiprocessing, asyncio, and concurrent.futures.
Algorithms & Complexity: Sorting, Searching & Big O
Big O, all sorting algorithms, binary search, two pointers, sliding window.
Recursion & Dynamic Programming
Recursion with memoization (@lru_cache) and tabulation. Fibonacci, coin change, LCS.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
python script.py, a surprising amount happens before a single line of your logic executes. Python is often called an "interpreted" language, but that's an oversimplification. It's actually compiled to bytecode, which is then executed by a virtual machine.Here's the full pipeline — click through each stage to see what your code looks like at every step:
x = 1 + 2 becomes:NAME 'x'
OP '='
NUMBER '1'
OP '+'
NUMBER '2'
NEWLINE
import ast
source = "x = 1 + 2"
tree = ast.parse(source)
print(ast.dump(tree, indent=2))Output:
Module(
body=[
Assign(
targets=[Name(id='x', ctx=Store())],
value=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)
)
)
],
type_ignores=[]
)
The AST is completely language-agnostic at this point. It just describes what operations need to happen, not how.
.pyc files inside __pycache__/.import py_compile
py_compile.compile("script.py")
# Creates __pycache__/script.cpython-312.pyc.pyc file contains:code object (bytecode + constants + names)dis Moduledis module lets you disassemble Python functions and see the actual bytecode instructions CPython will execute.import dis
def add(a, b):
return a + b
dis.dis(add)Output:
2 0 RESUME 0
3 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
line_number offset OPCODE arg (human-readable-arg)| Instruction | Meaning |
|---|---|
LOAD_FAST | Push a local variable onto the stack |
BINARY_OP | Pop top two stack items, apply operation, push result |
RETURN_VALUE | Pop top of stack and return it |
import dis
def with_loop(n):
result = []
for i in range(n):
result.append(i * 2)
return result
def with_comprehension(n):
return [i * 2 for i in range(n)]
print("=== FOR LOOP ===")
dis.dis(with_loop)
print("\n=== LIST COMPREHENSION ===")
dis.dis(with_comprehension)LIST_APPEND opcode that's more efficient than list.append. Comprehensions also run in their own code object, which keeps the namespace clean.a += b vs a = a + bimport dis
def inplace(a, b):
a += b
return a
def regular(a, b):
a = a + b
return a
dis.dis(inplace) # Uses INPLACE_ADD
dis.dis(regular) # Uses BINARY_OP (Add)+= calls __iadd__ which modifies in-place (no new object). For immutable objects like integers and strings, both produce identical behavior — CPython falls back to __add__.import dis
fn = lambda x: x * 2 + 1
dis.dis(fn)
# LOAD_FAST 'x', LOAD_CONST 2, BINARY_OP (*), LOAD_CONST 1, BINARY_OP (+), RETURN_VALUEdef function with a single expression body.a = 2
b = 3
x = a + bLOAD_CONST 2: Push the int 2 onto the evaluation stack.
Python/ceval.c is a giant switch over opcodes that manipulates this stack.BytecodeStepper above is great for tracing one snippet. The animated stepper below lets you flip between five canonical scenarios — simple arithmetic, a function call, a branch, a loop, and a tuple-vs-list optimization — and watch the instruction pointer glide across the bytecode while the evaluation stack pushes/pops and locals get bound in real time. Loops literally trace a circle. Jumps draw a curved arrow. That's what your code looks like to the VM.LOAD_CONST, while lists must be assembled at runtime with three LOAD_CONST + one BUILD_LIST. That's why (1,2,3) is a few times faster to "create" than [1,2,3] — even though they look almost identical in source.What does dis.dis() reveal about Python list comprehensions vs equivalent for loops?
ob_refcnt field — an integer that counts how many references point to it. This is CPython's primary memory management mechanism.import sys
x = [1, 2, 3]
print(sys.getrefcount(x)) # 2 (x + the function argument)
y = x # y also references the same list
print(sys.getrefcount(x)) # 3
del y # remove y's reference
print(sys.getrefcount(x)) # 2 again
# When x goes out of scope (e.g., function returns), refcount → 0, list is freedimport sys
a = "hello" # refcount = 1 (just 'a')
b = a # refcount = 2 (a and b)
c = [a, b] # refcount = 4 (a, b, c[0], c[1])
del a
del b
# refcount is still 2 (c[0] and c[1] still reference it)
del c
# refcount → 0 → string "hello" is freed| Feature | CPython (Refcount) | Java (Tracing GC) |
|---|---|---|
| Collection trigger | Immediate (refcount = 0) | Periodic / threshold |
| Pause time | Near-zero (most frees are instant) | Stop-the-world GC pauses |
| Overhead | Per-operation refcount update | Tracing scan overhead |
| Cycles | Can't handle (needs cyclic GC) | Handles naturally |
__del__ is called the moment refcount hits zero (unless there's a cycle).a = []
a.append(a) # a references itself!
# a's refcount is 2 (the variable 'a' + the element a[0])
del a
# The variable 'a' is removed → refcount drops to 1
# But the list still references itself → refcount never reaches 0
# MEMORY LEAK without the cyclic GC!gc module implements a generational garbage collector specifically for cycles.Objects are divided into three generations based on survival:
Gen 0: Newly created objects (collected most often)
Gen 1: Survived one gen-0 collection
Gen 2: Survived one gen-1 collection (collected least often)
The intuition: most objects die young (temporary variables, loop iteration values). Objects that survive are likely long-lived (module-level data, caches). Collecting gen-0 frequently is cheap and catches most garbage.
import gc
# Check current counts (objects in gen0, gen1, gen2)
print(gc.get_count()) # e.g., (312, 5, 1)
# Thresholds: collect gen-n when count exceeds threshold
print(gc.get_threshold()) # (700, 10, 10) — defaults
# Force a full collection
collected = gc.collect()
print(f"Collected {collected} unreachable objects")
# Check counts again
print(gc.get_count())import gc
class Node:
def __init__(self, val):
self.val = val
self.next = None
# Create a cycle
a = Node(1)
b = Node(2)
a.next = b
b.next = a # cycle!
del a, b # refcounts drop but never reach 0
gc.collect() # cyclic GC finds and frees themgc.disable() for Performanceimport gc
gc.disable() # Stop automatic cyclic GC
# ... run performance-critical code with no cycles ...
gc.enable() # Re-enable
gc.collect() # Manual cleanuporjson do this internally.__del__ and GC Problems__del__ methods that are part of a reference cycle cannot be collected by older Python versions. Python 3.4+ (PEP 442) improved this, but finalizers still add overhead and can delay collection.class Problematic:
def __del__(self):
print("Being deleted")
a = Problematic()
b = Problematic()
a.ref = b
b.ref = a # cycle with __del__
del a, b
# Python 3.4+: collected, but in "safe" order determined by GCThe GIL is one of the most discussed (and misunderstood) features of CPython. It's a mutex — a mutual exclusion lock — that ensures only one thread executes Python bytecode at a time.
ob_refcnt, both increment it, and write back — but only one increment would take effect. Objects would be freed prematurely → segfaults.The GIL is the simplest solution: allow only one thread to run Python code at once. Reference counting is then safe without per-object locks.
import threading
import time
def count_up(n):
x = 0
for _ in range(n):
x += 1
return x
# Single thread
start = time.time()
count_up(100_000_000)
print(f"Single thread: {time.time() - start:.2f}s")
# Two threads — same CPU time, no speedup!
start = time.time()
t1 = threading.Thread(target=count_up, args=(50_000_000,))
t2 = threading.Thread(target=count_up, args=(50_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Two threads: {time.time() - start:.2f}s")
# Both take ~the same time — GIL prevents true parallelismimport threading
import urllib.request
def fetch(url):
urllib.request.urlopen(url) # GIL released during network wait!
# Multiple threads CAN run concurrently for IO
# (the GIL is released when a thread waits for IO)sys.setswitchinterval)Py_BEGIN_ALLOW_THREADS macro (NumPy and SciPy do this for array operations)import sys
print(sys.getswitchinterval()) # 0.005 (5ms)
sys.setswitchinterval(0.01) # Switch every 10ms| Problem | Solution |
|---|---|
| CPU-bound parallelism | multiprocessing — separate processes, each has its own GIL |
| IO-bound concurrency | threading or asyncio |
| CPU-bound in C | C extensions that release GIL (NumPy, SciPy) |
| CPU-bound interpreted | PyPy (has its own GIL but faster execution) |
Python 3.13 introduced experimental support for running without the GIL (opt-in). Python 3.14+ may enable it more broadly. The challenge: removing the GIL requires replacing per-refcount updates with atomic operations, and ensuring all C extensions are thread-safe.
a = 100
b = 100
print(a is b) # True — SAME object in memory
print(id(a) == id(b)) # True
a = 1000
b = 1000
print(a is b) # True — but NOT because of the small-int cache
print(id(a) == id(b)) # True — `a is b` and id(a) == id(b) are the same test
# Why is it True? Both literals sit in the SAME code object, so the compiler
# stores 1000 once in co_consts and points both names at it. Type these two
# lines one at a time in the REPL and you get False, because each line is
# compiled separately. Never rely on either outcome.
# Build the ints at run time and the dedup cannot happen:
c = int("1000")
d = int("1000")
print(c is d) # False — two separately allocated objects
print(c == d) # True — equal value is the thing you actually wanted
# The small-int cache is different: it survives run-time construction.
print(int("100") is int("100")) # True — 100 is in the -5..256 rangemalloc/free calls and improves cache locality. The range is an implementation detail of CPython — PyPy may cache more, MicroPython fewer.Python automatically interns strings that look like identifiers (no spaces, valid variable names):
a = "hello"
b = "hello"
print(a is b) # True — interned automatically
a = "hello world"
b = "hello world"
print(a is b) # True — one shared constant in this code objectsys.intern():import sys
a = sys.intern("hello world")
b = sys.intern("hello world")
print(a is b) # True — now internedsys.intern(): When you have a large lookup table or dictionary with many repeated string keys that are loaded at runtime (not literals). Interning makes is comparison O(1) instead of O(n) character comparison.id() Functionid(obj) returns the memory address of the object (in CPython). This is how is comparison works internally — a is b is equivalent to id(a) == id(b).x = [1, 2, 3]
print(id(x)) # e.g., 140234567890
y = x
print(id(y)) # Same address — same object
z = [1, 2, 3]
print(id(z)) # Different address — new objectEvery object in CPython is a C struct. At minimum, every Python object has:
typedef struct _object {
Py_ssize_t ob_refcnt; // reference count (8 bytes on 64-bit)
PyTypeObject *ob_type; // pointer to type object (8 bytes)
} PyObject;
An integer (PyLongObject) adds:
typedef struct {
PyObject ob_base; // base PyObject (16 bytes)
Py_ssize_t ob_size; // number of digits (8 bytes)
digit ob_digit[1]; // the actual integer digits
} PyLongObject;
This is why Python objects have overhead compared to C primitives:
import sys
print(sys.getsizeof(1)) # 28 bytes (vs 4 bytes for C int32)
print(sys.getsizeof(1.0)) # 24 bytes (vs 8 bytes for C double)
print(sys.getsizeof(True)) # 28 bytes (bool inherits from int)
print(sys.getsizeof("hello")) # 54 bytes (vs 5 bytes for C char[])
print(sys.getsizeof("")) # 49 bytes (empty string overhead)
print(sys.getsizeof([])) # 56 bytes (empty list)
print(sys.getsizeof([1,2,3])) # 88 bytes (list + 3 pointers)
print(sys.getsizeof((1,2,3))) # 64 bytes (tuple is more compact)
print(sys.getsizeof({})) # 64 bytes (empty dict)sys.getsizeof returns the shallow size — it doesn't recursively count the objects that a list's elements point to. Use tracemalloc or the pympler package for deep memory accounting.float32 arrays store 4 bytes per element (C floats). A Python list of distinct floats stores 24 bytes per float object plus 8 bytes per pointer in the list. That's 9x more memory overhead for pure Python vs NumPy.import sys
import numpy as np
# 1 million floats
# NOTE: [0.0] * 1_000_000 would NOT show this — it makes a million references
# to ONE shared float, so the float overhead is 24 bytes total, not 24 MB.
python_list = [float(i) for i in range(1_000_000)]
numpy_array = np.zeros(1_000_000, dtype=np.float32)
# Rough comparison:
# Python list: ~8MB (pointers) + ~24MB (float objects) = ~32MB
# NumPy float32: 4MB exactly
print(sys.getsizeof(python_list)) # ~8MB for the list container
print(numpy_array.nbytes) # 4,000,000 bytes = 4MBThis is the core reason to use NumPy for ML: memory efficiency and vectorized C operations with no GIL overhead.
malloc directly for every object allocation. It has a custom allocator, PyMalloc, layered on top of the OS allocator.Tier 1 (OS): malloc/free for large allocations (> 512 bytes)
Tier 2 (Arena): Python requests 256KB chunks from the OS
Tier 3 (Pool): 4KB pools carved from arenas, fixed-size blocks
malloc for every tiny object and reduces memory fragmentation.tracemallocimport tracemalloc
tracemalloc.start()
# --- Code to profile ---
data = [dict(x=i, y=i*2) for i in range(10000)]
# --- End of code ---
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
print("Top 5 memory allocations:")
for stat in top_stats[:5]:
print(stat)Output (example):
Top 5 memory allocations:
profiling.py:6: size=2344 KiB, count=70001, average=34 B
profiling.py:7: size=984 KiB, count=10000, average=101 B
import tracemalloc
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
# ... run suspicious code ...
leaky_data = [object() for _ in range(100000)]
snapshot2 = tracemalloc.take_snapshot()
top_stats = snapshot2.compare_to(snapshot1, "lineno")
for stat in top_stats[:5]:
print(stat)Tests · assert sys.getsizeof(1) > 0; assert sys.getsizeof([]) < sys.getsizeof([1,2,3])
Step through bytecode execution on a virtual stack machine, see the GIL's thread scheduling, and explore PyMalloc's memory pool hierarchy:
cProfile: Function-Level CPU Profiling# Profile a script from the command line
python -m cProfile -s cumulative script.py
# Profile with output to file
python -m cProfile -o output.prof script.py
In code:
import cProfile
import pstats
import io
def slow_function():
return sum(i**2 for i in range(100000))
pr = cProfile.Profile()
pr.enable()
slow_function() # code to profile
pr.disable()
stream = io.StringIO()
ps = pstats.Stats(pr, stream=stream).sort_stats("cumulative")
ps.print_stats(10) # top 10 functions by cumulative time
print(stream.getvalue())timeit: Micro-benchmarksimport timeit
# Quick one-liner
print(timeit.timeit("x = [i*2 for i in range(100)]", number=100000))
# Compare two approaches
setup = "data = list(range(1000))"
t1 = timeit.timeit("result = [x*2 for x in data]", setup=setup, number=10000)
t2 = timeit.timeit("result = list(map(lambda x: x*2, data))", setup=setup, number=10000)
t3 = timeit.timeit(
"import numpy as np; np.array(data) * 2",
setup="data = list(range(1000))",
number=10000
)
print(f"Comprehension: {t1:.3f}s")
print(f"map+lambda: {t2:.3f}s")
print(f"NumPy: {t3:.3f}s")# 1. Local variable access is faster than global
import math
def slow():
result = 0
for i in range(100000):
result += math.sqrt(i) # global lookup each time
return result
def fast():
sqrt = math.sqrt # cache in local variable
result = 0
for i in range(100000):
result += sqrt(i) # local lookup (faster LOAD_FAST)
return result
# 2. join() is faster than string concatenation
slow_str = ""
for word in ["hello", "world", "foo", "bar"]:
slow_str += word # creates a new string object each time
fast_str = "".join(["hello", "world", "foo", "bar"]) # one allocation
# 3. in operator on set is O(1), on list is O(n)
lookup_set = {1, 2, 3, 1000000}
lookup_list = [1, 2, 3, 1000000]
# Checking membership:
1000000 in lookup_set # O(1) — hash lookup
1000000 in lookup_list # O(n) — linear scan
# 4. Avoid repeated dict lookups in inner loops
config = {"key": "value"}
for _ in range(100000):
val = config["key"] # dict lookup each time — slow
val = config["key"] # look up once
for _ in range(100000):
_ = val # local variable access — fast| Implementation | How It Works | Best For | Tradeoffs |
|---|---|---|---|
| CPython | Bytecode interpreter, reference counting | General use, maximum compatibility | Slowest at CPU-bound tasks |
| PyPy | JIT compiler (traces hot loops, compiles to machine code) | CPU-bound algorithms, long-running processes | Slower startup, higher memory, less C-extension compatibility |
| Jython | Python → JVM bytecode | Java ecosystem integration | No CPython C extensions, limited Python 3 support |
| MicroPython | Stripped-down CPython | Microcontrollers (ESP32, Raspberry Pi Pico) | Very limited stdlib |
| Cython | Compiles .pyx files to C extensions |
# This kind of code benefits enormously from PyPy:
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
# CPython: ~2s for fibonacci(10_000_000)
# PyPy: ~0.05s — 40x speedup from JIT compilationPyPy works best when:
# fibonacci.pyx (Cython)
def fibonacci(int n):
cdef int a = 0, b = 1
cdef int i
for i in range(n):
a, b = b, a + b
return a
# Compiled: ~100x faster than CPython for this function
# Used by: NumPy, SciPy, pandas, scikit-learn internals
refcount drops to 0, CPython deallocates the object synchronously — no GC pause; the cyclic collector only runs for reference cycles that refcounting missesmultiprocessing for CPU parallelism or asyncio for I/O concurrencya is b can return True for separately created objects in this range — always use == for value comparison, is only for identitycProfile to find the actual bottleneck; common wins are algorithmic changes, C extensions (NumPy, Pandas), and switching to PyPy for pure-Python CPU-bound workloadsYou now understand Python from the inside out:
| Layer | Key Concepts |
|---|---|
| Execution pipeline | .py → lexer → tokens → parser → AST → compiler → bytecode → CPython VM |
| Bytecode | dis module, stack machine, LOAD_FAST / BINARY_OP / RETURN_VALUE |
| Reference counting | ob_refcnt, sys.getrefcount(), immediate freeing when count = 0 |
| Cyclic GC | Three generations, gc.collect(), cycles never freed by refcounting alone |
| The GIL | One thread runs at a time, released for IO, use multiprocessing for CPU parallelism |
| Integer caching |
What does `sys.getrefcount(x)` always return at minimum, even when only one variable references the object?
| Accelerating hot Python functions |
Requires compilation, .pyx syntax |
| Numba | JIT for NumPy-heavy code (@jit decorator) | Scientific computing inner loops | Only works with NumPy and supported operations |
-5 to 256 are singletons; use == not is for value comparison |
| Memory layout | PyObject struct overhead, sys.getsizeof(), why NumPy beats Python lists |
| PyMalloc | Arena → pool → block hierarchy for sub-512-byte allocations |
| Profiling | cProfile, timeit, tracemalloc — measure before optimizing |
| Implementations | CPython (compat), PyPy (JIT speed), Cython (C extension), Numba (NumPy JIT) |