Part 1: Variables and Memory
How Python actually stores things
When you write x = 5, Python does two things: it creates an integer object 5 somewhere in memory, and it creates a name x that points at it. If you then write y = x, Python does not copy the number — it just makes y point at the same object x already points at.
a = [1, 2, 3]
b = a # b now points at the SAME list object as a
print(id(a)) # some memory address, e.g. 140234099283776
print(id(b)) # the exact same address
print(a is b) # True — they are literally the same object
Now watch what happens if you change the list through one name:
b.append(4)
print(a) # [1, 2, 3, 4]
print(b) # [1, 2, 3, 4]
Both changed, because there was only ever one list — a and b are just two labels on it.
Compare this to making an actual copy:
c = a.copy() # creates a genuinely NEW list with the same contents
c.append(5)
print(a) # [1, 2, 3, 4] — unaffected
print(c) # [1, 2, 3, 4, 5]
print(a is c) # False — different objects now
== and is ask different questions:
a == c→ "do these look the same (same contents)?"a is c→ "are these literally the same object in memory?"
Mutable vs Immutable — the most important distinction in the language
Immutable (cannot be changed after creation — any "change" makes a brand new object): int, float, str, bool, tuple
Mutable (can be changed in place, without creating a new object): list, dict, set, and custom class instances by default
Strings feel like they should be changeable, but they are not:
s = "hello"
s[0] = "H" # TypeError: 'str' object does not support item assignment
Any string "modification" actually builds a new string and reassigns the name to it:
s = "hello"
s = s.upper() # a NEW string "HELLO" is created; s now points to it
# the original "hello" string still exists in memory
# briefly, until nothing points to it and it's cleaned up
Why this matters for function arguments
def try_change_string(text):
text = text.upper() # this just repoints the LOCAL name "text"
return text
original = "hello"
result = try_change_string(original)
print(original) # "hello" — the caller's variable never changed
print(result) # "HELLO" — you have to explicitly return the new value
def try_change_list(items):
items.append("new") # this MUTATES the actual list object in place
original = ["a", "b"]
try_change_list(original)
print(original) # ["a", "b", "new"] — changed, with no return needed!
The rule: reassigning a parameter inside a function never affects the caller's variable. Mutating a mutable object that was passed in does affect the caller, because there's only one object and both the caller and the function are looking at it.
The classic trap this causes: mutable default arguments
def add_item(item, my_list=[]): # DANGEROUS
my_list.append(item)
return my_list
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] <-- surprising! you'd expect [2]
Why: the empty list [] in the function signature is created exactly once, at the moment the function is defined — not each time it's called. Every call that doesn't supply its own list reuses that same original list object, and it keeps growing across calls.
The fix — use None as a sentinel, and create a fresh list inside the function body each call:
def add_item(item, my_list=None):
if my_list is None:
my_list = [] # fresh list, created fresh on THIS call
my_list.append(item)
return my_list
Nested mutability — shallow vs deep copies
matrix = [[0, 0], [0, 0]]
shallow = matrix.copy()
shallow[0][0] = 99
print(matrix) # [[99, 0], [0, 0]] <- original changed too!
.copy() only copies the outer list. The inner lists are still the same shared objects. To fully separate everything, nested levels included:
import copy
deep = copy.deepcopy(matrix)
deep[0][0] = 55
print(matrix) # unaffected this time
Dictionaries behave identically:
config = {"debug": True, "settings": {"retries": 3}}
shallow_copy = config.copy()
shallow_copy["debug"] = False
print(config["debug"]) # True — outer level is independent
shallow_copy["settings"]["retries"] = 99
print(config["settings"]["retries"]) # 99 — inner dict is still SHARED
Part 2: Functions
Positional and keyword arguments
def describe(name, age, city="Unknown"):
print(f"{name}, {age}, from {city}")
describe("Alice", 30) # positional
describe(name="Alice", age=30) # keyword — order irrelevant
describe(age=30, name="Alice") # same result as above
describe("Alice", city="NYC", age=30) # mixed — positional args must come first
You can force some parameters to only be passable by keyword, using a bare *:
def create_user(name, *, email, is_admin=False):
print(name, email, is_admin)
create_user("Bob", email="bob@example.com") # fine
create_user("Bob", "bob@example.com") # TypeError — email must be named
This prevents accidental mix-ups when a function has several parameters of the same type.
Variable-length arguments: *args and **kwargs
def show_args(*args, **kwargs):
print("positional extras:", args)
print("keyword extras:", kwargs)
show_args(1, 2, 3, name="Alice", age=30)
# positional extras: (1, 2, 3) <- collected into a tuple
# keyword extras: {'name': 'Alice', 'age': 30} <- collected into a dict
The reverse — unpacking a collection into separate arguments:
def add(a, b, c):
return a + b + c
nums = [1, 2, 3]
print(add(*nums)) # * unpacks the list -> add(1, 2, 3)
kwargs = {"a": 1, "b": 2, "c": 3}
print(add(**kwargs)) # ** unpacks the dict -> add(a=1, b=2, c=3)
Scope — how Python decides which variable you mean
Python looks up names using the order: Local → Enclosing → Global → Built-in (often called the LEGB rule).
x = "global"
def outer():
x = "outer"
def inner():
print(x) # not found locally, found in enclosing scope -> "outer"
inner()
print(x) # "outer"
outer()
print(x) # "global" — never touched
A common trap — if you assign to a name anywhere inside a function, Python treats that name as local for the entire function body, even on lines before the assignment:
counter = 0
def broken_increment():
counter = counter + 1
# Python sees "counter = ..." in this function and decides
# counter is LOCAL throughout the whole function.
# So the "counter" being read on the right side is the
# not-yet-assigned local one -> UnboundLocalError
Two ways to fix it:
def fixed_with_global():
global counter
counter = counter + 1 # explicitly refers to the global variable
def fixed_with_return():
return counter + 1 # cleaner — no mutation of outer state at all
counter = fixed_with_return() # caller decides what to do with the new value
In practice, prefer the return-based style. Reaching into outer scope with global makes code much harder to reason about as programs grow.
Closures — functions that remember their environment
def make_counter():
count = 0
def increment():
nonlocal count # like "global" but for the enclosing (not global) scope
count += 1
return count
return increment
counter1 = make_counter()
counter2 = make_counter()
print(counter1()) # 1
print(counter1()) # 2
print(counter1()) # 3
print(counter2()) # 1 <- completely separate state from counter1
Each call to make_counter() creates a fresh count, and the returned increment function "closes over" (remembers) that specific count forever — even though make_counter() itself already finished running. This is how you get private, persistent state without writing a class.
Part 3: Classes
What self really is
class Dog:
def __init__(self, name):
self.name = name
def bark(self):
return f"{self.name} says woof"
rex = Dog("Rex")
print(rex.bark()) # normal syntax
print(Dog.bark(rex)) # produces the EXACT same result
rex.bark() is just convenient syntax for Dog.bark(rex) — Python automatically passes the object before the dot as the first argument. self is simply the name given to that first parameter; there's no hidden magic.
__init__ initializes, it doesn't construct
The object already exists by the time __init__ runs — __init__ just fills in its starting attribute values.
class Dog:
species = "Canis familiaris" # CLASS attribute — one copy, shared by all instances
def __init__(self, name, age):
self.name = name # INSTANCE attribute — unique to each object
self.age = age
Think of the class as a template stored once, and each instance as having its own private dictionary of overrides. Accessing rex.species checks rex's own storage first; not finding it there, Python falls back to the class. Accessing rex.name finds it directly on the instance.
rex = Dog("Rex", 3)
buddy = Dog("Buddy", 5)
Dog.species = "Canis lupus familiaris" # change at the class level
print(rex.species) # updated — both instances see the change, since neither has its own override
rex.species = "Special" # this creates an INSTANCE-level override on rex only
print(rex.species) # "Special"
print(buddy.species) # unaffected — still the class-level value
The same mutable-default trap, in class form
class ShoppingCart:
items = [] # DANGER: this is a class attribute, shared by every instance
def add(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add("apple")
print(cart2.items) # ["apple"] <- leaked in from cart1!
self.items.append(...) doesn't create a new instance attribute — it mutates the one shared list, because no instance-level items was ever separately created. Fix:
class ShoppingCart:
def __init__(self):
self.items = [] # a fresh, independent list per instance
def add(self, item):
self.items.append(item)
Dunder methods — how your objects plug into Python's built-in syntax
None of Python's operators are hardcoded to work on custom objects. +, ==, <, len(), printing, truthiness in if — all of these work because your class defines the matching "dunder" (double underscore) method.
class Money:
def __init__(self, amount):
self.amount = amount
def __add__(self, other): # enables: money1 + money2
return Money(self.amount + other.amount)
def __eq__(self, other): # enables: money1 == money2
return self.amount == other.amount
def __lt__(self, other): # enables: money1 < money2
return self.amount < other.amount
def __repr__(self): # controls what print(money) shows
return f"Money(${self.amount})"
def __bool__(self): # controls behavior in "if money:"
return self.amount != 0
m1 = Money(10)
m2 = Money(20)
print(m1 + m2) # Money($30)
print(m1 < m2) # True
print(m1 == Money(10)) # True
if not Money(0):
print("no money") # this prints, since __bool__ returns False for Money(0)
Three decorators you'll see constantly
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self): # accessed like a plain attribute, no parentheses
return 3.14159 * self.radius ** 2
@classmethod
def from_diameter(cls, diameter): # alternate way to construct a Circle
return cls(diameter / 2)
@staticmethod
def is_valid_radius(r): # doesn't need self or cls — just grouped here logically
return r > 0
c = Circle(5)
print(c.area) # 78.53975 -- notice: no parentheses used
c2 = Circle.from_diameter(10)
print(c2.radius) # 5.0
print(Circle.is_valid_radius(-1)) # False
@property— call a method without(), as if it were a plain attribute (useful for computed values)@classmethod— receives the class itself (cls) instead of an instance; typically used for alternate constructors@staticmethod— receives neitherselfnorcls; it's a plain function grouped inside the class for organization
Inheritance and super()
class Puppy(Dog):
def __init__(self, name):
super().__init__(name, age=0) # runs Dog's __init__ first
self.is_puppy = True
super() gives you a handle to the parent class, letting you build on top of its behavior instead of rewriting it from scratch.
Part 4: Type Hints
Type hints look like they should enforce types, but by default, plain Python ignores them completely at runtime:
def add(a: int, b: int) -> int:
return a + b
print(add("hello", "world")) # runs fine, prints "helloworld" — no error!
So what are they for?
Editor/IDE autocomplete and inline warnings
Static analysis tools (like
mypy) that check your code before running itDocumentation that stays accurate, since it lives in the code itself
from typing import Optional, List, Dict, Union
def get_name() -> Optional[str]: # means: returns a str, OR returns None
return None
def get_scores() -> List[int]: # a list of ints
return [90, 85, 77]
def get_user() -> Dict[str, str]: # a dict with string keys and string values
return {"name": "Alice"}
def process(value: Union[int, str]) -> str: # accepts either type
return str(value)
# Python 3.10+ shorthand:
def get_name_new() -> str | None:
return None
Some tools do enforce type hints at runtime — most notably Pydantic, which reads them and validates incoming data against them:
from pydantic import BaseModel
from typing import List, Optional
class Address(BaseModel):
street: str
city: str
class User(BaseModel):
name: str
age: int
tags: List[str] = []
address: Optional[Address] = None
Constructing a User(age="not a number") here would immediately raise a validation error — unlike plain Python functions, which silently let mismatched types through.
Part 5: async/await
The core idea: async is about not sitting idle while waiting, not about doing multiple things at the literal same instant. Only one line of Python code ever executes at a time — there's no multi-core magic here. The win comes purely from not blocking during waits (like network requests or database calls).
Synchronous version — everything waits in line
import time
def fetch_user():
time.sleep(2) # simulates a 2-second network call — freezes the whole program
return "user data"
def fetch_orders():
time.sleep(2)
return "orders data"
start = time.time()
fetch_user()
fetch_orders()
print(f"Took {time.time() - start:.1f}s") # Took 4.0s
Async version — waits overlap
import asyncio
import time
async def fetch_user():
await asyncio.sleep(2)
return "user data"
async def fetch_orders():
await asyncio.sleep(2)
return "orders data"
async def main():
start = time.time()
results = await asyncio.gather(fetch_user(), fetch_orders())
print(f"Took {time.time() - start:.1f}s") # Took 2.0s
print(results)
asyncio.run(main())
Step by step:
fetch_user()runs until it hitsawait asyncio.sleep(2), then pauses, handing control back to the event loop (a scheduler managing all this).The event loop, now free, starts
fetch_orders(), which also pauses at its ownawait.Both are now "parked" waiting on their timers simultaneously.
After 2 seconds (not 4), both resume, return their values, and
gathercollects them into a list.
The most common async bug: forgetting await
async def get_data():
await asyncio.sleep(1)
return {"status": "ok"}
async def main():
result = get_data() # forgot await!
print(result) # <coroutine object get_data at 0x7f...>
print(result["status"]) # TypeError — this isn't the actual data yet
result = await get_data() # correct — actually runs and waits for the result
print(result["status"]) # "ok"
Calling an async def function without await doesn't run it — it just hands you back an unexecuted coroutine object.
async for and async with
async def list_users(db):
async with db.client.start_session() as session: # async context manager
cursor = db.users.find({})
async for user in cursor: # async iteration
print(user)
Same shapes as regular with and for, but built for operations where each step might need to wait on I/O — such as fetching the next result from a database query over the network.
Part 6: Virtual Environments
By default, Python installs packages into one global location shared by everything on your machine. This becomes a problem the moment two different projects need different versions of the same library.
A virtual environment ("venv") is a self-contained, isolated copy of Python's package directory for a single project.
mkdir my_project && cd my_project
python -m venv venv
# Creates a venv/ folder: its own python, pip, and package storage
which python
# /usr/bin/python3 <- still the SYSTEM python, venv isn't active yet
source venv/bin/activate
# Shell prompt now shows (venv)
which python
# /Users/you/my_project/venv/bin/python <- now rewired to the venv's copy
pip install fastapi uvicorn
# Installs ONLY inside venv/ — completely invisible to other projects
pip freeze > requirements.txt
# Records exact installed versions:
# fastapi==0.115.0
# uvicorn==0.30.1
deactivate
# Prompt returns to normal; "python" points back to the system version
On another machine (or a deployment server), nobody copies the venv/ folder itself — it gets rebuilt from the requirements file:
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt # reproduces the exact same set of packages
requirements.txt is the one artifact that travels with your project (usually committed to git); venv/ itself is added to .gitignore, since it's just a large, easily-regeneratable, machine-specific folder.
About the author
- MERN || FARM || System Design || Devops
Comments
Join the discussionNo comments yet. Be the first to comment.