python · from zero to real

Sit Down.
Let Me Teach You Python.

Not definitions. Not documentation. A senior engineer writing to you like you're sitting across the table — because that's the only way any of this sticks.

~60 min read · Python 3.10+ · for curious juniors

Hey. Before we start — I want you to throw away whatever you've been told about "learning Python." The tutorials that say "print hello world, now you're a programmer!" Those are fine for getting started, but you're past that now. You want to actually know the language.

What I'm going to give you is what I wish someone had given me five years into my career when I realized I'd been using Python daily and still didn't actually understand it. I'm going to explain syntax not as a list of rules but as decisions that made sense for a reason. I'm going to walk you through logic-building, OOP, and builtins — but always with the "why" that books skip.

Ask questions in your head as you read. The dialogue boxes are me answering the questions you'll definitely have. Let's go.

— Your senior, finally explaining things properly

What we'll cover
01 / Foundations

Syntax That Actually Makes Sense

Python was designed by Guido van Rossum with one core philosophy: code is read far more often than it is written. That's not a slogan. It explains every syntax decision in the language. Indentation instead of braces? Because indentation is what humans do anyway — Python just enforces it. No semicolons? Because line breaks are structurally meaningful. Colons after if, def, for? They signal "a block follows" visually.

# Python's syntax reads like pseudocode — intentionally

# Indentation IS the structure (4 spaces, always)
if temperature > 100:
    print("Boiling!")      # this belongs to the if
    print("Watch out")    # so does this
print("Done checking")  # this does NOT — back to outer scope

# Multiple assignment — tuple unpacking under the hood
a, b = 10, 20          # a=10, b=20
a, b = b, a            # swap! Python evaluates right side fully first

# Chained comparisons — Python supports this, most languages don't
if 0 < score <= 100:    # equivalent to: 0 < score AND score <= 100
    print("Valid score")

# Walrus operator := (Python 3.8+) — assign AND use in one expression
if n := len(data):     # assigns len(data) to n, evaluates truthiness
    print(f"Data has {n} items")

# Truthiness: Python tests objects, not just booleans
# Falsy: 0, 0.0, '', [], {}, set(), None, False
# Truthy: everything else
if items:               # idiomatic — don't write: if len(items) > 0
    process(items)
JUNIORWhy does Python use indentation instead of curly braces like every other language?
SENIORBecause good programmers indent anyway. Python removes the possibility of mismatches between visual structure and actual structure. In C you can have code that looks like it's inside an if but actually isn't, because someone forgot braces. Python makes that impossible. The indentation is the truth.

The semicolon question

You can put semicolons at the end of Python lines. They do nothing. You can put multiple statements on one line with semicolons (a = 1; b = 2). Please don't. Python's philosophy is one logical thing per line. The language allows semicolons for historical compatibility, not because it endorses them.

Comments and docstrings

# Single line comment — use for WHY, not WHAT
# Bad:  x = x + 1  # increment x
# Good: x = x + 1  # retry_count, max 3 before abort

def calculate_tax(income, rate):
    """
    Calculate income tax given gross income and flat rate.

    Args:
        income (float): Gross annual income in INR
        rate (float): Tax rate as decimal (e.g., 0.3 for 30%)

    Returns:
        float: Tax amount
    """
    return income * rate

# Docstrings are accessible at runtime via __doc__
print(calculate_tax.__doc__)
02 / Data Types

Data Types: What They Really Are

In Python, every value you ever work with is an object. The number 5 is an object. The string "hello" is an object. True is an object. Even None is an object. This means they all have a type, a value, and methods you can call on them.

# The core types
age    = 25           # int   — whole numbers, arbitrary precision
height = 5.9          # float — 64-bit floating point (IEEE 754)
name   = "Veda"       # str   — immutable sequence of Unicode characters
active = True         # bool  — subclass of int! True==1, False==0
data   = None         # NoneType — the absence of a value, singleton

# Python integers have ARBITRARY PRECISION — no overflow
huge = 2 ** 1000       # works fine. Try that in C.
print(type(huge))     # <class 'int'>

