Basics
1. Python Fundamentals
Python Type Hierarchy
Numbers
- Integral
- Integers
- Booleans
- Non-Integral
- Floats (C doubles)
- Complex
- Decimals
- Fractions
Collections
- Sequences
- Mutable: Lists
- Immutable: Tuples, Strings
- Sets
- Mutable: Sets
- Immutable: Frozen Sets
- Mappings
- Dictionaries
Callables
- User-Defined Functions
- Generators
- Classes
- Instance Methods
- Class Instances (
__call__()) - Built-in Functions (e.g.
len(),open()) - Built-in Methods (e.g.
my_list.append(x))
Singletons
NoneNotImplementedEllipsis(...)
Naming Conventions
Identifier Names
- Case-sensitive:
my_var,my_Var,ham,Hamare all different identifiers - Must follow certain rules
- Should follow certain conventions
Rules (Must)
- Must start with an underscore (
_) or a letter (a-z,A-Z) - Followed by any number of underscores (
_), letters (a-z,A-Z), or digits (0-9) - Legal examples:
var,my_var,index1,index_1,_var,__var,__lt__ - Cannot be a reserved word:
None,True,Falseand,or,notif,else,eliffor,while,break,continue,passdef,lambda,global,nonlocal,return,yielddel,in,is,assert,classtry,except,finally,raiseimport,from,with,as
Conventions
_my_var(single underscore) — convention indicating "internal use" or "private" objects. Objects named this way are not imported byfrom module import *__my_var(double underscore, leading) — used to "mangle" class attributes; useful in inheritance chains__my_var__(double underscore, leading and trailing) — used for system-defined names with special meaning to the interpreter (e.g.__init__). Don't invent your own — stick to Python's predefined ones.- Example:
x < ycallsx.__lt__(y)
- Example:
Other Naming Conventions (from PEP 8 Style Guide)
| Type | Convention | Example |
|---|---|---|
| Packages | short, all-lowercase names; preferably no underscores | utilities |
| Modules | short, all-lowercase names; can have underscores | db_utils, dbutils |
| Classes | CapWords (upper camel case) | BankAccount |
| Functions | lowercase, snake_case | open_account |
| Variables | lowercase, snake_case | account_id |
| Constants | all-uppercase, underscore-separated | MIN_APR |
- Reference: https://www.python.org/dev/peps/pep-0008/ — "This is a should-read!"
- Quote: "A foolish consistency is the hobgoblin of little minds" — Emerson
Multi-Line Statements
Physical vs. Logical Lines
- Physical line of code — ends with a physical newline character
- Logical line of code — ends with a logical
NEWLINEtoken (after tokenizing) - Sometimes physical newlines are ignored in order to combine multiple physical lines into a single logical line of code, terminated by a logical
NEWLINEtoken - Conversion between physical and logical lines can be implicit or explicit
Implicit Line Joining
Happens automatically inside expressions in:
- list literals:
[ ] - tuple literals:
( ) - dictionary literals:
{ } - set literals:
{ } - function arguments / parameters
Supports inline comments.
Example:
[1, [1, #item 1
2, 2, #item 2
3] 3 #item 3
]
def my_func(a,
b, #comment
c):
print(a, b, c)
my_func(10, #comment
20, 30)
Explicit Line Joining
- Break up statements over multiple lines explicitly using the
\(backslash) character - Multi-line statements are not implicitly converted to a single logical line — the backslash is required
- Comments cannot be part of a statement, not even a multi-line statement
Example (valid):
if a \
and b \
and c:
Example (invalid — comment breaks it):
if a \
and b \ #comment
and c:
Multi-Line String Literals
- Created using triple delimiters:
'''...'''(single) or"""..."""(double) - Non-visible characters (newlines, tabs, etc.) are actually part of the string — basically anything you type
- Can use escaped characters (e.g.
\n,\t), string formatting, etc. - A multi-line string is just a regular string
- Multi-line strings are not comments, although they can be used as such — especially as docstrings
Imports
from module import name lets you import specific items from a module directly into your namespace, so you use them without the module prefix.
Basic forms
from math import sqrt
print(sqrt(16)) # 4.0, no need for math.sqrt
from math import sqrt, pi, ceil # multiple names
Import everything (generally discouraged)
from math import *
This pollutes your namespace and can cause name clashes — avoid it in real code.
Renaming with as
from math import sqrt as square_root
print(square_root(25)) # 5.0
From a package/submodule
from os import path
from os.path import join, exists
from collections import OrderedDict
Relative imports (within a package)
from . import sibling_module # same package
from .subpackage import something # subpackage
from ..parent_module import thing # parent package
vs. plain import
import math
math.sqrt(16) # need the prefix
from math import sqrt
sqrt(16) # no prefix needed
- Use
import modulewhen you want clarity about where functions come from. - Use
from module import namewhen you're using something frequently and want shorter code.
Modules and Packages
Module
A single Python file containing code (functions, classes, variables).
# mymodule.py
def greet():
print("hello")
import mymodule
mymodule.greet()
Package
A directory containing multiple modules, plus an __init__.py file (marks it as a package). A way to organize related modules into a hierarchy.
mypackage/
__init__.py
module1.py
module2.py
subpackage/
__init__.py
module3.py
import mypackage.module1
from mypackage import module2
from mypackage.subpackage import module3
Key differences
| Module | Package | |
|---|---|---|
| What it is | A single .py file | A directory of modules |
| Requires | Nothing special | __init__.py (optional marker in Python 3.3+, but still common practice) |
| Purpose | Groups related code | Groups related modules |
| Example | math, os.path's path | os, numpy, collections |
Analogy: a module is like a single chapter (file), a package is like a book made of multiple chapters organized in a folder.
Real-world example
import numpy # numpy is a package
numpy.array([1, 2]) # array is defined in one of numpy's modules, but numpy re-exports it
import numpy.random # random is a submodule inside the numpy package
Every package is (usually) made of modules, but a module isn't necessarily part of a package — it can just be a standalone .py file.
2. Operators & Expressions
Ternary Operator
Syntax (conditional expression):
value = true_result if condition else false_result
Example:
x = 5
label = "positive" if x > 0 else "non-positive"
print(label) # "positive"
Extras
- Chained:
a if cond1 else b if cond2 else c - In a function call:
print("yes" if flag else "no") - As a default value:
name = user_input if user_input else "Guest"(oruser_input or "Guest"for falsy-checks) - If multiple statements are needed per branch, use a regular
if/elseblock instead — the ternary is expression-only.
The not Operator
not can look like a function call, but isn't one:
not(True) # False
not (x > 5) # works fine
- This works, but not because
notis a function being called with parentheses. - The parentheses here are just a normal grouping expression — same as writing
(True)or(x > 5)— andnotis applied to whatever that parenthesized expression evaluates to. - So
not(x > 5)is really parsed asnot (x > 5), functionally identical to justnot x > 5(sincenothas lower precedence than comparison operators anyway).
Where this can trip people up
not (1, 2, 3) # False — this is NOT "not applied to each element"
(1, 2, 3)is a tuple, and a non-empty tuple is truthy, sonoton it givesFalse.- If coming from a language where
not(...)looks like a function call, it's easy to misread this.
Bottom line
not(expr)is valid and common, but it'snotapplied to a parenthesized expression — not a function call.- Style-wise,
not (expr)(with a space) or justnot expris usually preferred for clarity, since the parens can visually suggest a function call that isn't actually happening.
is vs ==
The is operator checks identity — whether two variables point to the exact same object in memory — as opposed to ==, which checks equality of values.
Basic behavior
a = [1, 2, 3]
b = [1, 2, 3]
c = a
a == b # True — same values
a is b # False — different objects in memory
a is c # True — c refers to the same object as a
Under the hood, a is b is equivalent to id(a) == id(b) — ties back to id().
Common correct use: comparing to None
if x is None:
print("x is None")
This is the idiomatic way to check for None — not x == None. Why? Because is guarantees checking against the singleton None object itself, and it can't be fooled by a custom __eq__ override. PEP 8 explicitly recommends is None / is not None.
if x is not None:
...
The classic gotcha: small integers and strings
a = 5
b = 5
a is b # True — small ints are cached/interned by CPython
x = 1000
y = 1000
x is y # False (usually) — large ints aren't guaranteed to be cached
s1 = "hello"
s2 = "hello"
s1 is s2 # True — string literals are often interned
s3 = "hello world!"
s4 = "hello world!"
s3 is s4 # may be True or False — not guaranteed
This caching behavior is a CPython implementation detail, not a language guarantee — relying on it for anything other than None/True/False checks is a bug waiting to happen.
is vs == — when to use which
is | == | |
|---|---|---|
| Checks | identity (same object) | equality (same value) |
| Use for | None, True, False, sentinel objects | almost everything else |
| Can be customized | No | Yes, via __eq__ |
class AlwaysEqual:
def __eq__(self, other):
return True
x = AlwaysEqual()
x == "anything" # True — __eq__ overridden
x is "anything" # False — is can't be fooled
is not
if x is not None:
...
Reads more naturally than not x is None (also legal but less common).
Rule of thumb: use is only for None, True, False, and singleton/sentinel checks. Use == for comparing actual values.
3. Control Flow & Iteration
No Switch, No Do-While
- Python has no switch statement
- Python has no do-while loop
- Can emulate a do-while easily using
while True:
- Can emulate a do-while easily using
Loop else Clauses
while...else
- The
elseblock runs only if the loop finishes because its condition becameFalse - It does not run if the loop exits via
break
else vs finally
finally(atry/exceptconstruct) runs unconditionally, no matter what happenselseon a loop is conditional — it only runs ifbreakwas never hit
for...else
- Same rule as
while...else, just applied to exhausting an iterable instead of a condition going false - Both
while...elseandfor...elseare handy for "search" patterns — lets you skip a manual flag variable to track whether something was found
For Loops and Iterables
- No C-style
forloop in Python - Only the
for...inloop - Works with iterables
range()creates an iterable- Collections and iterables are not the same thing
- Strings and tuples are iterables
Unpacking
The correct term is unpacking (also iterable unpacking or tuple unpacking, though it works on any iterable, not just tuples). Python's official docs and PEP 3132 refer to it as "unpacking."
Basic unpacking
a, b, c = (1, 2, 3)
a, b, c = [1, 2, 3] # works with lists too
a, b, c = "xyz" # even strings! a='x', b='y', c='z'
The right-hand side just needs to be iterable with the right number of items.
Starred unpacking (PEP 3132)
first, *middle, last = [1, 2, 3, 4, 5]
# first = 1, middle = [2, 3, 4], last = 5
*head, tail = [1, 2, 3]
# head = [1, 2], tail = 3
The starred name collects "everything else" into a list. Only one * is allowed per unpacking.
Swapping without a temp variable
a, b = b, a
This is unpacking in disguise — the right side builds a tuple (b, a), then it's unpacked into a, b.
Function calls: unpacking arguments
def add(x, y, z):
return x + y + z
nums = [1, 2, 3]
add(*nums) # unpacks list into positional args
d = {'x': 1, 'y': 2, 'z': 3}
add(**d) # unpacks dict into keyword args
* unpacks iterables into positional arguments; ** unpacks mappings into keyword arguments.
Unpacking into function definitions
def f(a, b, *args, **kwargs):
print(a, b, args, kwargs)
f(1, 2, 3, 4, x=5)
# a=1, b=2, args=(3, 4), kwargs={'x': 5}
Here *args/**kwargs collect rather than unpack — same syntax, opposite direction, which trips people up.
Merging with unpacking (PEP 448)
combined = [*[1, 2], *[3, 4]] # [1, 2, 3, 4]
merged = {**{'a': 1}, **{'b': 2}} # {'a': 1, 'b': 2}
Nested unpacking
((a, b), c) = ((1, 2), 3)
# a=1, b=2, c=3
Summary of terms
- Unpacking — the general term, assigning iterable contents to multiple targets
- Starred expression / extended unpacking — the
*nameform (PEP 3132) - Argument unpacking — using
*/**in function calls - Argument packing (less formal term) — using
*args/**kwargsin function definitions
enumerate()
enumerate() is a built-in Python function that adds a counter to an iterable, giving you both the index and the value as you loop.
Basic usage
fruits = ['apple', 'banana', 'cherry']
for i, fruit in enumerate(fruits):
print(i, fruit)
# 0 apple
# 1 banana
# 2 cherry
Without enumerate(), you'd have to do this manually:
i = 0
for fruit in fruits:
print(i, fruit)
i += 1
or the clunkier range(len(...)) pattern:
for i in range(len(fruits)):
print(i, fruits[i])
enumerate() is the idiomatic, Pythonic way to avoid both.
Custom start value
for i, fruit in enumerate(fruits, start=1):
print(i, fruit)
# 1 apple
# 2 banana
# 3 cherry
What it actually returns
enumerate() returns an enumerate object — an iterator that yields (index, value) tuples. That's why it pairs naturally with tuple unpacking in a for loop:
list(enumerate(['a', 'b']))
# [(0, 'a'), (1, 'b')]
Each pair is a tuple, and for i, val in enumerate(...) is unpacking each tuple as it's produced — ties back to Unpacking.
Works with any iterable, not just lists
for i, char in enumerate("abc"):
print(i, char)
# 0 a
# 1 b
# 2 c
Common use case: building a dict from a list
fruits = ['apple', 'banana', 'cherry']
index_map = {fruit: i for i, fruit in enumerate(fruits)}
# {'apple': 0, 'banana': 1, 'cherry': 2}
Signature: enumerate(iterable, start=0) — takes any iterable and an optional starting index, defaults to 0.
4. Built-in Functions
id(), hex(), str()
Three built-in global functions, each doing something quite different.
id()
Returns the identity of an object — a unique integer for that object's lifetime (in CPython, this is typically the memory address).
x = [1, 2, 3]
id(x) # e.g. 140234567891200
- Used to check if two variables refer to the same object (not just equal values):
a = [1, 2]
b = [1, 2]
a == b # True (same value)
a is b # False (different objects)
id(a) == id(b) # False
isinternally comparesid()values.
hex()
Converts an integer to its hexadecimal string representation (prefixed with 0x).
hex(255) # '0xff'
hex(16) # '0x10'
hex(-10) # '-0xa'
- Only works on integers (or objects with
__index__), not floats or strings. - Related functions:
bin()(binary),oct()(octal). - To go the other way (string → int), use
int(s, 16):
int('0xff', 16) # 255
int('ff', 16) # 255
str()
Converts an object into its string representation. One of the most commonly used built-ins.
str(42) # '42'
str(3.14) # '3.14'
str([1, 2, 3]) # '[1, 2, 3]'
str(True) # 'True'
str(None) # 'None'
- Also the type/class for strings —
stris a type, and callingstr(x)invokesx.__str__()under the hood. - Different from
repr():str()aims for readability,repr()aims for an unambiguous, code-like representation.
class Point:
def __str__(self):
return "a point"
def __repr__(self):
return "Point()"
p = Point()
str(p) # 'a point'
repr(p) # 'Point()'
Quick connection between the three
x = 255
id(x) # some big int, e.g. 4327462800
hex(id(x)) # hex string of that memory address, e.g. '0x101a2b6d0'
str(hex(id(x))) # same thing, since hex() already returns a str
type() vs isinstance()
Both check an object's type, but they differ in how strictly and how they handle inheritance.
Basic usage
x = 5
type(x) # <class 'int'>
type(x) == int # True
isinstance(x, int) # True
Key difference: inheritance
isinstance() respects subclassing — returns True if the object is an instance of the class or any subclass of it. type() == does an exact match only.
class Animal:
pass
class Dog(Animal):
pass
d = Dog()
isinstance(d, Animal) # True — Dog is a subclass of Animal
type(d) == Animal # False — type(d) is exactly Dog, not Animal
type(d) == Dog # True
This is the most important practical difference, and it's why isinstance() is almost always preferred for type checks — it works correctly with polymorphism.
bool and int — a classic gotcha
type(True) == int # False — type(True) is exactly bool
isinstance(True, int) # True — bool is a subclass of int!
Since bool subclasses int in Python, isinstance sees True/False as also being ints. This trips people up constantly.
Checking multiple types at once
isinstance() accepts a tuple of types — type() has no equivalent shortcut:
isinstance(x, (int, float)) # True if x is int OR float
Duck typing / __instancecheck__
isinstance() can be customized via metaclasses (__instancecheck__), and works with Abstract Base Classes:
from collections.abc import Iterable
isinstance([1, 2, 3], Iterable) # True
isinstance("abc", Iterable) # True
type() comparisons can't hook into this — they only ever check the literal, exact class.
When you'd actually want type() == instead
Rare, but useful when deliberately wanting to exclude subclasses:
def strict_check(x):
if type(x) == list: # only exact lists, not subclasses of list
...
Shows up in edge cases like preventing subclass instances from being treated identically to the base class — but uncommon, and often a sign to reconsider the design.
Summary
type() == | isinstance() | |
|---|---|---|
| Exact match only | Yes | No — includes subclasses |
Handles bool/int quirk | No | Yes (bool counts as int) |
| Multiple types at once | No | Yes, via tuple |
| Works with ABCs | No | Yes |
| Pythonic / recommended | Rarely | Almost always |
Rule of thumb: use isinstance() unless there's a specific reason to reject subclasses.
5. Classes & OOP
Access Modifiers
- No access modifiers in Python classes (no
private/protected/publickeywords) - Use
_varas convention instead
Getters and Setters
The idiomatic approach uses the @property decorator.
class Person:
def __init__(self, name):
self._name = name # convention: leading underscore for "private"
@property
def name(self): # getter
return self._name
@name.setter
def name(self, value): # setter
if not value:
raise ValueError("name cannot be empty")
self._name = value
Usage looks like plain attribute access — no parentheses:
p = Person("Alice")
print(p.name) # calls the getter -> "Alice"
p.name = "Bob" # calls the setter
p.name = "" # raises ValueError
Key points
_nameis the actual storage attribute;nameis the public-facing property.- The getter method is decorated with
@property. - The setter uses
@<propertyname>.setterand must have the same method name. - Can also add a
@name.deleterfordel p.nameif needed. - If only the getter is defined (no setter), the property becomes read-only.
- Preferred over Java-style
get_name()/set_name()methods because it lets you start with plain attributes and add validation/logic later without changing the public interface. - The getter and setter share the same name (
name), but this is not method overloading — Python has no overloaded methods. - Python uses
raiseto raise an exception (notthrow, as in Java/C#/JS).
Classes
Defining a class
class Rectangle:
def __init__(self, height, width):
self.height = height
self.width = width
class Rectangle:— creates an empty object (the class), passed to__init____init__runs right after object creationselfrefers to the new object — can technically be named anything, but "self" is convention- Inside
__init__, lines likeself.height = heightset ("get") props on the new object
Instance methods
def area(self): # instance method
return self.height * self.width
- The
selfparameter name is optional/conventional, not enforced by the language
Calling
r1 = Rectangle(10, 20)
r1.area()
r1.area()automatically passesr1as theselfargument — kind of similar tothisbinding in JS
Special methods in a class
__str__(self)— liketoString()in JS; works withstr(), e.g.str(r1)__repr__(self)— representation as a string; conventionally should let you recreate the object again__eq__(self, other)— like Java's.equals(); works with the==operator__lt__,__le__,__gt__,__ge__— similar to__eq__, for<,<=,>,>=__ne__— not equal (!=)