Python

The Ultimate Python Cheat Sheet

A comprehensive, practical Python reference covering syntax, data structures, functions, OOP, iterators and generators, decorators, concurrency, testing, and best practices — for daily use from beginner fundamentals through advanced, production-grade code.

75 reference blocks

Introduction & Getting Started

What Makes Python, Python

Readable syntax, dynamic typing, and a standard library so broad you rarely need a third-party package for the basics.

Python's whole reputation rests on a few consistent design choices: significant whitespace instead of braces, dynamic typing so you don't declare types up front, and a "batteries included" standard library covering everything from JSON parsing to HTTP servers.

Trait What it means in practice
Interpreted Runs line-by-line via the interpreter — no separate compile step for you to manage
Dynamically typed A variable's type is determined at runtime, and can change
Multi-paradigm Supports procedural, object-oriented, and functional styles side by side
Batteries included Huge standard library — json, re, datetime, os, and more, no install needed

Note: Python 2 reached end-of-life in January 2020. Anything you write today should target Python 3 — most modern syntax in this cheat sheet (f-strings, match statements, type hints) simply doesn't exist in Python 2.

Installing & Running Python

Getting the interpreter installed, and the handful of ways to actually run code with it.

python3 --version

python3 script.py             # run a script file
python3 -c "print(2 + 2)"      # run a short inline expression
python3                        # start the interactive REPL
# inside the REPL
>>> 2 + 2
4
>>> exit()

Pro Tip: The REPL (Read-Eval-Print Loop) is the fastest way to check how a small piece of syntax behaves — reach for it before writing a whole test script for a one-line question.

Syntax Basics & Variables

Statements, Indentation & Comments

Python uses whitespace, not braces, to define code blocks — get the indentation wrong and the code simply means something different.

# a comment
x = 1; y = 2          # semicolons CAN separate statements on one line, but it's rarely idiomatic

if x < y:
    print("x is smaller")   # indentation defines the block -- 4 spaces is the PEP 8 standard

total = 1 + 2 + \
        3 + 4              # backslash continues a line; parentheses are the preferred way instead

total2 = (1 + 2 +
          3 + 4)           # preferred: wrap in parens, no backslash needed

Common Mistake: Mixing tabs and spaces for indentation raises TabError in Python 3 — pick one (spaces, per PEP 8) and configure your editor to stop the mixing before it happens.

Variables & Naming

Assignment, multiple assignment, swapping, and the naming conventions that make code self-documenting.

name = "Ada"                  # simple assignment
a, b, c = 1, 2, 3              # multiple assignment
a, b = b, a                     # swap -- no temp variable needed

MAX_RETRIES = 5                 # ALL_CAPS is the convention for constants (Python has no true constants)
_private_helper = 42            # leading underscore signals 'internal use'
Rule Example
Must start with a letter or underscore _x, name — not 1x
Case-sensitive age and Age are different variables
snake_case for variables/functions user_name, not userName
ALL_CAPS for constants (by convention) MAX_SIZE = 100 — Python doesn't enforce immutability here

Note: Python has no real constants — MAX_RETRIES can still be reassigned. ALL_CAPS is a convention that signals intent to other developers, not a language-enforced rule.

Built-in Data Types & Conversion

The Core Built-in Types

Every value in Python is an instance of one of these — the vocabulary the rest of the language is built from.

Type Example Mutable?
int 42 No
float 3.14 No
complex 3+4j No
bool True, False No
str "hello" No
list [1, 2, 3] Yes
tuple (1, 2, 3) No
set {1, 2, 3} Yes
dict {"a": 1} Yes
NoneType None N/A — a singleton

Interview Tip: Whether a type is mutable determines whether it can be used as a dictionary key or set member — immutable types (int, str, tuple, frozenset) can be; mutable ones (list, dict, set) can't, because their hash would change if mutated.

Type Checking & Conversion

Confirming what type a value actually is, and safely converting between types.

type(42)                  # <class 'int'>
isinstance(42, int)        # True -- preferred over type() == comparisons, handles subclasses correctly
isinstance(True, int)      # True -- bool is a SUBCLASS of int in Python

int("42")                  # 42
float("3.14")               # 3.14
str(42)                     # "42"
bool(0)                      # False -- 0, empty strings/collections, and None are all falsy
list("abc")                  # ['a', 'b', 'c']
tuple([1, 2, 3])
set([1, 1, 2, 3])            # {1, 2, 3} -- dedupes automatically
dict([('a', 1), ('b', 2)])   # {'a': 1, 'b': 2}

Common Mistake: int("3.5") raises ValueErrorint() can't parse a decimal point directly from a string. Go through float() first: int(float("3.5")).

Input, Output & Operators

input() and print()

Reading text from the user, and every option `print()` gives you for controlling the output.

name = input("Enter your name: ")     # ALWAYS returns a string -- convert manually if you need a number

print("Hello", "world")                    # Hello world -- multiple values, space-joined by default
print("a", "b", "c", sep="-")               # a-b-c -- custom separator
print("Loading", end="...")                 # no trailing newline
print(f"{name} is here")                     # formatted output via f-string

Common Mistake: input() always returns a str, even if the user types a number. age = int(input("Age: ")) is the pattern — forgetting the conversion is a frequent beginner bug that shows up as TypeError the first time you try to do math on it.

Operators: Arithmetic, Comparison, Logical & More

The full operator lineup, and the precedence rules that decide what runs first.

Category Operators Example
Arithmetic + - * / // % ** 7 // 2 → 3, 7 % 2 → 1
Comparison == != < > <= >= 3 < 5 → True
Assignment = += -= *= /= etc. x += 1 is x = x + 1
Logical and or not True and False → False
Identity is, is not checks OBJECT identity, not value equality
Membership in, not in 3 in [1,2,3] → True
Bitwise & | ^ ~ << >> 5 & 3 → 1

Interview Trap: == compares value equality; is compares identity (same object in memory). a == b can be True while a is b is False for two separately-created equal lists — and small integers/short strings are cached by CPython, which makes is comparisons on them misleadingly seem to work before failing on larger values.

Numbers

Numeric Operations & Base Conversion

Floor division, modulo, rounding, and converting between number bases.

7 // 2        # 3 -- floor division
7 % 2          # 1 -- modulo (remainder)
2 ** 10         # 1024 -- exponentiation

round(3.14159, 2)     # 3.14 -- banker's rounding on ties, same as NumPy
abs(-5)                 # 5
min(3, 7, 1); max(3, 7, 1)

bin(10)      # '0b1010'
oct(10)       # '0o12'
hex(10)        # '0xa'
int('1010', 2)  # 10 -- parse a binary string back to an int

Common Mistake: round(2.5) gives 2, not 3 — Python's round() uses "round half to even" (banker's rounding), which surprises almost everyone the first time they hit it.

Creating, Indexing & Slicing Strings

Strings are immutable sequences — slicing and indexing work like lists, but you never mutate one in place.

s = 'single'; d = "double"          # functionally identical -- pick one style and stay consistent
ml = """line one
line two"""                          # triple-quoted, spans multiple lines
r = r"C:\new\test"                    # raw string -- backslashes are literal, not escape codes

s = "hello"
s[0]           # 'h'
s[-1]          # 'o'
s[1:4]          # 'ell'
s[::-1]          # 'olleh' -- reversed

"ab" + "cd"        # 'abcd' -- concatenation
"ab" * 3            # 'ababab' -- repetition
'a' in 'abc'          # True -- membership

Note: Strings are immutable — s[0] = 'H' raises TypeError. Any "modification" (like .replace() or .upper()) actually returns a brand-new string rather than changing the original.

String Methods: Case, Search, Split & Join

The methods you'll reach for constantly when cleaning or parsing text.

"  Hi There  ".strip()          # 'Hi There' -- also lstrip(), rstrip()
"Hi".upper(); "Hi".lower(); "hi there".title()

"hello world".find("world")       # 6 -- index, or -1 if not found
"hello".replace("l", "L")           # 'heLLo'

"a,b,c".split(",")                    # ['a', 'b', 'c']
"-".join(["a", "b", "c"])             # 'a-b-c'

"abc123".isalnum(); "123".isdigit(); "  ".isspace()
"Report".center(20, '*'); "5".zfill(3)         # '005'
"file.txt".endswith(".txt"); "pre_x".startswith("pre_")
"aabbcc".count("a")