# float pitfall: binary representation error
print(0.1 + 0.2)      # 0.30000000000000004  NOT 0.3
# Fix with decimal for money/precision work:
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2'))  # 0.3  ← exact

# Type checking
print(isinstance(True, int))   # True — bool IS an int
print(isinstance(True, bool))  # True
print(type(True) is int)       # False — exact type check
Important: is vs ==
Use == to compare values. Use is to check if two names point to the exact same object in memory. Always write x is None, never x == None. Why? Because a class could override __eq__ to do weird things when compared to None. is None is always safe and unambiguous.

Type conversion

# Explicit conversion (casting)
int("42")         # 42
int(3.9)          # 3   — truncates, does NOT round
float("3.14")     # 3.14
str(100)          # "100"
bool(0)           # False
bool("")          # False
bool([])          # False
bool([0])         # True  ← list is non-empty, even if element is falsy

# int() with base argument
int("ff", 16)     # 255 — parse hex
int("1010", 2)   # 10  — parse binary

# The other direction
hex(255)         # '0xff'
bin(10)          # '0b1010'
oct(8)           # '0o10'
03 / Control Flow

Control Flow & Real Logic

Control flow is how you tell the program "make a decision" and "do this repeatedly." Every program you will ever write is made of these. The keywords are few. The patterns are infinite.

# if / elif / else — exactly one branch runs
score = 72
if score >= 90:
    grade = 'A'
elif score >= 75:
    grade = 'B'
elif score >= 60:
    grade = 'C'
else:
    grade = 'F'

# Ternary (conditional expression) — for simple assignments
result = "pass" if score >= 50 else "fail"

# match/case (Python 3.10+) — structural pattern matching
command = "quit"
match command:
    case "quit" | "exit":
        shutdown()
    case "help":
        show_help()
    case _:            # wildcard, like default in switch
        print(f"Unknown: {command}")

Loops: for and while

# for loop iterates over ANY iterable — list, string, range, file, generator
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# When you need the index too — use enumerate(), not range(len())
for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")    # 1. apple, 2. banana...

# Iterate two lists together — zip()
names  = ["Alice", "Bob"]
scores = [88, 94]
for name, score in zip(names, scores):
    print(f"{name}: {score}")

# while — use when you don't know the number of iterations
attempts = 0
while attempts < 3:
    response = input("Password: ")
    if response == "secret":
        break            # exit loop immediately
    attempts += 1
else:                    # runs if loop completed WITHOUT break
    print("Locked out")

# continue — skip this iteration, go to next
for n in range(10):
    if n % 2 == 0:
        continue        # skip even numbers
    print(n)            # prints only odd numbers
Gotcha
The for...else and while...else constructs confuse everyone. The else block runs when the loop finishes normally (no break). It's like saying "if the loop ran to completion." Most people use a flag variable instead because this construct is unfamiliar — that's fine. But know it exists.
04 / Functions

Functions: Not Just Code Blocks

A function in Python is a first-class object. That means you can assign it to a variable, pass it to another function, return it from a function, and store it in a list. This is not a feature — it's the foundation of decorators, callbacks, higher-order programming, and most of what makes Python elegant.

# Basic function — def creates a function object and binds it to the name
def greet(name, greeting="Hello"):   # default argument
    return f"{greeting}, {name}!"

greet("Veda")                # positional: "Hello, Veda!"
greet(greeting="Hi", name="Bob")  # keyword: order doesn't matter

# *args — collect extra positional arguments into a tuple
def total(*numbers):
    return sum(numbers)

total(1, 2, 3, 4)   # numbers = (1, 2, 3, 4)

# **kwargs — collect extra keyword arguments into a dict
def show_info(**details):
    for key, value in details.items():
        print(f"{key}: {value}")

show_info(name="Alice", age=30, city="Pune")

# / and * in signatures — positional-only and keyword-only
def connect(host, port, /, *, timeout=30):
    # host, port must be positional (can't do connect(host="x"))
    # timeout must be keyword (can't do connect("x", 80, 60))
    pass

# Functions are objects
operations = {
    'add': lambda a, b: a + b,
    'mul': lambda a, b: a * b,
}
print(operations['add'](3, 4))   # 7

