Skip to main content

Python Fundamentals

Updated 14 min readMarkdown
Part of seriesFARM+AppliedAI

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 neither self nor cls; 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 it

  • Documentation 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:

  1. fetch_user() runs until it hits await asyncio.sleep(2), then pauses, handing control back to the event loop (a scheduler managing all this).

  2. The event loop, now free, starts fetch_orders(), which also pauses at its own await.

  3. Both are now "parked" waiting on their timers simultaneously.

  4. After 2 seconds (not 4), both resume, return their values, and gather collects 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

No comments yet. Be the first to comment.

FARM+AppliedAI

Part 1 of 1

farm stack and applied ai notes