Performance: Building a large string by repeatedly using += in a loop is O(n²) because each concatenation copies the whole string so far. ''.join(list_of_pieces) builds it in one pass and is dramatically faster for anything non-trivial.

String Formatting: f-strings, format() & %

Three generations of string formatting — and why f-strings win for almost everything written today.

name, age = "Ada", 36

f"{name} is {age} years old"              # f-string -- preferred, evaluates expressions inline
f"{age=}"                                    # 'age=36' -- debug shortcut, Python 3.8+
f"{3.14159:.2f}"                              # '3.14' -- 2 decimal places
f"{42:>6}"                                     # '    42' -- right-aligned in a width-6 field
f"{1000000:,}"                                  # '1,000,000' -- thousands separator

"{} is {}".format(name, age)                      # str.format() -- older, still seen in the wild
"%s is %d" % (name, age)                            # %-formatting -- legacy, avoid in new code

from datetime import date
f"{date.today():%B %d, %Y}"                            # date formatting inside an f-string

Best Practice: Use f-strings for new code — they're faster than .format(), more readable than %-formatting, and support inline expressions and the {var=} debug shortcut that the other two don't have.

Lists, Tuples & Sets

Lists: Creation, Slicing & Core Methods

The default go-to ordered, mutable collection — and the methods that add, remove, and reorder its contents.

nums = [3, 1, 4, 1, 5]
nums[0]; nums[-1]; nums[1:3]

nums.append(9)          # add to the end
nums.extend([2, 6])      # add multiple items
nums.insert(0, 100)       # insert at a specific position

nums.remove(1)             # removes the FIRST matching value
nums.pop()                   # removes and returns the LAST item (or pop(i) for a specific index)
nums.pop(0)
nums.clear()                  # empties the list

nums = [3, 1, 4, 1, 5]
nums.index(4); nums.count(1)
nums.sort(); nums.sort(reverse=True)      # in place
sorted(nums)                                # returns a NEW list, leaves original untouched
nums.reverse()
copy1 = nums.copy(); copy2 = nums[:]        # shallow copies, both equivalent

matrix = [[1, 2], [3, 4]]                     # nested lists

Common Mistake: list.sort() sorts in place and returns None — writing nums = nums.sort() silently throws your list away and leaves nums as None. Use the plain nums.sort() statement, or sorted(nums) if you want a new list back.

Tuples: Immutable Sequences & Unpacking

Fixed, ordered, and unchangeable — tuples trade flexibility for a guarantee: what you built is what stays.

point = (10, 20)
single = (5,)              # the trailing comma is REQUIRED -- (5) is just the int 5, not a tuple

point[0]; point[0:1]

x, y = point                       # unpacking
first, *rest = [1, 2, 3, 4]          # extended unpacking -- rest becomes [2, 3, 4]
a, *middle, z = [1, 2, 3, 4, 5]        # middle becomes [2, 3, 4]

point.count(10); point.index(20)         # tuples only have these two methods -- no append/remove, they're immutable

Common Mistake: t = (5) is just the integer 5 in parentheses, not a one-element tuple. The comma is what makes it a tuple: t = (5,).

Sets: Uniqueness & Set Algebra

Unordered collections of unique items, with real mathematical set operations built in.

s = {1, 2, 3}
s.add(4)
s.remove(2)         # raises KeyError if not present
s.discard(99)        # does NOT raise if the item isn't there

a = {1, 2, 3}; b = {2, 3, 4}
a | b            # union: {1, 2, 3, 4}
a & b             # intersection: {2, 3}
a - b              # difference: {1}
a ^ b               # symmetric difference: {1, 4}

{1, 2}.issubset({1, 2, 3})       # True
{1, 2, 3}.issuperset({1, 2})       # True

frozen = frozenset([1, 2, 3])        # immutable, hashable -- usable as a dict key or set member

Note: Sets (and dicts) in CPython 3.7+ have an insertion-order-preserving dict, but plain set objects still don't guarantee order — never rely on the iteration order of a set.

Dictionaries

Dictionary Basics: Access, Update & Iteration

Key-value storage with O(1) average lookup — probably the single most useful built-in structure in the language.

user = {"name": "Ada", "age": 36}

user["name"]                       # 'Ada' -- raises KeyError if missing
user.get("role", "guest")            # safe access with a default, never raises

user["active"] = True                  # add or update a key
del user["active"]                       # remove a key -- raises KeyError if missing

for key, value in user.items():
    print(key, value)

nested = {"a": {"b": 1}}                    # dictionaries can nest arbitrarily

merged = {**{"a": 1}, **{"b": 2}}              # merge via unpacking
merged2 = {"a": 1} | {"b": 2}                    # merge operator, Python 3.9+

Interview Tip: dict[key] raises KeyError on a missing key; dict.get(key, default) never does. Which one you want depends entirely on whether a missing key is a bug (use []) or an expected possibility (use .get()).

Dictionary Methods: get, keys, items, setdefault & More

The full method set — most of it exists specifically to avoid `KeyError`.

d = {"a": 1, "b": 2}

d.keys(); d.values(); d.items()          # dict_keys/dict_values/dict_items -- view objects, live-updating

d.update({"c": 3})                          # merge another dict in, overwriting shared keys
d.setdefault("d", 0)                          # get 'd' if present, else set it to 0 and return that

d.pop("a")                                      # remove and return the value, raises KeyError if missing (unless a default is given)
d.pop("z", None)                                 # safe pop with a fallback
d.popitem()                                        # removes and returns the LAST inserted (key, value) pair
d.clear()

d2 = d.copy()                                        # shallow copy
dict.fromkeys(["x", "y"], 0)                          # {'x': 0, 'y': 0} -- build from a list of keys, one shared default value

Warning: dict.fromkeys(['a', 'b'], []) gives every key the same list object, not independent ones — mutating the value under one key mutates it under all the others. Fine for immutable defaults like 0, dangerous for mutable ones.

Conditionals & Match Statements

if / elif / else & Truthy Values

Branching logic, and the rule for what counts as "true" without an explicit boolean.

score = 82
if score >= 90:
    grade = "A"
elif score >= 75:
    grade = "B"
else:
    grade = "C"

grade = "A" if score >= 90 else "B"          # conditional (ternary) expression

if 0 < score < 100:                            # chained comparison -- reads naturally, is genuinely one expression
    print("valid")
Falsy values Everything else is truthy
False, None, 0, 0.0, "", [], {}, set() Non-zero numbers, non-empty strings/collections, any other object

Common Mistake: if x == None works, but if x is None is the idiomatic and slightly faster form — None is a singleton, so identity comparison is both correct and conventional here.

match Statements (Structural Pattern Matching)

Python's answer to switch statements — but genuinely more powerful, since it can match on structure, not just value.

def handle(command):
    match command.split():
        case ["go", direction]:
            return f"Moving {direction}"
        case ["go", *rest]:                       # sequence pattern with remainder capture
            return f"Too many arguments: {rest}"
        case {"action": "jump", "height": h}:      # mapping pattern
            return f"Jumping {h}"
        case [x, y] if x == y:                       # guard condition
            return "Equal pair"
        case str() as s:                              # class pattern
            return f"Got a string: {s}"
        case _:                                          # wildcard -- the 'default' case
            return "Unknown command"

Note: match/case was added in Python 3.10 — if you're supporting older Python versions, you'll need an if/elif chain instead. It's structural pattern matching, closer to what languages like Rust or Haskell offer, not a simple value-equality switch.

Loops & Iteration Helpers

for, while & Loop Control

Python's two loop forms, and the statements that let you break out or skip early.

for item in [1, 2, 3]:
    print(item)

count = 3
while count > 0:
    print(count)
    count -= 1                 # Python has no ++ or -- operators

for i in range(10):
    if i == 5:
        break             # exit the loop entirely
    if i % 2 == 0:
        continue           # skip to the next iteration
    print(i)
else:
    print("loop finished without a break")     # for/while ELSE runs only if the loop wasn't broken out of

for i in range(3):
    for j in range(3):
        pass                  # 'pass' -- a no-op, a placeholder where syntax requires a statement