# Higher-order function
def apply_twice(func, value):
    return func(func(value))

apply_twice(lambda x: x * 2, 3)   # 12

Return values and None

Every function in Python returns something. If you don't have a return statement (or just write return with no value), the function returns None. This trips people up: result = my_list.sort()result is None because .sort() sorts in-place and returns nothing. If you want a sorted copy, use sorted(my_list).

Type hints
Python 3.5+ supports type annotations. They do nothing at runtime — Python won't enforce them. But they're invaluable for tooling (mypy, PyCharm, VS Code will catch errors) and for communicating intent. def add(a: int, b: int) -> int: — any reader immediately knows what this function expects and returns. Use them in every function you write professionally.
05 / Strings

Strings: More Than Just Text

A Python string is an immutable sequence of Unicode characters. Every character has a code point (an integer). Strings support slicing, iteration, membership testing, and dozens of methods. Understanding strings deeply is non-negotiable because real programs deal with text constantly.

# Slicing — [start:stop:step], stop is EXCLUSIVE
s = "Hello, World!"
s[0:5]       # 'Hello'
s[7:]         # 'World!'
s[:5]         # 'Hello'
s[::2]        # 'Hlo ol!'  — every 2nd character
s[::-1]       # '!dlroW ,olleH'  — reversed

# f-strings (Python 3.6+) — the right way to format strings
name = "Veda"
score = 97.5
print(f"Student: {name}, Score: {score:.1f}")  # 1 decimal
print(f"Score: {score:>10.2f}")               # right-aligned, width 10
print(f"{name!r}")                            # 'Veda' — repr() inside f-string
print(f"{name = }")                           # name = 'Veda'  — debug mode (3.8+)

# Important string methods
text = "  Hello World  "
text.strip()                 # "Hello World"    — remove whitespace
text.lower()                 # "  hello world  "
text.upper()                 # "  HELLO WORLD  "
text.replace("World", "Python")  # "  Hello Python  "
"a,b,c".split(",")           # ['a', 'b', 'c']
",".join(["a", "b", "c"])    # "a,b,c"

# Checking
"hello".startswith("he")   # True
"hello".endswith("lo")     # True
"ell" in "hello"           # True
"hello".isdigit()          # False
"123".isdigit()            # True

# String concatenation performance
# BAD — creates a new string object every + operation
result = ""
for word in words:
    result += word    # O(n²) — new string each time!

# GOOD — build a list, join once at the end
result = "".join(words)   # O(n)

Raw strings, byte strings, multiline

r"C:\Users\name"       # raw string — backslashes not treated as escape
b"binary data"         # bytes object — sequence of integers 0-255
"""
Multiline
string
"""                     # triple quotes — literal newlines included

# encode/decode
"café".encode('utf-8')      # b'caf\xc3\xa9'  — str → bytes
b'caf\xc3\xa9'.decode('utf-8')  # 'café'  — bytes → str
06 / Collections

Lists, Dicts, Sets, Tuples:
The Real Differences

TypeOrderedMutableDuplicatesUse when
listYesYesYesSequence of items, order matters, may grow
tupleYesNoYesFixed collection, function return values, dict keys
dictYes (3.7+)YesKeys: NoKey→value lookup, named data
setNoYesNoMembership testing, deduplication, set math
# ── LIST ──
nums = [3, 1, 4, 1, 5]
nums.append(9)          # add to end: O(1)
nums.insert(0, 0)       # insert at index: O(n) — expensive!
nums.pop()              # remove from end: O(1)
nums.pop(0)             # remove from front: O(n) — expensive!
nums.sort()             # in-place sort (Timsort), returns None
sorted(nums)            # returns new sorted list, original unchanged
nums.sort(key=lambda x: -x)  # sort descending

# If you need to pop from the front often, use deque:
from collections import deque
q = deque([1, 2, 3])
q.appendleft(0)    # O(1)
q.popleft()         # O(1)

# ── DICT ──
person = {"name": "Alice", "age": 30}
person["city"] = "Pune"          # add key
person.get("email", "N/A")      # safe get with default (no KeyError)
person.setdefault("scores", [])  # add key only if it doesn't exist

