True A→Z → PhD-level Python Mastery Course Part1

Missing Parts // Doubts

Great question — this is a classic Python interview trap 🔥
Let’s break it down slowly and precisely.


The code

a = 257
b = 257

print(a == b)   # True
print(a is b)   # False

1️⃣ ==Value equality

== checks whether the values are equal

a == b
  • a has value 257
  • b has value 257
  • Values are the same → ✅ True

✅ This will always be true here.


2️⃣ isObject identity

is checks whether both variables point to the SAME object in memory

a is b

This depends on how Python manages memory.


3️⃣ Python Integer Interning (IMPORTANT 🔥)

Python interns (reuses) small integers for performance.

📌 Integer cache range:

-5 to 256   (CPython implementation)

Example:

x = 100
y = 100

x is y   # True

Why?

  • Python reuses the same object for small integers.

4️⃣ Why 257 breaks this rule

a = 257
b = 257
  • 257 is outside the cached range
  • Python creates two separate integer objects
  • Values same ❌ memory different

So:

a == b  → True   (values same)
a is b  → False  (objects different)

5️⃣ Proof (Memory Address)

print(id(a))
print(id(b))

You’ll see different IDs.


6️⃣ Compare with Cached Integers

a = 256
b = 256

print(a == b)  # True
print(a is b)  # True

But:

a = 257
b = 257

print(a == b)  # True
print(a is b)  # False

7️⃣ Why Python does this?

  • Performance optimization
  • Saves memory
  • Faster comparisons for common integers

⚠️ This behavior is CPython-specific, not guaranteed by the Python language spec.


8️⃣ Interview Rule (VERY IMPORTANT ❗)

✅ Use == for value comparison

❌ Use is ONLY for:

None
True / False
Singleton objects

Example:

if x is None:
    ...

9️⃣ One-line Summary (Interview Ready)

== checks value equality, is checks memory identity. Python caches integers from -5 to 256, so 257 creates separate objects, making a is b false.


If you want, I can now show:

  • 🔥 how this behaves differently in REPL vs script
  • 🔥 how strings behave similarly
  • 🔥 why is can randomly return True sometimes
  • 🔥 how this appears in FAANG interviews

Just tell me 👌

Short answer: yes — but with an important correction
Your intuition is close, but the terms stack, heap, and hash need to be fixed for Python.

Let’s clean this up precisely and interview-correct.


❌ First, let’s correct the misconceptions

❌ “Saved differently in stack”

Python does not store integers in a traditional stack like C/C++.

❌ “Saved differently in hash”

Integers are not stored in a hash table just because they are integers.


✅ The REAL Python Memory Model (CPython)

Python uses:

  • Heap → for all objects (including integers)
  • Names (variables) → references to heap objects
  • No user-visible stack memory model

What REALLY happens for:

a = 390
b = 390

Step-by-step (CPython):

  1. Python sees literal 390
  2. 390 is outside the integer cache (-5 to 256)
  3. Python creates two separate integer objects in the heap
  4. a points to object #1
  5. b points to object #2

So:

a ──▶ int(390)  [heap]
b ──▶ int(390)  [heap]

Result:

a == b   # True  (values same)
a is b   # False (different objects)

🔥 What about HASH?

Important clarification:

  • Integers ARE hashable
  • But hash ≠ storage location
hash(390) == hash(390)   # True

That just means:

  • If used as a dict key / set element, both hash the same
  • It does NOT mean they share memory

Example:

d = {390: "x"}

Python:

  1. Calls hash(390)
  2. Uses it to place the key in a hash table
  3. Stores a reference to the integer object

➡️ Hashing is for lookup, not memory identity.


Why small integers behave differently

a = 100
b = 100

Python uses integer interning:

a ──▶ int(100) ◀── b

So:

a is b   # True

⚠️ Key Rule (Memorize This)

Variables do not store values.
They store references to objects on the heap.


Interview-Grade Summary (Perfect Answer)

When writing a = 390 and b = 390, Python creates two separate integer objects on the heap because integers outside the cached range are not interned. Both variables reference different objects with the same value, so a == b is true, but a is b is false. Hashing is unrelated to memory identity and is only used for dictionary/set lookups.