Interview Tip: A for/while loop's else clause runs when the loop completes normally — it's specifically there for the common "search and act only if nothing was found" pattern, and it's one of Python's more underused features.

range(), enumerate() & Iteration Helpers

Generating sequences of numbers, pairing values with their index, and the small toolkit around parallel iteration.

list(range(5))               # [0, 1, 2, 3, 4]
list(range(2, 10, 2))         # [2, 4, 6, 8] -- start, stop, step
list(range(10, 0, -1))          # reversed range via a negative step

for i, value in enumerate(["a", "b", "c"]):
    print(i, value)
for i, value in enumerate(["a", "b"], start=1):    # custom start index
    print(i, value)

list(zip([1, 2], ["a", "b"]))         # [(1, 'a'), (2, 'b')] -- pairs elements from parallel sequences
list(reversed([1, 2, 3]))                # [3, 2, 1]
sorted([3, 1, 2])                          # new sorted list

list(map(str, [1, 2, 3]))                    # ['1', '2', '3'] -- apply a function to every item
list(filter(lambda x: x > 1, [1, 2, 3]))       # [2, 3] -- keep items where the function is truthy

Performance: range() in Python 3 is lazy — range(1_000_000) doesn't build a million-item list in memory, it generates values on demand. This is different from Python 2, where range() eagerly built a full list (that's what xrange() was for, back then).

Comprehensions

List, Set, Dict Comprehensions & Generator Expressions

A compact, often faster way to build a collection than a full `for` loop with `.append()` calls.

squares = [x**2 for x in range(10)]                          # list comprehension
evens = [x for x in range(20) if x % 2 == 0]                    # conditional comprehension
labeled = ['even' if x % 2 == 0 else 'odd' for x in range(5)]     # if/else INSIDE the expression, not as a filter

unique_lengths = {len(w) for w in ['a', 'bb', 'ccc', 'dd']}         # set comprehension
word_lengths = {w: len(w) for w in ['a', 'bb', 'ccc']}                 # dict comprehension

gen = (x**2 for x in range(1_000_000))                                   # generator expression -- lazy, doesn't build the whole thing in memory

matrix = [[1, 2], [3, 4]]
flat = [x for row in matrix for x in row]                                  # nested comprehension -- reads left-to-right like nested for loops
Form Brackets Result
List comprehension [ ] A list, fully built in memory
Set comprehension { } A set, deduplicated
Dict comprehension {k: v} A dict
Generator expression ( ) A lazy generator — values computed on demand

Performance: For large sequences you only need to iterate once, a generator expression avoids building the whole collection in memory — swap [ ] for ( ) and nothing else needs to change in most cases.

Functions

Defining Functions, Arguments & Return Values

The building block of reusable code — definitions, defaults, keyword arguments, and documenting what a function does.

def greet(name, prefix="Hello"):        # prefix has a DEFAULT value
    """Return a greeting string for the given name."""     # docstring -- accessible via greet.__doc__ or help(greet)
    return f"{prefix}, {name}!"

greet("Ada")                       # positional argument
greet(name="Ada", prefix="Hi")       # keyword arguments -- order doesn't matter

def divide(a, b):
    return a // b, a % b            # multiple return values -- actually just one tuple

quotient, remainder = divide(17, 5)     # unpacked immediately

Note: "Multiple return values" in Python is really just returning a single tuple and unpacking it at the call site — there's no separate multi-value-return mechanism under the hood.

Advanced Arguments: *args, **kwargs & Argument-Only Markers

Variable-length arguments, and the syntax that forces callers to be explicit about how they pass values.

def total(*args):                      # collects extra positional args into a tuple
    return sum(args)
total(1, 2, 3)                            # 6

def describe(**kwargs):                  # collects extra keyword args into a dict
    return kwargs
describe(name="Ada", age=36)               # {'name': 'Ada', 'age': 36}

def combo(a, b, *args, c=1, **kwargs):     # can combine all forms in this order
    pass

def pos_only(a, b, /, c):                   # a, b are POSITIONAL-ONLY -- can't be passed as keywords
    pass

def kw_only(a, *, b):                        # b is KEYWORD-ONLY -- must be passed as b=value
    pass

nums = [1, 2, 3]
print(*nums)                                   # unpacking a list into positional arguments

Warning: A mutable default argument (def f(items=[])) is created once, at function definition time, and shared across every call that doesn't pass its own value. Mutating it inside the function leaks state between calls. The fix: def f(items=None): items = items if items is not None else [].

Scope, Lambda & Recursion

Variable Scope & the LEGB Rule

Where Python looks for a name, in order, and the keywords that let you reach outside the current scope.

x = "global"

def outer():
    x = "enclosing"
    def inner():
        global x        # refers to the module-level x, NOT the enclosing one -- a common surprise
        nonlocal_demo = x
    inner()

def counter():
    count = 0
    def increment():
        nonlocal count       # refers to the nearest ENCLOSING scope's count
        count += 1
        return count
    return increment
Scope Where it's searched Keyword to modify from inside a nested function
Local Inside the current function (default)
Enclosing Any enclosing function (closures) nonlocal
Global Module level global
Built-in Python's built-in names (len, print, ...) n/a

Interview Tip: LEGB is the search order Python uses to resolve a name: Local, Enclosing, Global, Built-in. Without global/nonlocal, assigning to a name inside a function always creates a new local variable rather than modifying an outer one.

Lambda Functions

Small, anonymous, single-expression functions — mostly useful as short arguments to other functions.

square = lambda x: x ** 2          # equivalent to def square(x): return x ** 2
add = lambda x, y: x + y

sorted([(1, 'b'), (2, 'a')], key=lambda pair: pair[1])       # sort by the second element
list(map(lambda x: x * 2, [1, 2, 3]))
list(filter(lambda x: x % 2 == 0, range(10)))

Best Practice: A lambda is limited to a single expression — no statements, no assignments. If the logic needs more than one line or a name for readability, a regular def function is the better (and more debuggable) choice, even though a lambda is technically possible in more places.

Recursion: Base Cases & Limits

Functions that call themselves — powerful for naturally recursive problems, but not free of gotchas.

def factorial(n):
    if n <= 1:                  # base case -- without this, infinite recursion
        return 1
    return n * factorial(n - 1)    # recursive call

import sys
sys.getrecursionlimit()          # default is usually 1000
sys.setrecursionlimit(3000)        # raise it -- but a stack overflow at the OS level is still possible

# an iterative alternative avoids the recursion limit entirely
def factorial_iterative(n):
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

Warning: Python doesn't optimize tail calls the way some other languages do — deep recursion (tens of thousands of calls) will hit RecursionError regardless of how the recursive call is written. An iterative rewrite is the reliable fix for genuinely deep recursion.

Modules & Packages

Importing Modules

Bringing code from another file into the current one, and the handful of ways to do it.

import math                          # import the whole module
import math as m                       # import with an alias
from math import sqrt, pi                # import specific names directly
from math import *                        # import everything -- generally discouraged, pollutes the namespace

if __name__ == "__main__":                 # True only when the file is run directly, not when imported
    print("Running as a script")

Note: __name__ is "__main__" only in the file that was actually executed. Every other module that gets imported has __name__ set to its own module name instead — this is exactly what the if __name__ == "__main__": guard is checking for.

Package Structure

Organizing multiple modules into a directory Python recognizes as an importable package.

my_package/
├── __init__.py          # marks the directory as a package (can be empty)
├── module_a.py
├── module_b.py
└── subpackage/
    ├── __init__.py
    └── module_c.py
from my_package import module_a              # absolute import
from . import module_b                          # relative import -- only valid INSIDE a package
from .subpackage import module_c                   # relative import into a subpackage

python -m my_package.module_a                        # run a module inside a package as a script

Common Mistake: Relative imports (from . import x) only work when the file is executed as part of a package (e.g., via python -m), not when run directly as a standalone script — a frequent source of ImportError: attempted relative import with no known parent package.

Common Built-in Functions & Exceptions

Frequently Used Built-in Functions

The small set of global functions available without any import — used in nearly every script.

Function Purpose
len(x) Number of items
sum(x) Add numeric items
min(x) / max(x) Smallest / largest item
abs(x) Absolute value
round(x, n) Round to n decimal places
all(x) / any(x) True if all / any items are truthy
dir(obj) List an object's attributes and methods
help(obj) Interactive documentation
id(obj) The object's unique identity (memory address in CPython)
hash(obj) Hash value — only defined for hashable (typically immutable) objects