# Merging dicts (Python 3.9+)
defaults = {"timeout": 30, "retries": 3}
config   = {"timeout": 60, "host": "localhost"}
merged   = defaults | config   # config values override defaults

# Iterating — don't use .keys() or .values() alone when you need both
for key, value in person.items():   # most common pattern
    print(f"{key}: {value}")

# ── SET ──
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a & b     # {3, 4}      — intersection
a | b     # {1,2,3,4,5,6} — union
a - b     # {1, 2}      — difference
a ^ b     # {1, 2, 5, 6} — symmetric difference

# O(1) membership testing — much faster than list for large data
valid_users = {"alice", "bob", "charlie"}   # set, not list
if username in valid_users:    # O(1). In a list this is O(n).
    pass
JUNIORWhen should I use a tuple instead of a list?
SENIORWhen the collection shouldn't change. Returning multiple values from a function: return x, y — that's a tuple. Coordinates: (lat, lon) — tuple. Database row data — tuple. Anything where "this is a fixed record" is semantically true. Tuples are also faster to create and iterate, and they can be used as dictionary keys (lists can't, because lists are mutable).
07 / Problem Solving

Thinking in Logic

This is the section most tutorials skip entirely. Syntax is easy. Logic is hard. Logic is: given a problem, how do you break it into steps your program can follow? Let's build this with real problems.

# Problem: find all duplicates in a list
# Junior approach: nested loops
def find_dupes_slow(items):
    dupes = []
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            if items[i] == items[j] and items[i] not in dupes:
                dupes.append(items[i])
    return dupes   # O(n³) — terrible for large data

# Senior approach: use a set to track seen items
def find_dupes_fast(items):
    seen = set()
    dupes = set()
    for item in items:
        if item in seen:
            dupes.add(item)
        seen.add(item)
    return list(dupes)   # O(n)

# Problem: group items by property
students = [
    ("Alice", "A"), ("Bob", "B"), ("Carol", "A"), ("Dave", "B")
]

# Use defaultdict to avoid "if key not in dict" boilerplate
from collections import defaultdict

by_grade = defaultdict(list)
for name, grade in students:
    by_grade[grade].append(name)
# {'A': ['Alice', 'Carol'], 'B': ['Bob', 'Dave']}

# Problem: flatten nested list one level
nested = [[1, 2], [3, 4], [5]]
flat = [x for sub in nested for x in sub]  # [1, 2, 3, 4, 5]

Algorithm thinking: two-pointer, sliding window

# Sliding window: max sum of k consecutive elements
def max_subarray_sum(arr, k):
    window_sum = sum(arr[:k])       # first window
    max_sum = window_sum

    for i in range(len(arr) - k):
        # slide: subtract element leaving, add element entering
        window_sum += arr[i + k] - arr[i]
        max_sum = max(max_sum, window_sum)

    return max_sum   # O(n) — much better than O(n*k)
"The best code doesn't show how clever you are. It shows how clearly you understood the problem."
08 / Scope

Scope & Namespaces:
Where Names Live

Every name in Python exists in a namespace — a mapping from name to object. When Python encounters a name, it searches namespaces in this order: Local → Enclosing → Global → Built-in (LEGB). Understanding this eliminates an entire category of bugs.

x = "global"

def outer():
    x = "enclosing"

    def inner():
        # x is not defined locally — Python looks outward
        print(x)        # "enclosing" — found in enclosing scope

    inner()

outer()
print(x)            # "global" — inner() didn't change this

# global and nonlocal keywords let you rebind outer names
counter = 0

def increment():
    global counter     # without this, would create a LOCAL counter
    counter += 1

# Avoid global — it makes code hard to test and reason about.
# Instead, use a class or pass/return values explicitly.

# LEGB in action
len = "oops"        # you can shadow builtins — please don't
print(len([1,2,3])) # TypeError: 'str' object is not callable
del len             # restore access to built-in len
09 / Built-ins

Built-ins You Should
Know Cold

Python has 68 built-in functions and a handful of built-in types. Most tutorials teach you five. Here are the ones that actually matter in real work, with what they actually do.

# ── sorted() — returns new sorted list, works on any iterable
words = ["banana", "apple", "cherry"]
sorted(words)                          # alphabetical
sorted(words, key=len)                # by length
sorted(words, key=lambda w: w[-1])    # by last character
sorted(words, reverse=True)           # descending

# ── map() and filter() — functional transforms
nums = [1, 2, 3, 4, 5]
list(map(lambda x: x**2, nums))   # [1, 4, 9, 16, 25]
list(filter(lambda x: x%2==0, nums)) # [2, 4]
# In modern Python, list comprehensions are preferred:
[x**2 for x in nums]
[x for x in nums if x%2==0]

# ── zip() — pair elements from multiple iterables
keys   = ["a", "b", "c"]
values = [1, 2, 3]
dict(zip(keys, values))   # {'a': 1, 'b': 2, 'c': 3}
# zip stops at shortest iterable. Use itertools.zip_longest to pad.

# ── enumerate() — always use instead of range(len())
for i, val in enumerate(["a", "b", "c"], start=1):
    print(i, val)   # 1 a, 2 b, 3 c

# ── min() / max() with key
students = [{"name":"A", "score":82}, {"name":"B", "score":94}]
best = max(students, key=lambda s: s['score'])  # {'name': 'B', ...}

# ── any() / all() — logical reduction
scores = [80, 72, 91, 65]
all(s >= 50 for s in scores)   # True — all passed
any(s >= 90 for s in scores)   # True — at least one distinction

# ── vars(), dir(), type(), id() — introspection
dir("")           # all attributes of string type
vars(some_obj)    # same as some_obj.__dict__
hasattr(obj, "x")  # True/False — safe check before getattr
getattr(obj, "x", "default")  # get attr or default

# ── repr() vs str()
# str() → human-readable (used in print())
# repr() → unambiguous, for debugging (used in the REPL)
print(str("hello"))    # hello
print(repr("hello"))   # 'hello'  ← shows it's a string
10 / OOP

Object-Oriented Python:
The Real Way to Think About It

OOP is not about making classes. Plenty of bad code is full of classes. OOP is about grouping data and behaviour that belong together so that the code reflects the domain. When you model a bank account, the balance and the deposit/withdraw operations belong together — that's a class. When something is just a utility function, it doesn't need to be in a class.

class BankAccount:
    """Represents a single bank account."""

    interest_rate = 0.05   # class variable — shared by ALL instances

    def __init__(self, owner, initial_balance=0.0):
        # instance variables — unique to each instance
        self.owner = owner
        self._balance = initial_balance    # _ means "internal, handle with care"
        self._transactions = []

    @property
    def balance(self):
        return self._balance               # read-only from outside

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount
        self._transactions.append(('deposit', amount))
        return self   # fluent interface: allows account.deposit(100).deposit(50)

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        self._transactions.append(('withdrawal', amount))
        return self

    @classmethod
    def from_dict(cls, data):
        # Alternative constructor — factory pattern
        return cls(data['owner'], data['balance'])

    @staticmethod
    def validate_pin(pin):
        # doesn't need self or cls — utility function logically grouped here
        return len(pin) == 4 and pin.isdigit()

    def __repr__(self):
        return f"BankAccount(owner={self.owner!r}, balance={self._balance:.2f})"

    def __str__(self):
        return f"{self.owner}'s account: ₹{self._balance:,.2f}"

Inheritance — IS-A relationship

class SavingsAccount(BankAccount):
    def __init__(self, owner, initial_balance=0):
        super().__init__(owner, initial_balance)  # always call super().__init__
        self.min_balance = 1000

    def withdraw(self, amount):               # override parent method
        if self._balance - amount < self.min_balance:
            raise ValueError(f"Must maintain minimum balance of {self.min_balance}")
        return super().withdraw(amount)         # call parent's withdraw for rest

    def apply_interest(self):
        interest = self._balance * self.interest_rate
        return self.deposit(interest)

Composition vs Inheritance

The most important OOP decision you'll make is: inherit or compose? Inheritance models IS-A. Composition models HAS-A. A Car IS-A Vehicle → inherit. A Car HAS-A Engine → composition (store an Engine object). Favour composition. Deep inheritance trees are rigid and hard to change.

# Composition — Car HAS-A Engine, not IS-A Engine
class Engine:
    def __init__(self, horsepower):
        self.hp = horsepower
        self.running = False
    def start(self): self.running = True

class Car:
    def __init__(self, make, engine: Engine):
        self.make = make
        self.engine = engine    # HAS-A — engine is a component

    def start(self):
        self.engine.start()   # delegate to the component
        return f"{self.make} started"
Dunder methods = making your class feel native
__len__ makes len(obj) work. __iter__ makes for x in obj work. __getitem__ makes obj[key] work. __contains__ makes x in obj work. Implement these and your class integrates seamlessly with all Python built-ins, for loops, and comprehensions — no special handling needed.
11 / Errors

Errors & Exceptions:
Handle Them Like a Pro

Errors are not embarrassing. They're information. Python's exception system is a first-class feature for communicating what went wrong. The amateur approach: wrap everything in except Exception and silently pass. The professional approach: catch specific exceptions you expect, let unexpected ones propagate so you know when something truly breaks.

# Exception hierarchy (important to know)
# BaseException
#   ├── SystemExit, KeyboardInterrupt, GeneratorExit  (don't catch these)
#   └── Exception
#       ├── ValueError, TypeError, KeyError, IndexError
#       ├── AttributeError, NameError, NotImplementedError
#       ├── OSError (IOError, FileNotFoundError, PermissionError)
#       └── ... hundreds more, all subclass Exception

try:
    result = int(user_input)     # may raise ValueError
    data = items[result]         # may raise IndexError
except ValueError:
    print("Please enter a number")
except IndexError:
    print("Number out of range")
except (TypeError, AttributeError) as e:
    print(f"Unexpected type error: {e}")
else:
    print("Success!")          # runs only if NO exception occurred
finally:
    cleanup()                  # ALWAYS runs — with or without exception

# Custom exceptions — inherit from appropriate base
class InsufficientFundsError(ValueError):
    def __init__(self, amount, balance):
        self.amount = amount
        self.balance = balance
        super().__init__(
            f"Cannot withdraw {amount}: only {balance} available"
        )

# Raising with context — exception chaining
try:
    data = json.loads(raw)
except json.JSONDecodeError as e:
    raise ValueError(f"Config file is malformed") from e
    # "from e" preserves the original traceback as __cause__
Never do this
except: pass — this silently swallows ALL exceptions including KeyboardInterrupt (Ctrl+C) and SystemExit. Also bad: except Exception: pass — swallows everything and gives you zero information about what broke. If you must suppress, be specific and log it: except ValueError as e: logger.warning("Expected error: %s", e)
12 / Files

Files & I/O: Always Use Context Managers

# The with statement guarantees file closure even on exception
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()          # read entire file
    # f.readline()              # one line
    # f.readlines()             # list of lines (including \n)
    # for line in f:            # iterate line by line (memory efficient)

# Writing
with open("output.txt", "w", encoding="utf-8") as f:
    f.write("Hello\n")
    f.writelines(["line1\n", "line2\n"])

# Append
with open("log.txt", "a") as f:
    f.write("New entry\n")

# JSON — most common data format you'll work with
import json

data = {"name": "Alice", "scores": [88, 92, 76]}

with open("data.json", "w") as f:
    json.dump(data, f, indent=2)          # write to file

json_str = json.dumps(data)               # to string
parsed   = json.loads('{"key": "val"}')  # from string

# pathlib — the modern way to work with file paths
from pathlib import Path

p = Path("data") / "processed" / "output.csv"   # cross-platform
p.parent.mkdir(parents=True, exist_ok=True)   # create dirs
p.write_text("data")
print(p.exists(), p.suffix, p.stem)      # True, '.csv', 'output'

# Iterate all CSV files in a directory
for csv_file in Path("data").glob("*.csv"):
    print(csv_file.name)
13 / Comprehensions

Comprehensions & Generators:
Python's Power Tools

# List comprehension — create list in one readable expression
squares = [x**2 for x in range(10)]
evens   = [x for x in range(20) if x % 2 == 0]

# Dict comprehension
word_lengths = {word: len(word) for word in ["apple", "fig", "mango"]}
# {'apple': 5, 'fig': 3, 'mango': 5}

# Set comprehension
unique_lengths = {len(w) for w in ["apple", "fig", "mango"]}
# {3, 5}

# Nested comprehension — read inner loop first, then outer
matrix = [[1,2],[3,4],[5,6]]
flat = [x for row in matrix for x in row]
# [1, 2, 3, 4, 5, 6]

# Generator expression — () instead of [] — lazy, no list in memory
total = sum(x**2 for x in range(1_000_000))  # O(1) memory

# Generator function
def read_in_chunks(filepath, chunk_size=1024):
    with open(filepath, 'r') as f:
        while chunk := f.read(chunk_size):
            yield chunk    # yields one chunk at a time, suspends
                           # processes a 10GB file with constant memory

# When to use list vs generator expression:
# list  — when you need to iterate multiple times, need len(), need indexing
# gen   — when you only need to iterate once, data might be large
14 / Modules

Modules & Packages:
How Python Finds Your Code

# A module is just a .py file. A package is a directory with __init__.py.
#
# my_package/
#   __init__.py       ← makes it a package
#   utils.py
#   models/
#     __init__.py
#     user.py

# Import styles
import math                         # import module — use as math.sqrt()
from math import sqrt, pi           # import names directly
from math import sqrt as sq        # alias to avoid name conflicts
import numpy as np                  # standard alias (convention)
from . import utils                 # relative import (inside a package)
from ..models import user           # two levels up, then models

# if __name__ == "__main__" — run only when executed directly, not imported
def main():
    print("Running!")

if __name__ == "__main__":
    main()   # won't run when someone does: from mymodule import something

# __all__ — control what `from mymodule import *` exposes
__all__ = ["PublicClass", "public_function"]
# Names starting with _ are considered private by convention (not enforced)
JUNIORWhat's actually in __init__.py?
SENIORWhatever you want to expose when someone does import your_package. It runs when the package is first imported. Many projects keep it empty. Others use it to re-export key names so users don't need to know the internal file structure: from .models import User, Product in __init__.py means callers can write from my_package import User instead of from my_package.models import User.
15 / Standard Library

Standard Library:
The Battery Pack You Already Have

Python's tagline is "batteries included." The standard library is enormous. Here are the modules every Python programmer reaches for constantly:

# ── collections ── beyond basic dict and list
from collections import Counter, defaultdict, namedtuple, OrderedDict

Counter(["a", "b", "a", "c", "a"])   # Counter({'a': 3, 'b': 1, 'c': 1})
Counter.most_common(2)              # [('a', 3), ('b', 1)]

Point = namedtuple('Point', ['x', 'y'])
p = Point(3, 4)
print(p.x, p.y, p[0])   # 3, 4, 3 — accessible by name AND index

# ── itertools ── combinatorics and lazy iteration
from itertools import product, combinations, permutations, chain, islice

list(combinations([1,2,3], 2))    # [(1,2), (1,3), (2,3)]
list(product([0,1], repeat=3))   # all 3-bit binary numbers
list(islice(some_gen, 5))         # take first 5 items from generator

# ── functools ── higher-order functions
from functools import lru_cache, partial, reduce

@lru_cache(maxsize=None)           # memoize — caches results by arguments
def fib(n):
    if n < 2: return n
    return fib(n-1) + fib(n-2)

double = partial(pow, exp=2)       # partial application

# ── dataclasses — clean data containers
from dataclasses import dataclass, field

@dataclass
class Student:
    name:  str
    grade: int
    scores: list = field(default_factory=list)

    def average(self):
        return sum(self.scores) / len(self.scores) if self.scores else 0

# @dataclass auto-generates __init__, __repr__, __eq__ for you

# ── datetime ── don't roll your own date logic
from datetime import datetime, timedelta, date

now  = datetime.now()
utc  = datetime.utcnow()
tomorrow = now + timedelta(days=1)
print(now.strftime("%Y-%m-%d %H:%M"))         # format
datetime.strptime("2025-01-15", "%Y-%m-%d")  # parse

# ── re — regular expressions
import re

email_pattern = re.compile(r'[\w.+-]+@[\w-]+\.[\w.]+')
match = email_pattern.search("Contact: user@example.com")
if match:
    print(match.group())    # user@example.com

# ── logging — use instead of print() in real code
import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)
logger.info("Starting process")
logger.warning("Retrying request")
logger.error("Failed to connect", exc_info=True)
16 / Senior Patterns

