Py 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
TabErrorin 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_RETRIEScan 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")raisesValueError—int()can't parse a decimal point directly from a string. Go throughfloat()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 astr, 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 asTypeErrorthe 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;iscompares identity (same object in memory).a == bcan beTruewhilea is bisFalsefor two separately-created equal lists — and small integers/short strings are cached by CPython, which makesiscomparisons 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)gives2, not3— Python'sround()uses "round half to even" (banker's rounding), which surprises almost everyone the first time they hit it.
Strings
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'raisesTypeError. 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 returnsNone— writingnums = nums.sort()silently throws your list away and leavesnumsasNone. Use the plainnums.sort()statement, orsorted(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 integer5in 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
setobjects still don't guarantee order — never rely on the iteration order of aset.
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]raisesKeyErroron 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 like0, 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 == Noneworks, butif x is Noneis the idiomatic and slightly faster form —Noneis 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/casewas added in Python 3.10 — if you're supporting older Python versions, you'll need anif/elifchain 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/whileloop'selseclause 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, whererange()eagerly built a full list (that's whatxrange()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
deffunction 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
RecursionErrorregardless 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 theif __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., viapython -m), not when run directly as a standalone script — a frequent source ofImportError: 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([])andany([])both apply to the empty sequence, and their answers surprise people in opposite directions:all([])isTrue(vacuously — no item fails), andany([])isFalse(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 bareexcept:), 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 |
File Handling & Paths
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
withmeans 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 preferwith.
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.Pathover manually building paths withos.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
privatekeyword. A single leading underscore is a convention signaling "internal use"; a double leading underscore triggers name mangling (__secretbecomes_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 = []) raisesValueErrorat 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 genericwrapperinstead 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
Truefrom__exit__suppresses the exception that occurred inside thewithblock — usually not what you want, and easy to do by accident if__exit__doesn't explicitly returnFalse/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 runtime —
def f(x: int): return xhappily accepts a string if you callf("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'. Usere.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.3in plain floating point because binary floats can't represent most decimal fractions exactly.Decimalis 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()raisesTypeErroron objects it doesn't know how to serialize (datetimes, custom classes, sets). Thedefault=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])), sincecsvhas 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
listis O(n) for inserting/removing at the front;dequeis O(1) at both ends. If you're usinglist.insert(0, x)orlist.pop(0)in a loop,dequeis 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(), andrepeat()(without a count) are genuinely infinite — always pair them withislice()or abreakcondition, 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 overos.system("...")orshell=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:
argparsegenerates a working--helpflag automatically from the arguments you define — a real time-saver over hand-parsingsys.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 apyproject.toml) to version control, never a populated.venvfolder — 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:
assertstatements are stripped out entirely when Python runs with the-O(optimize) flag — never use bareassertfor 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
loggingmodule instead of scatteredprint()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/ProcessPoolExecutorinstead.
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/awaitgives 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_codeexplicitly) after a request — silently continuing past a404or500response 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 xadds nothing;x += 1 # account for the header rowactually 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 undersrc/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)