Pro Tip: all([]) and any([]) both apply to the empty sequence, and their answers surprise people in opposite directions: all([]) is True (vacuously — no item fails), and any([]) is False (nothing succeeded, since there's nothing there at all).

Exception Handling: try / except / finally

Catching and responding to errors without letting one bad case crash the whole program.

try:
    number = int("abc")
except ValueError as e:
    print(f"Invalid input: {e}")
except (TypeError, KeyError):                     # catch multiple exception types together
    print("Different kind of problem")
else:
    print("Ran only if NO exception occurred")
finally:
    print("Always runs, exception or not -- great for cleanup")

raise ValueError("Custom message")                    # trigger an exception manually

try:
    raise ValueError("low-level problem")
except ValueError as e:
    raise RuntimeError("higher-level failure") from e      # exception chaining -- preserves the original cause

class InsufficientFundsError(Exception):                    # custom exception -- inherit from Exception (or a subclass)
    pass

Best Practice: Catch the most specific exception type you can (ValueError, not a bare except:), and only catch what you can actually handle meaningfully — swallowing every exception silently is one of the most common ways real bugs go unnoticed for months.

Common Exception Types

The exceptions you'll actually run into constantly, and what each one is telling you.

Exception Typical Cause
SyntaxError Invalid Python syntax — caught before the code even runs
TypeError An operation applied to an incompatible type (e.g., "2" + 2)
ValueError Right type, invalid value (e.g., int("abc"))
NameError Referencing a variable that doesn't exist
IndexError Sequence index out of range
KeyError Dictionary key doesn't exist
AttributeError Object doesn't have the attribute/method you called
ZeroDivisionError Division or modulo by zero
FileNotFoundError Opening a file that doesn't exist
ImportError A module or name couldn't be imported

Opening, Reading & Writing Files

Working with files safely, using a context manager so the file always gets closed.

with open("notes.txt", "r", encoding="utf-8") as f:      # 'with' guarantees the file closes, even on error
    content = f.read()               # whole file as one string
    # or: for line in f: ...           # memory-efficient line-by-line iteration

with open("notes.txt", "w", encoding="utf-8") as f:       # 'w' OVERWRITES the file entirely
    f.write("hello\n")

with open("notes.txt", "a", encoding="utf-8") as f:        # 'a' appends to the end
    f.write("another line\n")

with open("data.bin", "rb") as f:                             # 'b' -- binary mode, returns bytes not str
    raw = f.read()
Mode Meaning
'r' Read (default) — errors if the file doesn't exist
'w' Write — creates the file, overwrites if it exists
'a' Append — creates the file if needed, adds to the end otherwise
'b' Binary mode suffix, e.g. 'rb', 'wb'

Warning: Opening a file without with means you're responsible for calling .close() yourself — forget it, or hit an exception before you get there, and the file can stay open (and unflushed) longer than intended. Always prefer with.

pathlib: Modern File & Directory Paths

The object-oriented way to work with filesystem paths — cleaner and more portable than string concatenation.

from pathlib import Path

p = Path("data") / "raw" / "file.csv"       # / joins path segments -- portable across OSes

p.exists(); p.is_file(); p.is_dir()
p.parent; p.name; p.suffix; p.stem

Path("new_folder").mkdir(exist_ok=True)          # create a directory, don't error if it already exists
Path("nested/dirs").mkdir(parents=True, exist_ok=True)

list(Path(".").iterdir())                          # list directory contents
list(Path(".").glob("*.csv"))                       # pattern-matched listing

p.rename("renamed.csv")
p.unlink(missing_ok=True)                              # delete a file, don't error if it's already gone

Best Practice: Prefer pathlib.Path over manually building paths with os.path.join() and string concatenation — it's more readable, handles OS differences (like / vs \) automatically, and reads naturally with the / operator.

Object-Oriented Programming Basics

Classes, Objects & Constructors

Defining your own types — the blueprint (`class`) and the concrete things it produces (instances).

class Dog:
    species = "Canis familiaris"        # class variable -- shared across ALL instances

    def __init__(self, name, age):        # constructor -- runs when Dog(...) is called
        self.name = name                   # instance variable -- unique per object
        self.age = age

    def bark(self):                          # instance method -- always takes 'self' first
        return f"{self.name} says woof!"

rex = Dog("Rex", 3)          # an object/instance of Dog
rex.bark()                       # 'Rex says woof!'
rex.name; rex.species             # instance and class attributes both accessible via the instance

Common Mistake: Mutable class variables (like a list) are shared across every instance unless explicitly set in __init__. If one instance appends to a class-level list, every other instance sees the change too — usually not what was intended.

Inheritance & super()

Building a new class on top of an existing one, reusing and optionally overriding its behavior.

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return "..."

class Dog(Animal):                             # Dog inherits from Animal
    def speak(self):                             # method OVERRIDING -- replaces the parent's version
        return f"{self.name} says Woof!"

class Puppy(Dog):
    def speak(self):
        base = super().speak()                    # call the PARENT's version, then extend it
        return f"{base} (but smaller)"

class Hybrid(Dog, Animal):                       # multiple inheritance
    pass
Hybrid.__mro__                                       # Method Resolution Order -- the lookup path Python follows

Interview Tip: super() doesn't just call "the parent class" — in a multiple-inheritance setup it follows the class's Method Resolution Order (C3 linearization), which can mean calling a sibling class's method, not a direct parent's. ClassName.__mro__ shows the exact order.

Encapsulation, Special Methods & Dataclasses

Encapsulation, Properties & Duck Typing

Controlling access to an object's internals, and Python's preference for behavior over declared type.

class Account:
    def __init__(self, balance):
        self._balance = balance         # single underscore -- 'protected' by CONVENTION only, not enforced
        self.__secret = "hidden"          # double underscore -- name-mangled to _Account__secret

    @property
    def balance(self):                    # getter -- accessed like an attribute, not called like a method
        return self._balance

    @balance.setter
    def balance(self, value):                # setter -- runs validation on assignment
        if value < 0:
            raise ValueError("Balance can't be negative")
        self._balance = value

acc = Account(100)
acc.balance = 150            # runs the setter, not a plain attribute assignment

class Duck:
    def quack(self):
        return "Quack!"
def make_it_quack(thing):        # duck typing -- no type check, just call the method and see what happens
    return thing.quack()

Note: Python has no true private keyword. A single leading underscore is a convention signaling "internal use"; a double leading underscore triggers name mangling (__secret becomes _ClassName__secret), which discourages accidental access but doesn't truly prevent it.

Special (Dunder) Methods

The methods Python calls automatically for built-in syntax — `print()`, `len()`, `[]`, `for`, and more.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):                    # unambiguous, developer-facing representation
        return f"Vector({self.x}, {self.y})"
    def __str__(self):                       # readable, user-facing string -- used by print()
        return f"({self.x}, {self.y})"
    def __len__(self):
        return 2
    def __getitem__(self, i):                  # enables v[0], v[1]
        return (self.x, self.y)[i]
    def __eq__(self, other):                     # enables == comparison
        return (self.x, self.y) == (other.x, other.y)
    def __add__(self, other):                       # operator overloading -- enables v1 + v2
        return Vector(self.x + other.x, self.y + other.y)

class Counter:
    def __init__(self, limit):
        self.n, self.limit = 0, limit
    def __iter__(self):                                # makes the object usable in a for loop
        return self
    def __next__(self):
        if self.n >= self.limit:
            raise StopIteration
        self.n += 1
        return self.n

Interview Tip: If you only implement one of __repr__/__str__, implement __repr__print() falls back to it if __str__ is missing, but the reverse isn't true, and __repr__ is what shows up in a debugger or the REPL.

classmethod, staticmethod & Dataclasses

Methods that operate on the class rather than an instance, and a shortcut for writing simple data-holding classes.

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    @classmethod
    def margherita(cls):                    # alternative constructor -- gets the CLASS, not an instance
        return cls(["tomato", "mozzarella"])

    @staticmethod
    def validate_topping(name):                # no access to self or cls -- just namespaced under the class
        return isinstance(name, str)

from dataclasses import dataclass, field