Patterns That Separate
Good Python From Bad

The language is the same. How you use it isn't. Here is a direct comparison of junior vs senior patterns on the same problems.

SituationJunior writesSenior writes
Check if list is non-empty if len(lst) > 0: if lst:
Get dict value safely if k in d: v = d[k] v = d.get(k, default)
Loop with index for i in range(len(lst)): lst[i] for i, v in enumerate(lst):
Build string in loop result += word "".join(words)
Return first match Loop, append, return first next((x for x in lst if cond), None)
Flatten one level Nested for loops, append [x for sub in lst for x in sub]
Count occurrences Dict + manual increment Counter(items)
Open file safely f = open(); f.close() with open() as f:
# Context managers for everything that needs cleanup
from contextlib import contextmanager

@contextmanager
def db_transaction(conn):
    try:
        yield conn
        conn.commit()
    except:
        conn.rollback()
        raise

with db_transaction(connection) as conn:
    conn.execute("INSERT ...")   # auto-committed or rolled back

# Prefer data classes over plain dicts for structured data
# BAD:
user = {"name": "Alice", "age": 30}  # no autocomplete, typos fail silently

# GOOD:
@dataclass
class User:
    name: str
    age:  int

# Guard clauses — fail fast, reduce nesting
# BAD:
def process(user):
    if user:
        if user.is_active:
            if user.has_permission:
                do_work(user)