🔥 Extra trap (VERY IMPORTANT)

In interactive REPL, sometimes:

a = 390
b = 390
a is b   # MAY appear True

Why?

  • Compiler optimizations
  • Constant folding
  • Implementation detail

👉 Never rely on is for value comparison


If you want next, I can explain:

  • Why strings behave similarly
  • Why is works with None
  • Why this differs in PyPy
  • How garbage collection interacts here

Just say the word 👌

Great follow-up — this is exactly the right question 👍
Let’s answer it cleanly, correctly, and interview-safe.


Short answer (pin this in your head)

The references for a and b are stored in a namespace (symbol table), and the integer objects live in the heap.

No stack. No hash table.


Now the exact picture for:

a = 390
b = 390

1️⃣ Where are the objects stored?

Heap

Python stores ALL objects in the heap:

  • integers
  • strings
  • lists
  • functions
  • classes

So you get:

Heap:
  [ int object: 390 ]  ← object #1
  [ int object: 390 ]  ← object #2

These are two separate heap objects.


2️⃣ Where are the references (a and b) stored?

Namespace (symbol table)

Depending on scope:

ScopeWhere reference lives
Globalglobals() dict
Functionlocal namespace (fast locals array + dict view)
Classclass __dict__

Example:

a = 390
b = 390

At module level:

globals() = {
    'a': <ref to int(390)>,
    'b': <ref to int(390)>
}

Visual diagram (mental model)