@dataclass(frozen=True, order=True)                     # auto-generates __init__, __repr__, __eq__; frozen makes it immutable
class Point:
    x: int
    y: int
    tags: list = field(default_factory=list)              # mutable defaults need a factory, not a bare []

    def __post_init__(self):                                # runs right after the generated __init__
        object.__setattr__(self, 'label', f'({self.x},{self.y})')

Warning: A dataclass field with a mutable default (like tags: list = []) raises ValueError at class definition time specifically to prevent the shared-mutable-default bug — field(default_factory=list) is the required workaround.

Iterators, Generators & Decorators

Iterators & Generators

The protocol behind every `for` loop, and `yield` — the keyword that turns an ordinary function into a lazy value producer.

nums = [1, 2, 3]
it = iter(nums)               # get an iterator from an iterable
next(it); next(it); next(it)     # StopIteration raised on the 4th call

def countdown(n):
    while n > 0:
        yield n                # pauses here, resumes on the next call, remembers local state between calls
        n -= 1

gen = countdown(3)
next(gen)          # 3
list(countdown(3))    # [3, 2, 1] -- exhausts the generator fully

def chain_gen(*iterables):
    for it in iterables:
        yield from it            # delegate to a sub-iterable -- flattens one level
list(chain_gen([1, 2], [3, 4]))     # [1, 2, 3, 4]

Performance: A generator computes values one at a time, on demand, instead of building the whole sequence in memory upfront — the right choice whenever you're processing a large or unbounded sequence and only need to iterate over it once.

Decorators

Wrapping a function (or class) to add behavior without changing its actual code.

import functools

def timer(func):
    @functools.wraps(func)              # preserves the original function's __name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        import time
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

@timer                        # equivalent to: slow_function = timer(slow_function)
def slow_function():
    pass

def repeat(n):                   # a decorator FACTORY -- a decorator that takes its own arguments
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def greet():
    print("hi")

@timer
@repeat(2)                        # decorators stack -- applied bottom-up
def combo():
    pass

Common Mistake: Skipping @functools.wraps(func) inside a decorator leaves the wrapped function's __name__ and __doc__ pointing at the generic wrapper instead of the original — breaks introspection, debugging tools, and anything that relies on function metadata.

Context Managers & the with Statement

Guaranteeing setup and teardown code runs — even if an exception happens in between.

with open("file.txt") as f:            # the built-in file object is a context manager
    data = f.read()

class Timer:
    def __enter__(self):                     # runs when entering the 'with' block
        import time
        self.start = time.perf_counter()
        return self
    def __exit__(self, exc_type, exc_val, exc_tb):    # runs on exit, EVEN if an exception occurred
        import time
        print(f"Elapsed: {time.perf_counter() - self.start:.4f}s")
        return False                              # False (or None) means: don't suppress the exception

with Timer():
    pass

from contextlib import contextmanager

@contextmanager                                     # a simpler, generator-based way to write one
def managed_resource():
    print("acquire")
    try:
        yield "resource"
    finally:
        print("release")           # guaranteed cleanup, exception or not

Note: Returning True from __exit__ suppresses the exception that occurred inside the with block — usually not what you want, and easy to do by accident if __exit__ doesn't explicitly return False/None.

Type Hints & Functional Programming

Type Hints: Variables, Functions & Generics

Optional, non-enforced annotations that document intent and let tools like mypy catch bugs before runtime.

age: int = 36                              # variable annotation
name: str

def greet(name: str, times: int = 1) -> str:      # function annotation + return type
    return (name + " ") * times

from typing import Optional, Union, Callable

def find(items: list[int], target: int) -> Optional[int]:    # Optional[int] means int OR None
    ...

def parse(value: Union[int, str]) -> int:                       # accepts either type -- or use int | str in 3.10+
    ...

Age = int                                                          # type alias -- just a readable name
def process(callback: Callable[[int], str]) -> None:                # a function taking an int, returning a str
    ...

from typing import Any
def anything(value: Any) -> Any:                                       # opts OUT of type checking for this value
    ...

Note: Type hints are not enforced at runtimedef f(x: int): return x happily accepts a string if you call f("hello"). They exist for static type checkers (mypy, pyright) and editor tooling, not as a runtime guarantee.

Functional Programming: Closures, Partial & Composition

Treating functions as ordinary values — passed around, returned, and combined like any other data.

def make_multiplier(factor):            # closure -- inner function 'remembers' factor after outer returns
    def multiply(x):
        return x * factor
    return multiply

double = make_multiplier(2)
double(5)          # 10

from functools import partial
add = lambda x, y: x + y
add_five = partial(add, 5)               # 'pre-fill' the first argument
add_five(10)          # 15

from functools import reduce
reduce(lambda acc, x: acc + x, [1, 2, 3, 4], 0)     # cumulative reduction -- 10

def compose(*funcs):                          # combine multiple functions into one pipeline
    def composed(x):
        for f in reversed(funcs):
            x = f(x)
        return x
    return composed

pipeline = compose(str, lambda x: x + 1)
pipeline(5)          # '6'

Note: A pure function always returns the same output for the same input and has no side effects (no mutating external state, no I/O). They're easier to test and reason about, and are the building blocks functional-style code leans on.

Regex, Date/Time & Math Modules

Regular Expressions (re module)

Pattern matching for text — search, extract, and replace based on structure rather than exact strings.

import re

re.search(r'\d+', 'abc123')            # match object or None -- searches anywhere in the string
re.match(r'\d+', 'abc123')                # None -- match() only anchors at the START
re.findall(r'\d+', 'a1 b22 c333')          # ['1', '22', '333'] -- all matches, as strings
re.sub(r'\d+', '#', 'a1 b22')                # 'a# b#' -- search and replace
re.split(r'\s+', 'a   b  c')                   # ['a', 'b', 'c']

match = re.search(r'(?P<user>\w+)@(?P<domain>\w+)', 'ada@example')
match.group('user')          # named capture group

re.findall(r'\d+', 'A1 a2', re.IGNORECASE)      # flags modify matching behavior
Symbol Meaning
\d \w \s Digit, word character, whitespace (capital = negated)
* + ? {m,n} Quantifiers: 0+, 1+, 0-or-1, m-to-n repeats
^ $ Anchors: start / end of string (or line, with re.MULTILINE)
( ) Capture group
(?P<name>...) Named capture group

Common Mistake: re.match() only anchors at the start of the string, not the whole string — a trailing mismatch still 'matches'. Use re.fullmatch() when the entire string needs to conform to the pattern.

Date & Time Handling

Working with dates, times, and durations using the standard library's `datetime` module.

from datetime import datetime, date, time, timedelta

now = datetime.now()
today = date.today()

datetime(2024, 3, 15, 14, 30)                  # explicit construction
datetime.strptime("2024-03-15", "%Y-%m-%d")       # parse a string into a datetime
now.strftime("%B %d, %Y")                            # format a datetime as a string

later = now + timedelta(days=7, hours=3)               # date arithmetic
diff = later - now                                        # a timedelta
diff.days; diff.total_seconds()

from datetime import timezone
now_utc = datetime.now(timezone.utc)                        # timezone-aware datetime

Warning: A "naive" datetime (no timezone info) and an "aware" one (has timezone info) can't be compared or subtracted — mixing them raises TypeError. Be consistent about which kind you're using throughout an application.

math, statistics, decimal & fractions

The standard library's numeric modules for when plain floats aren't precise or expressive enough.

import math
math.sqrt(16); math.pi; math.floor(3.7); math.ceil(3.2); math.factorial(5)

import statistics
statistics.mean([1, 2, 3, 4]); statistics.median([1, 2, 3, 4]); statistics.stdev([1, 2, 3, 4])

from decimal import Decimal
Decimal("0.1") + Decimal("0.2")          # Decimal('0.3') -- exact, unlike float
0.1 + 0.2                                    # 0.30000000000000004 -- classic float imprecision

from fractions import Fraction
Fraction(1, 3) + Fraction(1, 6)             # Fraction(1, 2) -- exact rational arithmetic

import random
random.randint(1, 10); random.choice([1, 2, 3]); random.shuffle([1, 2, 3])

Interview Tip: 0.1 + 0.2 != 0.3 in plain floating point because binary floats can't represent most decimal fractions exactly. Decimal is the fix when exactness matters — money calculations being the classic example.

