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.
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
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)
if but actually isn't, because someone forgot braces. Python makes that impossible. The indentation is the truth.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.
# 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__)
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
== 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.
# 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'
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}")
# 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
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.
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
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).
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.
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)
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
| Type | Ordered | Mutable | Duplicates | Use when |
|---|---|---|---|---|
list | Yes | Yes | Yes | Sequence of items, order matters, may grow |
tuple | Yes | No | Yes | Fixed collection, function return values, dict keys |
dict | Yes (3.7+) | Yes | Keys: No | Key→value lookup, named data |
set | No | Yes | No | Membership 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
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).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]
# 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)
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
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
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}"
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)
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"
__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.
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__
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)
# 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)
# 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
# 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)
__init__.py?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.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)
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.
| Situation | Junior writes | Senior 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
# 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.
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.