# GOOD:
def process(user):
    if not user: return
    if not user.is_active: return
    if not user.has_permission: return
    do_work(user)   # happy path is clean and obvious

# Unpacking everywhere
first, *rest = [1, 2, 3, 4, 5]    # first=1, rest=[2,3,4,5]
*init, last  = [1, 2, 3, 4, 5]    # init=[1,2,3,4], last=5
a, _, c      = (1, 2, 3)          # _ conventionally means "don't care"

# Ternary for simple conditions
status = "adult" if age >= 18 else "minor"

# Dictionary as a lookup table instead of long if/elif
GRADE_MAP = {"A": 4.0, "B": 3.0, "C": 2.0, "D": 1.0, "F": 0.0}
gpa = GRADE_MAP.get(letter_grade, 0.0)   # replaces 5-branch if/elif

Write code for the reader, not the machine

# Bad: clever but unreadable
result=[x for x in range(1,101) if not any(x%d==0 for d in range(2,x))]

# Good: name your intent
def is_prime(n):
    if n < 2: return False
    return all(n % d != 0 for d in range(2, int(n**0.5) + 1))

primes_under_100 = [n for n in range(2, 101) if is_prime(n)]

# Constants deserve names
MAX_RETRIES     = 3          # not the magic number 3 everywhere
API_BASE_URL    = "https://api.example.com/v1"
DEFAULT_TIMEOUT = 30

# Functions should do ONE thing and do it clearly
# If you need "and" in the function name, split it into two functions.
"When you understand Python, you don't fight it.
You write code that looks inevitable."

You've got the foundation now. Not just the syntax — the thinking. You know why things work, not just that they work. That's what separates engineers who can maintain code from engineers who can improve it.

The next step: build something real. A CLI tool. A data pipeline. A simple API. Reading is 10% of learning. The other 90% is writing code that breaks in interesting ways and figuring out why.

Come back to this when something doesn't make sense. The sections connect to each other — scoping explains closures, closures explain decorators, decorators explain half of Python's standard library. Keep pulling on those threads.

— Go write something. Break it. Fix it. That's the curriculum.