JSON & CSV Handling

JSON Encoding & Decoding

Converting between Python objects and JSON text — for config files, API payloads, and simple data storage.

import json

data = {"name": "Ada", "skills": ["math", "code"]}

text = json.dumps(data)                      # Python object -> JSON string
pretty = json.dumps(data, indent=2)            # human-readable, indented
restored = json.loads(text)                       # JSON string -> Python object

with open("data.json", "w") as f:
    json.dump(data, f, indent=2)                     # write directly to a file
with open("data.json") as f:
    loaded = json.load(f)                              # read directly from a file

import datetime
def custom_serializer(obj):                          # handle types json.dumps doesn't know natively
    if isinstance(obj, datetime.datetime):
        return obj.isoformat()
    raise TypeError(f"Not serializable: {obj}")
json.dumps({"ts": datetime.datetime.now()}, default=custom_serializer)

Common Mistake: json.dumps() raises TypeError on objects it doesn't know how to serialize (datetimes, custom classes, sets). The default= parameter is the standard escape hatch for teaching it how.

CSV Reading & Writing

Working with comma-separated (or any delimiter) tabular text files via the standard library.

import csv

with open("data.csv", newline="") as f:            # newline='' avoids extra blank rows on Windows
    reader = csv.reader(f)
    header = next(reader)
    for row in reader:                                 # each row is a plain list of strings
        print(row)

with open("data.csv", newline="") as f:
    reader = csv.DictReader(f)                              # each row is an OrderedDict/dict keyed by header
    for row in reader:
        print(row["name"])