globals()  (dictionary)
 ├── 'a' ─────────▶ int(390) [heap object #1]
 └── 'b' ─────────▶ int(390) [heap object #2]

3️⃣ What is NOT happening ❌

MythReality
Stored on stack❌ Python hides stack
Stored in hash table automatically❌ Only dict/set use hashing
Variables store values❌ Variables store references
Same hash ⇒ same memory❌ Hash ≠ identity

4️⃣ Why is fails here

a is b

Checks:

id(a) == id(b)

Since:

heap_object_1 ≠ heap_object_2

➡️ False


5️⃣ Why small ints are different

a = 100
b = 100

Python reuses a single cached heap object:

globals()
 ├── 'a' ───▶ int(100) [heap]
 └── 'b' ───▶ int(100) [heap]

➡️ a is b → True


6️⃣ Ultra-important interview line (gold 🥇)

In Python, names are stored in namespaces as references, and all objects live in the heap. Whether two names point to the same object depends on interning and implementation details.


7️⃣ One-line memory rule (remember forever)

Names live in namespaces.
Objects live in the heap.
is compares object identity, not values.


If you want, next I can show:

  • how function locals are stored differently (fast locals)
  • how id() maps to actual memory
  • how garbage collection cleans unused integers
  • why del a doesn’t delete the object immediately

You’re asking exactly the right questions — this is deep Python 👍

Excellent — this is core CPython internals territory.
I’ll explain all 4 points clearly, step-by-step, with correct mental models (no myths).


1️⃣ How function locals are stored differently (Fast Locals)

Global / class variables

Stored in a dictionary:

globals()['x']

Dict lookup → slower.


Function locals (special optimization 🚀)

Inside functions, CPython does NOT use a dict for every local access.

Instead it uses:

  • A fixed-size array called fast locals
  • Index-based access (much faster)

Example

def f():
    a = 10
    b = 20
    return a + b

Internally:

Local variable table:
index 0 → a
index 1 → b

Bytecode:

LOAD_FAST 0  # a
LOAD_FAST 1  # b

🔥 This is why:

a + b        # very fast
globals()['a'] + globals()['b']  # slower

Why locals() still shows a dict?

locals()

➡️ Python creates a temporary dict view of the fast locals array
(Not the real storage)


Key rule

Fast locals = array
Globals = dict


2️⃣ How id() maps to actual memory

In CPython only:

id(obj) == memory_address_of_obj

Example:

a = 390
print(id(a))  # e.g. 140291884530096

That number is the actual address of the object in RAM.


Proof (CPython)

import ctypes
ctypes.cast(id(a), ctypes.py_object).value is a
# True

⚠️ Other Python implementations:

  • PyPy → id() is NOT a memory address
  • Jython → abstract identifier

Interview-safe line

In CPython, id() returns the memory address of the object; in other implementations, it’s just a unique identifier.


3️⃣ How garbage collection cleans unused integers

Python uses two systems:

1️⃣ Reference Counting (primary)

Each object tracks:

refcount = number of references

Example:

a = 390   # refcount = 1
b = a     # refcount = 2
del a     # refcount = 1
del b     # refcount = 0 → destroyed immediately

2️⃣ Cyclic Garbage Collector (secondary)

Handles:

a = []
a.append(a)

Refcount ≠ 0 but object unreachable → GC detects cycles.


Integers specifically

  • Small ints (-5 to 256) are never freed
  • Larger ints are freed when:
    • refcount → 0
    • not referenced by internals

When freed?

  • Immediately (refcount hits zero)
  • OR delayed until GC run (cycles)

4️⃣ Why del a doesn’t delete the object immediately

Important rule:

del a

❌ does NOT delete the object
✅ deletes the reference name


Example

a = 390
b = a

del a

Memory state:

b ──▶ int(390)

Object still alive (refcount = 1).


Only when:

del b

Now:

refcount = 0 → object destroyed

Another example

x = [1, 2, 3]
y = x

del x

List is still alive via y.


⚠️ Extra subtlety (very important)

Even if refcount = 0:

  • CPython may reuse memory
  • OS may not reclaim immediately
  • Object destructor (__del__) may delay cleanup

🧠 Unified Mental Model (Save this)

Names → References → Heap Objects
  • del removes names
  • GC removes objects
  • is compares object identity
  • == compares value
  • Locals use fast arrays
  • Globals use dicts

🥇 Final Interview-Perfect Summary

Function locals in CPython are stored in a fast array rather than a dictionary for performance. The id() function returns the actual memory address of an object in CPython. Python primarily uses reference counting for garbage collection, supplemented by a cyclic garbage collector. The del statement removes a name binding, not the object itself, so an object is only destroyed when its reference count drops to zero.


If you want next, I can explain:

  • bytecode (LOAD_FAST, STORE_FAST)
  • frame objects and call stack
  • why closures behave differently
  • why mutable defaults are dangerous

You’re now thinking like a Python runtime engineer 🧠🔥

Perfect — this is deep CPython execution model now.
I’ll explain all 4 topics with mental models + bytecode-level clarity (interview-grade).


1️⃣ Python Bytecode: LOAD_FAST, STORE_FAST, etc.

Python code is first compiled into bytecode, which the Python Virtual Machine (PVM) executes.


Example

def f():
    a = 10
    b = 20
    return a + b

Disassemble:

import dis
dis.dis(f)

Output (simplified)

0 LOAD_CONST     1 (10)
2 STORE_FAST     0 (a)
4 LOAD_CONST     2 (20)
6 STORE_FAST     1 (b)
8 LOAD_FAST      0 (a)
10 LOAD_FAST     1 (b)
12 BINARY_ADD
14 RETURN_VALUE

Meaning of key opcodes

🔹 STORE_FAST

STORE_FAST index
  • Store value into fast locals array
  • Index-based (very fast)

🔹 LOAD_FAST

LOAD_FAST index
  • Read from fast locals array
  • No dictionary lookup

🔹 LOAD_GLOBAL

LOAD_GLOBAL name
  • Used for globals
  • Requires dict lookup → slower

Why this matters

a + b        # LOAD_FAST → fast
global_a + global_b  # LOAD_GLOBAL → slower

💡 This is why Python recommends local variables for performance.


2️⃣ Frame Objects & Call Stack

Every function call creates a frame object.


What is a frame?

A frame stores:

  • Local variables (fast locals array)
  • Global namespace reference
  • Bytecode instruction pointer
  • Operand stack
  • Reference to previous frame

Example

def outer():
    x = 10
    inner()

def inner():
    y = 20

outer()

Call stack (top → bottom)

Frame(inner)
Frame(outer)
Frame(<module>)

Frame lifecycle

  1. Function called → frame created
  2. Bytecode executed
  3. Function returns
  4. Frame destroyed (unless referenced)

Inspect current frame

import sys
sys._getframe()

⚠️ Used in debuggers, profilers, trace tools.


Key insight

Python does NOT use a C-style stack you can access — frames are heap objects, linked like a stack.


3️⃣ Why Closures Behave Differently

Closures capture variables, not values.


Example

def outer():
    x = 10
    def inner():
        return x
    return inner

f = outer()
print(f())   # 10

What really happens

  • x is not copied
  • It is stored in a cell object
  • Inner function holds reference to the cell
cell → x → 10

Proof

print(f.__closure__)

Late Binding Problem

funcs = []
for i in range(3):
    def f():
        return i
    funcs.append(f)

[f() for f in funcs]  # [2, 2, 2]

Why?

  • All functions reference same i
  • i evaluated at call time, not creation time

Fix

for i in range(3):
    def f(i=i):
        return i

Closure rule (memorize)

Closures capture variables, not values.


4️⃣ Why Mutable Default Arguments Are Dangerous

Default arguments are evaluated once, at function definition time.


Dangerous example

def add_item(item, lst=[]):
    lst.append(item)
    return lst

Calls:

add_item(1)  # [1]
add_item(2)  # [1, 2]
add_item(3)  # [1, 2, 3]

Why this happens

  • lst is created once
  • Stored in function.__defaults__
  • Shared across all calls

Proof

print(add_item.__defaults__)

Correct pattern

def add_item(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

Immutable defaults are safe

def f(x, n=10):  # safe
    return x + n

🔥 Unified Mental Model (SAVE THIS)

Source Code
   ↓
Bytecode
   ↓
Frame Object
   ↓
Fast Locals Array
   ↓
Heap Objects

🥇 Interview-Perfect Summary

Python compiles code into bytecode executed by the Python Virtual Machine. Local variables are accessed using fast array-based instructions like LOAD_FAST. Each function call creates a frame object that holds execution state. Closures capture variables via cell objects, leading to late binding behavior. Mutable default arguments are evaluated once at function definition time, causing shared state across calls if not handled carefully.


If you want next (advanced level 🔥):

  • Python GIL internals
  • Stack vs heap myths in Python
  • How exceptions unwind frames
  • How async/await changes frames
  • Memory leaks via closures

Just tell me 🚀

Excellent — this is advanced Python runtime & concurrency internals.
I’ll explain each topic with correct mental models, what’s real vs myth, and why it matters in production & interviews.


1️⃣ Python GIL Internals (Global Interpreter Lock)

What the GIL really is

The GIL is a mutex that allows only ONE thread to execute Python bytecode at a time in CPython.

It does NOT:

  • block I/O
  • affect multiprocessing
  • exist in PyPy/Jython in the same way

Why the GIL exists

CPython uses:

  • reference counting for memory management
  • refcount updates are not atomic

Without GIL:

Two threads increment/decrement refcount simultaneously → memory corruption

GIL = simpler + faster single-threaded performance


How the GIL works internally

Simplified loop:

while thread holds GIL:
    execute N bytecode instructions
    OR release on blocking I/O
    OR release after time slice

Key points:

  • Default switch interval ≈ 5ms
  • Checked between bytecode instructions
  • I/O operations release GIL (read, sleep, socket)

CPU-bound vs I/O-bound

TaskThreads help?
CPU-bound Python❌ No
I/O-bound Python✅ Yes
NumPy / Pandas✅ Yes (GIL released in C)
Multiprocessing✅ Yes

Interview one-liner 🥇

The GIL ensures thread safety of CPython’s memory model but prevents true parallel execution of Python bytecode.


2️⃣ Stack vs Heap Myths in Python

❌ Common myth

“Local variables live on the stack”

❌ WRONG in Python


Reality

ConceptPython reality
StackCall frames linked together
HeapALL objects live here
VariablesNames bound to objects

Frames themselves are heap objects.


What Python does have

Heap:
  - int objects
  - list objects
  - frame objects
  - function objects

Frames link together → behaves LIKE a stack

But:

  • no raw stack memory
  • no user control over stack allocation

Correct statement

Python has a call stack conceptually, but frames and objects are heap-allocated.


3️⃣ How Exceptions Unwind Frames

Example

def a():
    b()

def b():
    c()

def c():
    1 / 0

a()

What happens internally

  1. Exception raised in c
  2. Python looks for except in same frame
  3. None found → frame c destroyed
  4. Goes to frame b
  5. Same process
  6. Continues until:
    • handler found
    • OR reaches top → crash

Stack unwinding visualization

Frame(c)  ❌ destroyed
Frame(b)  ❌ destroyed
Frame(a)  ❌ destroyed
Frame(module) → exception printed

finally blocks

try:
    risky()
finally:
    cleanup()

finally always runs, even during unwinding.


Why this matters

  • resource cleanup
  • file/socket safety
  • DB connections

4️⃣ How async / await Changes Frames

Traditional functions

Call → Frame created → Execute → Destroy

Async functions

async def f():
    await g()

What changes?

  • Frame is SUSPENDED
  • Instruction pointer saved
  • Frame stored inside coroutine object
  • Event loop resumes it later

Coroutine lifecycle

Coroutine created
↓
Execution until await
↓
Suspended (frame kept alive)
↓
Resumed
↓
Finished

Key difference

Normal functionAsync function
Frame destroyed on returnFrame survives suspension
Linear executionCooperative multitasking
Stack-likeGraph of suspended frames

Why async scales

  • No OS threads
  • No context switching
  • Thousands of suspended frames in memory

Interview gold 🥇

Async functions suspend frames instead of destroying them, allowing cooperative multitasking without threads.


5️⃣ Memory Leaks via Closures (REAL PROBLEM)

Problem example

def make_handler():
    large_data = [1_000_000 items]
    def handler():
        return len(large_data)
    return handler

h = make_handler()

What happens

  • large_data stored in closure cell
  • Cell referenced by handler
  • large_data NEVER freed

Even if:

del make_handler

Leak pattern

Function → Closure → Cell → Large Object

Worse with globals + closures

handlers.append(handler)

Leak persists for entire program life.


How to fix

Option 1: Avoid closure

def handler(data):
    return len(data)

Option 2: Weak references

import weakref

Option 3: Explicit cleanup

handler.__closure__ = None

(advanced, rare)


Debugging leaks

  • gc.get_objects()
  • objgraph
  • tracemalloc

🧠 Unified Expert Mental Model (Save This)

Python = Heap-based runtime
GIL = Bytecode execution lock
Frames = Heap objects
Async = Suspended frames
Closures = Hidden references
GC = Refcount + cycle detector

🥇 Ultimate Interview Summary

CPython uses a Global Interpreter Lock to protect reference counting, preventing parallel bytecode execution. Python frames and objects are heap-allocated rather than stack-based. Exceptions unwind frames until a handler is found. Async functions suspend and resume frames instead of destroying them, enabling cooperative multitasking. Closures can unintentionally retain large objects, leading to memory leaks if not designed carefully.


If you want the final boss level 🔥 next:

  • GIL removal attempts (PEP 703)
  • Why Python threads still matter
  • Async vs threading vs multiprocessing
  • Debugging real memory leaks
  • CPython vs PyPy internals

Just say go 🚀

Great question — this is where the GIL myths end and real system design begins.
Let’s go practical + conceptual, the way it’s expected in senior interviews and real projects.


1️⃣ Why Python Threads STILL Matter (Despite the GIL)

Many people say:

“Python threads are useless because of the GIL”

This is wrong


What the GIL actually blocks

  • Only parallel execution of Python bytecode
  • It does NOT block:
    • I/O operations
    • C extensions that release the GIL
    • OS-level scheduling

Threads are PERFECT for I/O-bound work

Examples:

  • API calls
  • Database queries
  • File reads/writes
  • Network sockets
  • Waiting on external systems
import threading
import requests

def fetch(url):
    return requests.get(url)

threads = []
for url in urls:
    t = threading.Thread(target=fetch, args=(url,))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

Why this works:

  • While one thread waits for I/O → GIL released
  • Another thread runs → CPU not wasted

Threads + C Extensions = REAL parallelism

Libraries that release the GIL:

  • NumPy
  • Pandas
  • OpenCV
  • TensorFlow
  • PyTorch
# NumPy matrix multiplication
# runs in parallel on multiple cores

➡️ Threads matter a lot in data/ML workloads.


Threads are lightweight

  • Cheaper than processes
  • Shared memory (no serialization)
  • Faster startup

When threads are BAD

  • CPU-bound pure Python loops
  • Heavy numeric computation in Python code

Interview one-liner 🥇

Python threads are ideal for I/O-bound workloads and C-extension-heavy computation, even though they don’t provide parallelism for pure Python CPU-bound code.


2️⃣ Async vs Threading vs Multiprocessing (THE REAL COMPARISON)

This is one of the most important system design tables in Python.


Mental Model First

ModelHow it works
ThreadingOS threads, shared memory
AsyncSingle thread, event loop
MultiprocessingMultiple processes, separate memory

2.1 Async (asyncio)

Best for:

  • Massive I/O concurrency
  • 10k–100k simultaneous tasks
  • APIs, web servers, sockets

How it works:

  • One thread
  • Cooperative multitasking
  • Tasks yield control using await
async def fetch():
    await aiohttp.get(url)

Pros

✅ Extremely scalable
✅ No thread overhead
✅ Predictable performance

Cons

❌ CPU-bound work blocks everything
❌ Requires async-compatible libraries
❌ Harder mental model


2.2 Threading

Best for:

  • Moderate I/O concurrency
  • Blocking libraries
  • Legacy code
  • Mixed workloads
Thread(target=blocking_io)

Pros

✅ Simple mental model
✅ Works with blocking libraries
✅ Shared memory

Cons

❌ GIL limits CPU-bound work
❌ Context switching overhead
❌ Race conditions


2.3 Multiprocessing

Best for:

  • CPU-bound workloads
  • Parallel computation
  • Bypassing the GIL
from multiprocessing import Process

Pros

✅ True parallelism
✅ Full CPU utilization
✅ No GIL issues

Cons

❌ High memory usage
❌ Serialization overhead
❌ Slow process startup


🔥 The Ultimate Comparison Table

FeatureAsyncThreadingMultiprocessing
Uses multiple cores❌*
GIL affected
Best forMassive I/OBlocking I/OCPU-bound
MemoryLowMediumHigh
Context switchingMinimalMediumHigh
Shared memory
DebuggingHardMediumEasier
Scalability⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

* Threads can use multiple cores only if GIL is released (C extensions).


3️⃣ Real-World Decision Rules (VERY IMPORTANT)

Rule 1

If it’s CPU-bound → Multiprocessing


Rule 2

If it’s massive I/O (APIs, sockets) → Async


Rule 3

If it’s blocking I/O or legacy libs → Threading


Rule 4

If it uses NumPy/Pandas → Threads CAN scale


4️⃣ Real Production Architectures

Web servers (FastAPI)

  • Async for request handling
  • Thread pool for blocking I/O
  • Process workers (Gunicorn/Uvicorn)

Data engineering

  • Multiprocessing for CPU transforms
  • Threads for DB/API reads
  • Async for ingestion pipelines

ML pipelines

  • Multiprocessing for training
  • Threads inside NumPy / PyTorch
  • Async for serving

🥇 Interview-Perfect Final Answer

Python threads still matter because they efficiently handle I/O-bound tasks and leverage C extensions that release the GIL. Async programming excels at massive I/O concurrency with minimal overhead, while multiprocessing enables true parallelism for CPU-bound workloads. Choosing between them depends on whether the bottleneck is I/O, CPU, or library behavior.


If you want next (this is the ultimate level 🔥):

  • How FastAPI mixes async + threads internally
  • Why Gunicorn uses multiple workers
  • When async is actually slower
  • Hybrid designs (async + process pools)
  • PEP 703 (GIL removal future)

Just say next 🚀

\