with open("out.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["name", "age"])
    writer.writerows([["Ada", 36], ["Linus", 54]])

with open("out.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["name", "age"])
    writer.writeheader()
    writer.writerow({"name": "Ada", "age": 36})

csv.reader(f, delimiter=";", quotechar='"')                   # custom delimiter and quote character

Note: Every value read from a CSV comes back as a plain string — numbers need explicit conversion (int(row[1])), since csv has no concept of a numeric column type.

Collections & Itertools Modules

collections: Counter, defaultdict, deque & namedtuple

Specialized container types that solve common problems the plain `dict`/`list` make more awkward.

from collections import Counter, defaultdict, deque, namedtuple, ChainMap, OrderedDict

Counter("mississippi")                       # Counter({'i': 4, 's': 4, 'p': 2, 'm': 1}) -- frequency count
Counter("aab").most_common(1)                    # [('a', 2)]

dd = defaultdict(list)                             # missing keys auto-create an empty list instead of KeyError
dd["fruits"].append("apple")

dq = deque([1, 2, 3])
dq.appendleft(0); dq.append(4); dq.popleft()          # O(1) at BOTH ends, unlike a plain list

Point = namedtuple("Point", ["x", "y"])
p = Point(1, 2)
p.x; p.y                                                 # tuple with named fields -- lightweight, immutable

cm = ChainMap({"a": 1}, {"b": 2})                          # views multiple dicts as one, checked in order

Performance: A plain list is O(n) for inserting/removing at the front; deque is O(1) at both ends. If you're using list.insert(0, x) or list.pop(0) in a loop, deque is almost always the better choice.

itertools: Infinite, Combinatoric & Terminating Iterators

Building blocks for constructing complex iteration patterns without writing the loops by hand.

import itertools

list(itertools.chain([1, 2], [3, 4]))                  # [1, 2, 3, 4] -- flatten sequences together
list(itertools.islice(itertools.count(10), 3))            # [10, 11, 12] -- count() is infinite, islice() limits it
list(itertools.repeat("x", 3))                              # ['x', 'x', 'x']

list(itertools.product([1, 2], ['a', 'b']))                    # [(1,'a'), (1,'b'), (2,'a'), (2,'b')] -- cartesian product
list(itertools.permutations([1, 2, 3], 2))                       # all ordered pairs
list(itertools.combinations([1, 2, 3], 2))                         # all unordered pairs, no repeats

cycler = itertools.cycle([1, 2, 3])                                  # repeats forever -- must be limited manually
list(itertools.islice(cycler, 7))                                       # [1, 2, 3, 1, 2, 3, 1]

Warning: itertools.count(), cycle(), and repeat() (without a count) are genuinely infinite — always pair them with islice() or a break condition, or the loop will never terminate.

OS Utilities, CLI Args & Environments

The os Module & Environment Variables

Interacting with the operating system — paths, environment variables, and running shell commands.

import os

os.getcwd()                       # current working directory
os.listdir(".")                     # directory contents (pathlib's .iterdir() is often preferred now)
os.makedirs("a/b/c", exist_ok=True)

os.environ.get("HOME")               # read an environment variable, with a default via .get()
os.environ["MY_VAR"] = "value"          # set one for the current process (and its children)

os.getpid()                                # current process ID

import subprocess
result = subprocess.run(["ls", "-l"], capture_output=True, text=True)      # run a shell command safely
result.stdout

Best Practice: Prefer subprocess.run([...]) with a list of arguments over os.system("...") or shell=True — passing a list avoids shell injection risk when any part of the command includes untrusted input.

Command-Line Arguments with argparse

Parsing arguments passed to a script from the terminal, with automatic help text generation.

import sys
sys.argv               # ['script.py', 'arg1', 'arg2'] -- raw list, no parsing

import argparse

parser = argparse.ArgumentParser(description="Process some files.")
parser.add_argument("filename")                          # positional argument -- required
parser.add_argument("--verbose", "-v", action="store_true")   # optional flag
parser.add_argument("--count", type=int, default=1)          # optional argument with a type and default

args = parser.parse_args()
args.filename; args.verbose; args.count

# run as:  python script.py data.txt --verbose --count 5

Pro Tip: argparse generates a working --help flag automatically from the arguments you define — a real time-saver over hand-parsing sys.argv, which also breaks the moment argument order changes.

Virtual Environments & Package Management

Isolating project dependencies so different projects can use different (and conflicting) package versions.

python3 -m venv .venv                  # create a virtual environment
source .venv/bin/activate                # activate it (macOS/Linux)
.venv\Scripts\activate                   # activate it (Windows)
deactivate                                 # leave the virtual environment

pip install requests                       # install a package into the active environment
pip install --upgrade requests               # update it
pip uninstall requests                        # remove it
pip list                                        # see what's installed

pip freeze > requirements.txt                    # snapshot exact installed versions
pip install -r requirements.txt                    # install from that snapshot elsewhere

Best Practice: Commit requirements.txt (or a pyproject.toml) to version control, never a populated .venv folder — the environment is meant to be reproducible from the requirements file, not shipped as-is.

Testing, Debugging & Logging

Testing with assert & unittest

Verifying code behaves as expected, from a quick sanity check to a full test suite.

assert 2 + 2 == 4, "math is broken"          # raises AssertionError with the message if False

import unittest

class TestMath(unittest.TestCase):
    def setUp(self):                          # runs before EVERY test method -- a fixture
        self.data = [1, 2, 3]

    def test_sum(self):
        self.assertEqual(sum(self.data), 6)
        self.assertTrue(len(self.data) > 0)
        with self.assertRaises(ZeroDivisionError):
            1 / 0

if __name__ == "__main__":
    unittest.main()          # discovers and runs every test_* method automatically

from unittest.mock import Mock, patch
mock_obj = Mock(return_value=42)
mock_obj()          # 42, without running any real logic

Note: assert statements are stripped out entirely when Python runs with the -O (optimize) flag — never use bare assert for something that needs to hold in production, like input validation; raise an explicit exception instead.

Debugging & Logging

Moving past `print()` debugging, and setting up logging that's actually useful once code leaves your laptop.

import pdb
pdb.set_trace()          # drops into an interactive debugger at this line (legacy)
breakpoint()               # the modern equivalent, Python 3.7+ -- respects PYTHONBREAKPOINT env var

import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

logger.debug("Detailed diagnostic info")
logger.info("Normal operation message")
logger.warning("Something unexpected, but not fatal")
logger.error("A real problem occurred")
logger.critical("The program may not be able to continue")

handler = logging.FileHandler("app.log")           # send logs to a file instead of (or as well as) the console
logger.addHandler(handler)

Best Practice: Use the logging module instead of scattered print() statements in anything beyond a quick script — it gives you severity levels, timestamps, and the ability to redirect output to a file or external service without touching the calling code.

Copying, Sorting, Unpacking & Mutability

Shallow vs Deep Copies

Three different levels of 'copying' a Python object, and why only one of them is truly independent for nested data.

import copy

original = [1, 2, [3, 4]]

same = original                    # NOT a copy -- same object, same reference
shallow = original.copy()             # or copy.copy(original), or original[:]
deep = copy.deepcopy(original)          # recursively copies EVERYTHING, including nested objects

shallow[2].append(99)                    # mutates the INNER list -- affects 'original' too, since it's shared
deep[2].append(100)                        # does NOT affect 'original' -- fully independent

Common Mistake: A shallow copy duplicates the outer container but still shares references to any nested mutable objects inside it. If your data has nested lists/dicts and you need true independence, copy.deepcopy() is the one that actually delivers that.

Sorting with Custom Keys

Controlling exactly how `sorted()` orders complex data, including multi-field sorts.

people = [("Ada", 36), ("Linus", 54), ("Grace", 36)]

sorted(people, key=lambda p: p[1])                       # sort by age
sorted(people, key=lambda p: p[1], reverse=True)            # descending
sorted(people, key=lambda p: (p[1], p[0]))                     # sort by age, THEN name as a tiebreaker

from operator import itemgetter
sorted(people, key=itemgetter(1))                                 # equivalent to the lambda, often faster

d = {"b": 2, "a": 1, "c": 3}
sorted(d.items(), key=lambda item: item[1])                            # sort a dict's items by value

Note: Python's sort is stable — items that compare equal keep their original relative order. That's exactly what makes sorted(people, key=lambda p: (p[1], p[0])) work correctly as a multi-field sort: sorting once by a tuple key, or sorting twice in reverse priority order, both rely on that stability.

Unpacking Everywhere

The `*`/`**` unpacking syntax that shows up in assignment, function calls, and merging collections.

a, b, c = [1, 2, 3]                        # sequence unpacking
first, *rest = [1, 2, 3, 4]                    # extended unpacking

func_args = (1, 2)
func_kwargs = {"c": 3}
def f(a, b, c): return a + b + c
f(*func_args, **func_kwargs)                      # unpack into a function call

combined_list = [*[1, 2], *[3, 4]]                  # merge lists via unpacking -- [1, 2, 3, 4]
combined_dict = {**{"a": 1}, **{"b": 2}}              # merge dicts via unpacking

Note: */** unpacking is the same mechanism whether you're merging collections, splitting a sequence into a head and a tail, or passing a dynamic set of arguments into a function — one syntax, several very handy uses.

Mutable vs Immutable Objects

Which types can change in place, and why that distinction quietly explains a lot of confusing bugs.

Mutable Immutable
list, dict, set int, float, str, tuple, frozenset, bool
def append_item(lst, item):
    lst.append(item)         # mutates the CALLER's list -- no return needed, but it's a side effect

nums = [1, 2]
append_item(nums, 3)
nums                     # [1, 2, 3] -- changed, because lists are mutable and passed by reference

a = [1, 2]
b = a                       # b is an ALIAS for a, not a copy
b.append(3)
a                              # [1, 2, 3] -- 'a' changed too, because they're the same object

Interview Trap: Python doesn't have "pass by value" or "pass by reference" in the C/C++ sense — it's "pass by object reference." Whether a function call appears to "mutate the original" depends entirely on whether the object itself is mutable, not on any special argument-passing mode.

Memory Management, Concurrency & Async

References, Garbage Collection & Memory

How CPython decides when an object's memory can be reclaimed.

import sys
sys.getrefcount(x)              # reference count -- how many places point at this object

import gc
gc.collect()                       # force a garbage collection pass -- rarely needed manually

import weakref
ref = weakref.ref(some_object)       # a reference that does NOT keep the object alive

Note: CPython primarily uses reference counting — an object is freed the moment its reference count hits zero. A separate cyclic garbage collector handles reference cycles (objects referencing each other) that counting alone can't resolve. Weak references exist specifically to avoid creating those cycles (e.g., in caches).

Concurrency: Threads & Processes

Running work in parallel — and the GIL-shaped reason the right tool depends on whether your work is CPU-bound or I/O-bound.

import threading

def worker():
    print("working")

t = threading.Thread(target=worker)
t.start(); t.join()

lock = threading.Lock()
with lock:                     # prevents a race condition on shared state
    shared_value = 1

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

with ThreadPoolExecutor(max_workers=4) as ex:          # good for I/O-bound work (network, disk)
    results = list(ex.map(worker, range(4)))

with ProcessPoolExecutor(max_workers=4) as ex:            # good for CPU-bound work -- true parallelism
    results = list(ex.map(worker, range(4)))

Interview Tip: CPython's Global Interpreter Lock (GIL) means only one thread executes Python bytecode at a time, so threads don't give real parallelism for CPU-bound work — they're still excellent for I/O-bound work, though, since the GIL is released during I/O waits. For genuine CPU-bound parallelism, use multiprocessing/ProcessPoolExecutor instead.

Asynchronous Programming: async / await

Concurrency for I/O-bound work using a single thread and cooperative scheduling, instead of OS threads.

import asyncio

async def fetch_data():                     # a coroutine -- doesn't run until awaited/scheduled
    await asyncio.sleep(1)                     # yields control back to the event loop while 'waiting'
    return "data"

async def main():
    result = await fetch_data()                    # await pauses THIS coroutine, not the whole program
    task1 = asyncio.create_task(fetch_data())         # schedule concurrently
    task2 = asyncio.create_task(fetch_data())
    results = await asyncio.gather(task1, task2)         # run both concurrently, wait for both

asyncio.run(main())                                        # entry point that starts the event loop

async def async_gen():
    for i in range(3):
        yield i               # an async generator -- iterate with 'async for'

Note: async/await gives concurrency, not parallelism — it's built around a single-threaded event loop that switches between tasks whenever one is waiting on I/O. It doesn't speed up CPU-bound work at all; that's what multiprocessing is for.

Networking, Databases & Serialization

HTTP Requests & Basic Networking

Making web requests and handling the responses — usually via the third-party `requests` library rather than the lower-level standard-library tools.

import requests            # third-party, but the de facto standard for HTTP in Python

response = requests.get("https://api.example.com/users", params={"page": 1})
response.status_code; response.json(); response.text

response = requests.post("https://api.example.com/users", json={"name": "Ada"})

response.raise_for_status()               # raises an exception for 4xx/5xx responses instead of silently continuing

import socket                                # low-level, standard library
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

Best Practice: Always call .raise_for_status() (or check .status_code explicitly) after a request — silently continuing past a 404 or 500 response as if it succeeded is a common source of confusing downstream failures.

Database Basics with sqlite3

Connecting to a database, running SQL, and handling the results — using the standard library's built-in SQLite support.

import sqlite3

conn = sqlite3.connect("app.db")            # creates the file if it doesn't exist
cursor = conn.cursor()

cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Ada",))       # ? placeholder -- prevents SQL injection
conn.commit()                                                              # required to persist INSERT/UPDATE/DELETE

cursor.execute("SELECT * FROM users WHERE name = ?", ("Ada",))
cursor.fetchone(); cursor.fetchall()

with conn:                    # 'with conn' auto-commits or rolls back a transaction on exit
    conn.execute("UPDATE users SET name = ? WHERE id = ?", ("Ada Lovelace", 1))

conn.close()

Warning: Never build SQL with plain string formatting (f"SELECT * FROM users WHERE name = '{name}'") — it's a direct SQL injection vector. Always use parameterized queries with ? placeholders instead.

Data Serialization Formats

The tradeoffs between the common ways to turn Python objects into storable/transmittable data.

Format Human-readable? Python-specific? Notes
JSON Yes No Universal, widely supported — the default choice for interchange
Pickle No Yes Preserves almost any Python object exactly, but insecure to unpickle untrusted data
CSV Yes No Great for flat tabular data, awkward for nested structures
XML Yes No Verbose, still common in enterprise/legacy systems
YAML (concept) Yes No More human-friendly than JSON for config files, needs a third-party library (PyYAML)

Warning: Never call pickle.load() on data from an untrusted source — unpickling can execute arbitrary code as a side effect of deserialization. It's fine for your own trusted, internal data; it's a real security risk otherwise.

Style, Idioms, Pitfalls & Performance

PEP 8 & Documentation Basics

The community style guide, and the difference between a comment and a docstring.

Guideline Convention
Indentation 4 spaces, never tabs
Line length 79 characters (many teams relax this to ~100)
Naming snake_case for functions/variables, PascalCase for classes, ALL_CAPS for constants
Imports Standard library, then third-party, then local — each group separated by a blank line
Whitespace Spaces around operators (x = 1, not x=1); no trailing whitespace
def add(a, b):
    """Return the sum of a and b.

    Args:
        a: first number
        b: second number
    """
    return a + b       # inline comment -- explains WHY, not just what the code does

help(add)              # docstrings are accessible at runtime via help() or __doc__

Best Practice: Comments should explain why, not restate what the code obviously does — x += 1 # increment x adds nothing; x += 1 # account for the header row actually earns its place.

Common Python Idioms: EAFP & LBYL

Two philosophies for handling things that might go wrong, and the situations Python code typically favors one over the other.

# LBYL -- 'Look Before You Leap'
if "key" in d:
    value = d["key"]
else:
    value = None

# EAFP -- 'Easier to Ask Forgiveness than Permission' -- the more Pythonic default
try:
    value = d["key"]
except KeyError:
    value = None

# common shorthand idioms
value = d.get("key")                                 # dictionary default without an explicit try/except
count = counts.setdefault("x", 0) + 1                    # conditional assignment via a dict method
for i, item in enumerate(items):                            # enumerating instead of manual index tracking
    pass

Note: EAFP is generally preferred in Python — it also avoids a subtle race condition where the state checked in an if (LBYL) could change before the following line executes, particularly in multi-threaded or multi-process code.

Common Pitfalls to Avoid

The mistakes that show up in almost every Python codebase at some point — usually more than once.

Pitfall What actually happens
Mutable default arguments The default is created ONCE and shared across all calls that don't override it
Modifying a list while iterating over it Silently skips or repeats elements — iterate over a copy (for x in list[:]) instead
Floating-point precision 0.1 + 0.2 != 0.3 exactly — use Decimal or round comparisons when it matters
Late-binding closures in a loop A lambda created in a loop captures the LOOP VARIABLE by reference, not its value at creation time
Variable shadowing A local variable with the same name as a built-in or outer variable silently hides it
Circular imports Two modules importing each other can raise ImportError depending on import order
is vs == Using is for value comparison works by accident on small cached ints/strings, then fails on larger ones
# late-binding closure trap
funcs = [lambda: i for i in range(3)]
[f() for f in funcs]              # [2, 2, 2] -- NOT [0, 1, 2], because 'i' is looked up when called, not when created

# the fix: capture the value as a default argument
funcs_fixed = [lambda i=i: i for i in range(3)]
[f() for f in funcs_fixed]           # [0, 1, 2]

Interview Trap: The late-binding closure issue is one of the most-asked "gotcha" questions in Python interviews specifically because the wrong answer looks so reasonable at first glance.

Performance Optimization Basics

The habits that actually move the needle in ordinary Python code, roughly in order of impact.

# efficient string building
parts = [str(i) for i in range(1000)]
joined = "".join(parts)              # far better than repeated += in a loop

# local variable access is faster than repeated attribute/global lookups in a hot loop
def process(items):
    append = result.append          # cache the bound method locally if calling it thousands of times
    ...

# generators over lists when you don't need to keep everything in memory
total = sum(x * x for x in range(1_000_000))          # generator expression -- no intermediate list

import functools
@functools.lru_cache(maxsize=None)          # cache expensive, repeatable function calls
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

import cProfile
cProfile.run("fib(20)")                          # profile BEFORE optimizing -- measure, don't guess

Pro Tip: Always profile before optimizing. It's extremely common to spend an afternoon micro-optimizing a line that accounts for 0.1% of total runtime while the real bottleneck sits somewhere else entirely — cProfile (or a line profiler) tells you where time is actually going.

Security Basics & Project Structure

Security Fundamentals

The handful of practices that prevent the most common, most damaging classes of bugs from becoming vulnerabilities.

Risk Mitigation
eval()/exec() on user input Avoid entirely — there's almost always a safer, more specific alternative (e.g., ast.literal_eval for literals)
Unsafe deserialization Never pickle.load() untrusted data — prefer JSON for anything from outside your own system
SQL injection Always use parameterized queries (? placeholders), never string-format SQL directly
Hardcoded secrets Load API keys/passwords from environment variables or a secrets manager, never commit them to source
Plaintext passwords Hash with a purpose-built algorithm (bcrypt, argon2) — never store or compare passwords in plaintext
import ast
ast.literal_eval("[1, 2, 3]")           # safely evaluates a Python LITERAL, not arbitrary code -- unlike eval()

import os
api_key = os.environ["API_KEY"]           # read a secret from the environment, not a hardcoded string

Warning: eval() on any input that could come from a user, even indirectly, is a direct code-execution vulnerability. If you only need to parse a literal (a list, dict, number, string), ast.literal_eval() does that safely.

Project Structure Conventions

A layout that scales from a small script to a proper installable package, without a painful restructure later.

my_project/
├── src/
│   └── my_package/
│       ├── __init__.py
│       └── core.py
├── tests/
│   └── test_core.py
├── pyproject.toml          # modern dependency + build config (replacing setup.py for most new projects)
├── requirements.txt          # or defined inside pyproject.toml
├── .env                        # local secrets/config -- should be in .gitignore, never committed
├── .gitignore
└── README.md

Best Practice: A src/ layout (package code under src/my_package/ rather than directly in the project root) avoids a common trap where tests accidentally import the local source directory instead of the properly installed package — worth adopting even for small projects that might grow.

Modern Python Version Features

Recent Language Additions

Syntax that didn't exist a few Python versions ago — worth knowing which release introduced what, especially if supporting older versions.

Feature Introduced in Example
Assignment expressions (walrus) 3.8 if (n := len(items)) > 10: ...
Structural pattern matching 3.10 match command: case ["go", d]: ...
Union type syntax 3.10 def f(x: int | str) -> None: ... instead of Union[int, str]
Exception groups 3.11 except* ValueError: — handling multiple simultaneous exceptions
tomllib 3.11 import tomllib; tomllib.load(f) — built-in TOML parsing
{var=} f-string debugging 3.8 f"{x=}" prints both the expression and its value
# walrus operator -- assign and use a value in the same expression
if (count := len(data)) > 100:
    print(f"Large dataset: {count} items")

while (chunk := file.read(1024)):          # a very common, genuinely useful pattern
    process(chunk)

Note: The walrus operator (:=) doesn't do anything you couldn't do with an extra line before — but it removes a real class of bugs where a value is computed once for a condition and then recomputed (possibly inconsistently) inside the block.

Quick Reference Tables

One-Page Concept Lookup

The core building blocks, grouped by what you're trying to do.

Task Reach for
Store ordered, mutable data list
Store fixed, unchangeable data tuple
Store unique items, set algebra set
Store key-value pairs dict
Build a collection concisely list/set/dict comprehension
Process a large sequence lazily generator expression or function with yield
Handle an expected failure try/except, specific exception type
Guarantee cleanup happens with statement / context manager
Add behavior to a function decorator
Parse text by structure re module
Read/write structured config or API data json module
Isolate project dependencies virtual environment (venv) + pip

Decision Guide: Which Tool Do I Need?

A quick flow for the recurring "which one was it again" questions.

Need a container?
  -> Ordered, changeable?              use list
  -> Ordered, fixed?                    use tuple
  -> Unique items only?                 use set
  -> Key-value lookups?                 use dict

Need to handle something that might fail?
  -> Expected, recoverable?             try/except (EAFP)
  -> Truly exceptional/unexpected?      let it raise, log it

Need concurrency?
  -> I/O-bound (network, disk)?         threading or asyncio
  -> CPU-bound (heavy computation)?     multiprocessing

Need to copy something?
  -> Flat data, no nesting?             .copy() / slice copy
  -> Nested lists/dicts?                copy.deepcopy()

Need to format text?
  -> New code?                          f-strings
  -> Legacy codebase already using it?  str.format() (avoid % formatting in new code)