Every meaningful program needs to make decisions. Whether you are validating user input, routing requests, or handling edge cases, conditional statements are how your code responds to different situations at runtime. They are the foundation of control flow — the mechanism that determines which lines of code execute and in what order.
In Python, conditionals are clean, readable, and indentation-driven. There are no curly braces or explicit end keywords. If you are coming from Java, C++, or JavaScript, you will find Python’s approach refreshingly minimal. This tutorial walks through every conditional construct Python offers, from basic if statements to the modern match-case pattern matching introduced in Python 3.10.
Before writing conditionals, you need to understand the operators that produce True or False values. These are the building blocks of every condition you will ever write.
== — Equal to: checks if two values are the same!= — Not equal to: checks if two values differ< — Less than> — Greater than<= — Less than or equal to>= — Greater than or equal tox = 10 y = 20 print(x == y) # False print(x != y) # True print(x < y) # True print(x > y) # False print(x <= 10) # True print(x >= 15) # False
One critical distinction: == checks equality, while is checks identity (whether two variables point to the same object in memory). For value comparison, always use ==.
if StatementThe if statement is the most fundamental decision-making construct. It evaluates an expression, and if that expression is True, the indented block beneath it executes. If the expression is False, Python skips the block entirely.
# Basic if statement
temperature = 35
if temperature > 30:
print("It's a hot day.")
print("Stay hydrated!")
Key point: Python uses indentation (typically 4 spaces) to define the body of an if block. The first line that returns to the previous indentation level marks the end of the block. There are no braces, no end if — just consistent indentation.
# Age verification example
user_age = 21
if user_age >= 18:
print("Access granted. You are old enough to enter.")
print("This line runs regardless of the condition.")
if-else StatementWhen you need to handle both outcomes of a condition, use if-else. The else block runs when the if condition evaluates to False.
# Age verification with else
age = 16
if age >= 18:
print("You are eligible to vote.")
else:
print(f"You must wait {18 - age} more year(s) to vote.")
# Even or odd check
number = 7
if number % 2 == 0:
print(f"{number} is even.")
else:
print(f"{number} is odd.")
if-elif-else ChainWhen you have more than two possible outcomes, chain conditions together with elif (short for “else if”). Python evaluates each condition from top to bottom and executes the first block whose condition is True. If none match, the else block runs as a fallback.
# Grade calculator
score = 82
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
elif score >= 60:
grade = "D"
else:
grade = "F"
print(f"Score: {score}, Grade: {grade}")
# Output: Score: 82, Grade: B
Notice the order matters. Since Python stops at the first match, we check from highest to lowest. If we reversed the order, a score of 95 would match score >= 60 first and incorrectly receive a “D”.
# Simple calculator using if-elif
num1 = 15
num2 = 4
operator = "/"
if operator == "+":
result = num1 + num2
elif operator == "-":
result = num1 - num2
elif operator == "*":
result = num1 * num2
elif operator == "/":
if num2 != 0:
result = num1 / num2
else:
result = "Error: Division by zero"
else:
result = "Error: Unknown operator"
print(f"{num1} {operator} {num2} = {result}")
# Output: 15 / 4 = 3.75
# User role-based access control
user_role = "editor"
if user_role == "admin":
print("Full access: read, write, delete, manage users")
elif user_role == "editor":
print("Editor access: read and write")
elif user_role == "viewer":
print("Viewer access: read only")
else:
print("Unknown role. Access denied.")
You can place if statements inside other if statements. This is useful when a secondary condition only makes sense after a primary condition is met.
# Nested conditional: age and membership check
age = 25
has_membership = True
if age >= 18:
if has_membership:
print("Welcome! You have full access.")
else:
print("You are old enough, but you need a membership.")
else:
print("Sorry, you must be 18 or older.")
# Login validation with nested checks
username = "folauk"
password = "secure123"
is_active = True
if username == "folauk":
if password == "secure123":
if is_active:
print("Login successful.")
else:
print("Account is deactivated.")
else:
print("Incorrect password.")
else:
print("User not found.")
A word of caution: While nesting works, deeply nested conditionals (3+ levels) become difficult to read and maintain. If you find yourself going deeper than two levels, consider refactoring with early returns, guard clauses, or by extracting logic into helper functions. Here is the same login example refactored:
# Refactored: use early returns to avoid deep nesting
def authenticate(username, password, is_active):
if username != "folauk":
return "User not found."
if password != "secure123":
return "Incorrect password."
if not is_active:
return "Account is deactivated."
return "Login successful."
result = authenticate("folauk", "secure123", True)
print(result)
and, or, notLogical operators let you combine multiple conditions into a single expression. Mastering them is essential for writing expressive, readable conditionals.
and — Both conditions must be True# Both conditions must be True
age = 25
has_license = True
if age >= 18 and has_license:
print("You are allowed to drive.")
else:
print("You cannot drive.")
# Checking a value is within a range
score = 85
if score >= 80 and score <= 89:
print("You got a B.")
or — At least one condition must be True# At least one condition must be True
is_weekend = True
is_holiday = False
if is_weekend or is_holiday:
print("You can sleep in today!")
else:
print("Time to get up for work.")
# User role check with or
role = "moderator"
if role == "admin" or role == "moderator":
print("You can delete posts.")
not — Inverts a boolean# Invert a condition
is_logged_in = False
if not is_logged_in:
print("Please log in to continue.")
# Complex condition with and, or, not
age = 30
is_student = False
has_coupon = True
if (age < 18 or is_student) and not has_coupon:
print("Standard discount applied.")
elif has_coupon:
print("Coupon discount applied.")
else:
print("No discount available.")
Operator precedence: not is evaluated first, then and, then or. When in doubt, use parentheses to make your intent explicit. Your future self (and your teammates) will thank you.
Python does not require conditions to be strictly True or False. Every object in Python has an inherent boolean value. Understanding what is “truthy” and “falsy” will make your conditionals more Pythonic.
Falsy values (evaluate to False):
FalseNone0 (integer zero), 0.0 (float zero), 0j (complex zero)"" (empty string)[] (empty list), () (empty tuple), {} (empty dict), set() (empty set)Everything else is truthy, including non-zero numbers, non-empty strings, and non-empty collections.
# Truthy/Falsy in action
name = ""
if name:
print(f"Hello, {name}!")
else:
print("Name is empty. Please provide a name.")
# Checking if a list has items
shopping_list = ["milk", "eggs"]
if shopping_list:
print(f"You have {len(shopping_list)} item(s) to buy.")
else:
print("Your shopping list is empty.")
# Using truthiness for None checks
config = None
if not config:
print("No configuration loaded. Using defaults.")
This is idiomatic Python. Instead of writing if len(my_list) > 0:, experienced Python developers write if my_list:. It is cleaner, faster, and communicates intent more directly.
Python supports a one-line if-else expression, often called the ternary operator. The syntax is:
# Syntax: value_if_true if condition else value_if_false age = 20 status = "adult" if age >= 18 else "minor" print(status) # Output: adult
# Ternary for assigning default values
user_input = ""
username = user_input if user_input else "Anonymous"
print(f"Welcome, {username}!") # Output: Welcome, Anonymous!
# Ternary in a function return
def get_access_level(role):
return "full" if role == "admin" else "limited"
print(get_access_level("admin")) # Output: full
print(get_access_level("viewer")) # Output: limited
Use ternary expressions for simple, short conditions. If the logic is complex or involves multiple branches, stick with a regular if-elif-else block for readability.
match-case Statement (Python 3.10+)Python 3.10 introduced structural pattern matching with the match-case statement. If you are familiar with switch statements from other languages, this is Python’s more powerful equivalent. It does not just match values — it can destructure data, match types, and bind variables.
# Basic match-case: HTTP status code handler
status_code = 404
match status_code:
case 200:
print("OK - Request succeeded.")
case 301:
print("Moved Permanently.")
case 404:
print("Not Found - Resource does not exist.")
case 500:
print("Internal Server Error.")
case _:
print(f"Unhandled status code: {status_code}")
The underscore _ is the wildcard pattern — it matches anything and acts as the default case.
# match-case with combined patterns using |
command = "quit"
match command:
case "start" | "run":
print("Starting the application...")
case "stop" | "quit" | "exit":
print("Shutting down...")
case "status":
print("Application is running.")
case _:
print(f"Unknown command: {command}")
# match-case with pattern destructuring
point = (3, 0)
match point:
case (0, 0):
print("Origin")
case (x, 0):
print(f"On the X-axis at x={x}")
case (0, y):
print(f"On the Y-axis at y={y}")
case (x, y):
print(f"Point at ({x}, {y})")
# Output: On the X-axis at x=3
match-case is especially useful for command parsing, event handling, and working with structured data like API responses or ASTs. However, note that it requires Python 3.10 or later, so check your target Python version before using it in production code.
pass StatementIn Python, if statements cannot have an empty body. If you need a placeholder — perhaps you plan to implement the logic later — use the pass statement to avoid a syntax error.
# Placeholder for future implementation
if 3 > 2:
pass # TODO: implement this logic later
In practice, you will use pass during development as a stub. It does nothing but tells Python “I intentionally left this empty.”
These are mistakes that every Python developer makes at some point. Knowing them ahead of time will save you hours of debugging.
= instead of ==# WRONG: = is assignment, not comparison
# if x = 10: # SyntaxError!
# CORRECT: == is comparison
x = 10
if x == 10:
print("x is 10")
Unlike C or Java, Python will raise a SyntaxError if you use = in a condition. This is actually a helpful safeguard, but it still catches beginners off guard.
# WRONG: inconsistent indentation
# if True:
# print("indented with 4 spaces")
# print("indented with 6 spaces") # IndentationError!
# CORRECT: consistent 4-space indentation
if True:
print("line 1")
print("line 2")
Python enforces indentation as part of its syntax. Mix tabs and spaces, or use inconsistent spacing, and you will get an IndentationError. Configure your editor to insert 4 spaces when you press Tab. This is the Python community standard (PEP 8).
# Potential surprise: string vs integer comparison
user_input = "5"
threshold = 5
# This is False because "5" (str) != 5 (int)
if user_input == threshold:
print("Match")
else:
print("No match") # This prints
# Fix: convert types explicitly
if int(user_input) == threshold:
print("Match") # Now this prints
Python does not implicitly convert types during comparison. A string "5" is not equal to an integer 5. Always be explicit about type conversions, especially when working with user input.
# Hard to read
if user.age >= 18 and user.is_active and not user.is_banned and user.email_verified:
grant_access()
# Better: extract to a well-named variable or function
def is_eligible(user):
return (
user.age >= 18
and user.is_active
and not user.is_banned
and user.email_verified
)
if is_eligible(user):
grant_access()
# Deeply nested (avoid this)
def process_order(order):
if order is not None:
if order.is_valid:
if order.in_stock:
ship(order)
# Flat and clear (prefer this)
def process_order(order):
if order is None:
return
if not order.is_valid:
return
if not order.in_stock:
return
ship(order)
# Harder to reason about
if not is_invalid:
process()
# Clearer
if is_valid:
process()
When naming boolean variables and writing conditions, aim for positive phrasing. is_active is easier to reason about than not is_inactive.
# Complete example: Student grade report generator
def calculate_grade(score):
"""Return a letter grade based on the numeric score."""
if not isinstance(score, (int, float)):
return "Invalid"
if score < 0 or score > 100:
return "Invalid"
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
return "F"
def get_status(grade):
"""Return pass/fail status."""
return "Pass" if grade not in ("F", "Invalid") else "Fail"
# Student records
students = [
{"name": "Alice", "score": 92},
{"name": "Bob", "score": 75},
{"name": "Charlie", "score": 58},
{"name": "Diana", "score": 88},
]
for student in students:
grade = calculate_grade(student["score"])
status = get_status(grade)
print(f"{student['name']}: {student['score']}% -> {grade} ({status})")
Output:
Alice: 92% -> A (Pass) Bob: 75% -> C (Pass) Charlie: 58% -> F (Fail) Diana: 88% -> B (Pass)
if, elif, and else block must be consistently indented (use 4 spaces).if-elif-else is the workhorse of Python conditionals. Order your conditions from most specific to least specific.==, !=, <, >, <=, >=) produce boolean values that drive your conditions.and, or, not) let you combine and invert conditions. Remember precedence: not > and > or.0, None, and "" are all falsy. Use this to write concise, Pythonic checks.x if condition else y) are great for simple inline conditionals, but do not overuse them for complex logic.match-case (Python 3.10+) is powerful for pattern matching, especially when working with structured data or command dispatching.
In Python, a class is a blueprint for creating objects. Think of it like an architectural blueprint for a house: the blueprint itself is not a house, but it defines exactly how every house built from it will be structured — where the doors go, how many rooms there are, and what materials to use. From that single blueprint, you can build dozens of houses (objects), each with its own address and paint color, but all sharing the same fundamental design.
This concept is the foundation of Object-Oriented Programming (OOP). Instead of writing loose functions that pass data around (procedural style), OOP lets you bundle related data and behavior together into classes. The result is code that is easier to reason about, test, and maintain — especially as your codebase grows.
An object (also called an instance) is a concrete realization of a class, living in memory with its own data. The process of creating an object from a class is called instantiation.
At its simplest, a Python class is defined with the class keyword followed by the class name in PascalCase and a colon.
class ClassName:
"""Docstring describing the class."""
# class body: variables, methods, etc.
pass
# Creating an instance
obj = ClassName()
Note: the parentheses after the class name are optional when there is no explicit parent class. You will see both class Foo: and class Foo(): in the wild — they are equivalent when no inheritance is involved.
__init__() ConstructorThe __init__() method is Python’s constructor. It is called automatically every time you create a new instance. Its job is to initialize the object’s state — setting up the attributes that each instance needs to function.
class User:
def __init__(self, name, age):
self.name = name # instance variable
self.age = age # instance variable
# Instantiation triggers __init__
user1 = User("Alice", 30)
print(user1.name) # Alice
print(user1.age) # 30
When you call User("Alice", 30), Python internally creates a new User object and then calls User.__init__(new_object, "Alice", 30). You never call __init__ directly.
self ParameterEvery instance method in Python receives self as its first argument. self is a reference to the current instance of the class — it is how the method knows which object’s data to read or modify.
class Dog:
def __init__(self, name):
self.name = name # 'self.name' belongs to THIS specific dog
def bark(self):
# 'self' lets us access instance data inside methods
return f"{self.name} says Woof!"
rex = Dog("Rex")
buddy = Dog("Buddy")
print(rex.bark()) # Rex says Woof!
print(buddy.bark()) # Buddy says Woof!
self is not a reserved keyword — you could technically name it anything — but using self is a universal Python convention. Breaking this convention will confuse every developer who reads your code.
This distinction trips up many developers, so it is worth understanding clearly.
__init__ (or other methods) using self.variable_name. Each object gets its own independent copy.class Employee:
# Class variable — shared by ALL instances
company = "TechCorp"
employee_count = 0
def __init__(self, name, role):
# Instance variables — unique to EACH instance
self.name = name
self.role = role
Employee.employee_count += 1
emp1 = Employee("Alice", "Engineer")
emp2 = Employee("Bob", "Designer")
# Class variable is the same for both
print(emp1.company) # TechCorp
print(emp2.company) # TechCorp
# Instance variables are independent
print(emp1.name) # Alice
print(emp2.name) # Bob
# Class variable tracks state across all instances
print(Employee.employee_count) # 2
Instance methods are the most common type of method. They take self as the first parameter and can freely access and modify the object’s state.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
return self.balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
return self.balance
def get_balance(self):
return self.balance
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance})"
# Usage
account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(account) # BankAccount(owner='Alice', balance=1300)
@classmethod)A class method receives the class itself (conventionally named cls) as its first argument instead of an instance. It cannot access instance-specific data, but it can read and modify class-level state. Class methods are commonly used as alternative constructors — factory methods that create instances in different ways.
class Employee:
employee_count = 0
def __init__(self, name, role, salary):
self.name = name
self.role = role
self.salary = salary
Employee.employee_count += 1
@classmethod
def from_string(cls, employee_string):
"""Alternative constructor: create Employee from a dash-separated string."""
name, role, salary = employee_string.split("-")
return cls(name, role, float(salary))
@classmethod
def get_employee_count(cls):
return cls.employee_count
# Standard construction
emp1 = Employee("Alice", "Engineer", 95000)
# Alternative construction via class method
emp2 = Employee.from_string("Bob-Designer-85000")
print(emp2.name) # Bob
print(emp2.role) # Designer
print(emp2.salary) # 85000.0
print(Employee.get_employee_count()) # 2
Notice that from_string calls cls(...) instead of Employee(...). This makes the class method work correctly with subclasses — the cls parameter will refer to the subclass when called from one.
@staticmethod)A static method receives neither self nor cls. It behaves like a regular function but lives inside the class’s namespace for organizational purposes. Use it for utility logic that is related to the class but does not need to access any instance or class state.
class MathUtils:
@staticmethod
def is_even(number):
return number % 2 == 0
@staticmethod
def factorial(n):
if n < 0:
raise ValueError("Factorial not defined for negative numbers")
result = 1
for i in range(2, n + 1):
result *= i
return result
print(MathUtils.is_even(4)) # True
print(MathUtils.factorial(5)) # 120
| Method Type | First Parameter | Can Access Instance State? | Can Access Class State? | Use Case |
|---|---|---|---|---|
| Instance method | self |
Yes | Yes | Most methods — operating on object data |
| Class method | cls |
No | Yes | Alternative constructors, class-level operations |
| Static method | None | No | No | Utility functions related to the class |
@property Decorator)The @property decorator lets you define methods that behave like attributes. This is the Pythonic way to implement computed attributes and controlled access — no need for Java-style get_x() / set_x() methods.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
"""Get the temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero is not possible")
self._celsius = value
@property
def fahrenheit(self):
"""Get the temperature in Fahrenheit (computed from Celsius)."""
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5 / 9
# Usage
temp = Temperature(100)
print(temp.celsius) # 100
print(temp.fahrenheit) # 212.0
temp.fahrenheit = 32
print(temp.celsius) # 0.0
# Validation kicks in automatically
# temp.celsius = -300 # Raises ValueError
The beauty of @property is that callers use simple attribute syntax (temp.fahrenheit) while the class retains full control over how values are computed, validated, and stored.
Python uses dunder (double underscore) methods to let your classes integrate with built-in language features like print(), len(), comparison operators, and more. Here are the most important ones.
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, name, price):
self.items.append({"name": name, "price": price})
def __str__(self):
"""Human-readable string — used by print() and str()."""
item_list = ", ".join(item["name"] for item in self.items)
return f"Cart({item_list}) - Total: ${self.__get_total():.2f}"
def __repr__(self):
"""Developer-readable string — used in debugger, REPL, and logs."""
return f"ShoppingCart(items={self.items!r})"
def __len__(self):
"""Called by len() — returns the number of items."""
return len(self.items)
def __eq__(self, other):
"""Called by == operator."""
if not isinstance(other, ShoppingCart):
return NotImplemented
return self.items == other.items
def __get_total(self):
return sum(item["price"] for item in self.items)
# Usage
cart = ShoppingCart()
cart.add_item("Python Book", 39.99)
cart.add_item("Mechanical Keyboard", 129.00)
print(str(cart)) # Cart(Python Book, Mechanical Keyboard) - Total: $168.99
print(repr(cart)) # ShoppingCart(items=[{'name': 'Python Book', 'price': 39.99}, ...])
print(len(cart)) # 2
A rule of thumb: always define __repr__ for every class you write. When you are debugging at 2 AM, you will thank yourself for having a clear representation of your objects instead of <MyClass object at 0x7f...>.
Python does not have true private or protected access like Java or C++. Instead, it relies on naming conventions that signal developer intent.
| Convention | Example | Meaning |
|---|---|---|
| No prefix | self.name |
Public — use freely from outside the class |
Single underscore _ |
self._name |
Protected by convention — "internal use, don't touch unless you know what you're doing" |
Double underscore __ |
self.__name |
Name mangling — Python renames it to _ClassName__name to prevent accidental access from subclasses |
class Account:
def __init__(self, owner, balance):
self.owner = owner # public
self._account_type = "checking" # protected by convention
self.__balance = balance # name-mangled
def get_balance(self):
return self.__balance
acct = Account("Alice", 5000)
print(acct.owner) # Alice — works fine
print(acct._account_type) # checking — works, but signals "internal"
# print(acct.__balance) # AttributeError!
print(acct._Account__balance) # 5000 — name mangling, avoid this
print(acct.get_balance()) # 5000 — the intended way
In practice, most Python code uses the single underscore convention. Double underscore name mangling is rarely needed unless you are designing a class hierarchy and want to avoid name collisions with subclasses.
Let us put several concepts together into a well-structured class.
class BankAccount:
"""A bank account supporting deposits, withdrawals, and transaction history."""
# Class variable — shared interest rate for all accounts
interest_rate = 0.02
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
self._transactions = []
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
self._transactions.append(f"+{amount}")
return self
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance:
raise ValueError(f"Insufficient funds: balance is {self._balance}, requested {amount}")
self._balance -= amount
self._transactions.append(f"-{amount}")
return self
def apply_interest(self):
interest = self._balance * self.interest_rate
self._balance += interest
self._transactions.append(f"+{interest:.2f} (interest)")
return self
@classmethod
def from_dict(cls, data):
"""Alternative constructor: create account from a dictionary."""
return cls(owner=data["owner"], balance=data.get("balance", 0))
@staticmethod
def validate_amount(amount):
"""Utility: check if an amount is valid for transactions."""
return isinstance(amount, (int, float)) and amount > 0
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance=${self._balance:,.2f})"
def __repr__(self):
return f"BankAccount(owner={self.owner!r}, balance={self._balance!r})"
# Usage
account = BankAccount("Alice", 1000)
account.deposit(500).withdraw(200).apply_interest()
print(account) # BankAccount(owner='Alice', balance=$1,326.00)
print(account.balance) # 1326.0
# Alternative constructor
data = {"owner": "Bob", "balance": 5000}
bob_account = BankAccount.from_dict(data)
print(bob_account) # BankAccount(owner='Bob', balance=$5,000.00)
# Static utility
print(BankAccount.validate_amount(100)) # True
print(BankAccount.validate_amount(-50)) # False
1. Forgetting the self parameter
This is the single most common mistake for developers coming from other languages.
# WRONG — will raise TypeError when called
class User:
def greet():
return "Hello!"
user = User()
# user.greet() # TypeError: greet() takes 0 positional arguments but 1 was given
# CORRECT
class User:
def greet(self):
return "Hello!"
2. Mutable class variables shared between instances
This is a subtle and dangerous bug. If a class variable is a mutable object (like a list or dictionary), all instances share the same object in memory.
# WRONG — all instances share the SAME list
class Student:
grades = [] # class variable (mutable!)
def add_grade(self, grade):
self.grades.append(grade)
s1 = Student()
s2 = Student()
s1.add_grade(95)
print(s2.grades) # [95] — s2 sees s1's grade!
# CORRECT — each instance gets its own list
class Student:
def __init__(self):
self.grades = [] # instance variable
def add_grade(self, grade):
self.grades.append(grade)
s1 = Student()
s2 = Student()
s1.add_grade(95)
print(s2.grades) # [] — isolated as expected
3. Confusing class variables and instance variables
When you read an attribute, Python first checks the instance, then falls back to the class. When you assign through an instance, it creates a new instance variable that shadows the class variable.
class Config:
debug = False # class variable
c1 = Config()
c2 = Config()
c1.debug = True # creates an INSTANCE variable on c1, does NOT modify the class variable
print(c1.debug) # True (instance variable)
print(c2.debug) # False (still reading class variable)
print(Config.debug) # False (class variable unchanged)
1. Use descriptive class names in PascalCase. Follow PEP 8: BankAccount, not bank_account or bankAccount.
2. Keep classes focused (Single Responsibility Principle). A class should do one thing well. If your class has methods for sending emails, querying databases, and rendering HTML, it is doing too much — split it up.
3. Use @property instead of getter/setter methods. Python is not Java. Replace get_balance() / set_balance() with a @property. Your callers get clean attribute-style access while you retain full control.
4. Always define __repr__ for debugging. A good __repr__ should ideally return a string that could recreate the object: BankAccount(owner='Alice', balance=1000).
5. Prefer composition over deep inheritance. Before creating a class hierarchy five levels deep, ask yourself if one class could simply hold a reference to another.
6. Initialize all instance attributes in __init__. Do not create new attributes in random methods — it makes the class harder to understand and can lead to AttributeError surprises.
__init__ is the constructor — it runs automatically when you create an instance.self refers to the current instance and is required as the first parameter of every instance method.self.x) belong to one object; class variables belong to the class and are shared.@property decorator replaces getters and setters with clean, Pythonic attribute access.__str__, __repr__, __len__, __eq__) let your classes integrate seamlessly with Python's built-in operations._ (protected), __ (name-mangled).
Exception handling is one of the hallmarks of production-ready code. As a senior developer, I can tell you that the difference between a prototype and a system that runs reliably in production often comes down to how well it handles failure. Defensive programming means anticipating what can go wrong and writing code that degrades gracefully rather than crashing spectacularly.
In Python, errors and exceptions are related but distinct concepts. An error is a broader term that includes syntax mistakes caught before your code runs, while an exception is a runtime event that disrupts the normal flow of execution. Python’s exception handling mechanism gives you fine-grained control over how your program responds to these runtime failures, and mastering it is non-negotiable for writing robust software.
This guide covers exception handling in depth: from the basics of try/except to custom exception hierarchies, exception chaining, context managers, and the best practices that separate junior code from senior code.
Before diving in, it is important to understand the difference between the two main categories of errors you will encounter in Python.
Syntax errors (also called parsing errors) are caught by the Python interpreter before your code executes. These are mistakes in the structure of your code — a missing colon, unmatched parentheses, or incorrect indentation.
# Syntax error — caught before execution
if True
print("missing colon")
# SyntaxError: expected ':'
Runtime exceptions occur while your program is running. The syntax is valid, but something goes wrong during execution — dividing by zero, accessing a missing dictionary key, or trying to open a file that does not exist.
# Runtime exception — code is syntactically valid result = 12 / 0 # Traceback (most recent call last): # File "main.py", line 1, in <module> # result = 12 / 0 # ZeroDivisionError: division by zero
Exception handling deals exclusively with runtime exceptions. Syntax errors must be fixed in your source code before the program can run at all.
The fundamental mechanism for handling exceptions in Python is the try/except block. You place the code that might raise an exception inside the try clause, and the code that handles the exception inside the except clause.
# Without exception handling — program crashes
result = 12 / 0
print(result)
print("This line never executes")
# Traceback (most recent call last):
# File "main.py", line 1, in <module>
# result = 12 / 0
# ZeroDivisionError: division by zero
# With exception handling — program continues
try:
result = 12 / 0
print(result)
except ZeroDivisionError as e:
print(f"Caught an exception: {e}")
print("Program continues normally")
# Output:
# Caught an exception: division by zero
# Program continues normally
The key insight here is that once an exception is caught and handled, execution continues normally after the try/except block. Without the handler, the program terminates immediately.
Always catch the most specific exception possible. This is a principle that separates professional code from amateur code. Each exception type tells you exactly what went wrong, and your handler should respond accordingly.
# Catching specific exception types
def process_data(data):
try:
value = data["price"]
result = 100 / value
formatted = "Total: " + result # TypeError: can't concat str and float
return formatted
except KeyError as e:
print(f"Missing key in data: {e}")
return None
except ZeroDivisionError:
print("Price cannot be zero")
return None
except TypeError as e:
print(f"Type mismatch: {e}")
return None
# Test with different failure scenarios
process_data({}) # Missing key in data: 'price'
process_data({"price": 0}) # Price cannot be zero
process_data({"price": 50}) # Type mismatch: can only concatenate str...
You can also catch multiple exception types in a single except clause using a tuple.
# Grouping related exceptions
try:
value = int(input("Enter a number: "))
result = 100 / value
except (ValueError, ZeroDivisionError) as e:
print(f"Invalid input: {e}")
A try clause can have any number of except clauses to handle different exceptions. Python evaluates them from top to bottom and executes the first matching handler. Only one except block runs per exception.
def safe_list_access(items, index, key):
"""Demonstrate multiple except blocks with different responses."""
try:
item = items[index]
value = item[key]
return int(value)
except IndexError:
print(f"Index {index} is out of range (list has {len(items)} items)")
except KeyError:
print(f"Key '{key}' not found in item at index {index}")
except (ValueError, TypeError) as e:
print(f"Could not convert value to int: {e}")
except Exception as e:
print(f"Unexpected error: {type(e).__name__}: {e}")
return None
data = [{"name": "Alice", "age": "30"}, {"name": "Bob"}]
safe_list_access(data, 0, "age") # Returns 30
safe_list_access(data, 5, "age") # Index 5 is out of range
safe_list_access(data, 1, "age") # Key 'age' not found in item at index 1
safe_list_access(data, 0, "name") # Could not convert value to int: ...
Important: Order your except clauses from most specific to most general. If you put except Exception first, it will catch everything and the more specific handlers below it will never execute.
The else clause runs only when the try block completes without raising any exception. This is useful for code that should only execute on success, and it keeps the try block focused on the operations that might fail.
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Cannot divide by zero")
except TypeError:
print("Both arguments must be numbers")
else:
# Only runs if no exception was raised
print(f"{a} / {b} = {result}")
return result
return None
divide(10, 3) # 10 / 3 = 3.3333333333333335
divide(10, 0) # Cannot divide by zero
divide(10, "a") # Both arguments must be numbers
A key advantage of the else clause is that exceptions raised inside it are not caught by the preceding except blocks. This gives you a clean separation between “code that might fail” and “code that should only run on success.”
The finally clause executes no matter what — whether an exception was raised, caught, or not. It is the right place for cleanup operations: closing files, releasing database connections, or shutting down network sockets. If you acquire a resource, finally guarantees you release it.
def read_config(filepath):
"""Read a configuration file with guaranteed cleanup."""
f = None
try:
f = open(filepath, "r")
content = f.read()
config = parse_config(content)
return config
except FileNotFoundError:
print(f"Config file not found: {filepath}")
return {}
except PermissionError:
print(f"Permission denied: {filepath}")
return {}
finally:
# This ALWAYS runs, even if we return from try or except
if f is not None:
f.close()
print(f"File handle closed for: {filepath}")
You can combine all four clauses in a single statement: try, except, else, and finally.
def complete_example(filename):
"""Demonstrate the full try/except/else/finally structure."""
f = None
try:
f = open(filename, "r")
data = f.read()
except FileNotFoundError:
print(f"File '{filename}' not found")
data = None
else:
print(f"Successfully read {len(data)} characters")
finally:
if f is not None:
f.close()
print("Cleanup complete")
return data
Use the raise keyword to explicitly throw an exception when your code detects an invalid state. This is how you enforce preconditions, validate inputs, and signal error conditions to calling code.
def set_age(age):
"""Validate and set age with clear error messages."""
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, got {type(age).__name__}")
if age < 0:
raise ValueError(f"Age cannot be negative, got {age}")
if age > 150:
raise ValueError(f"Age {age} is unrealistic")
return age
# Usage
try:
set_age("thirty")
except TypeError as e:
print(e) # Age must be an integer, got str
try:
set_age(-5)
except ValueError as e:
print(e) # Age cannot be negative, got -5
You can also re-raise the current exception using raise without arguments. This is useful when you want to log an exception but still let it propagate up the call stack.
import logging
logger = logging.getLogger(__name__)
def process_payment(amount):
try:
charge_credit_card(amount)
except PaymentError as e:
logger.error(f"Payment failed for amount {amount}: {e}")
raise # Re-raise the same exception for the caller to handle
For any non-trivial application, you should define your own exception classes. Custom exceptions make your error handling more expressive, allow callers to catch domain-specific errors, and keep your API clean.
# Define a custom exception hierarchy for a user registration system
class ApplicationError(Exception):
"""Base exception for the application."""
pass
class ValidationError(ApplicationError):
"""Raised when input validation fails."""
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"Validation error on '{field}': {message}")
class DuplicateUserError(ApplicationError):
"""Raised when attempting to register an existing user."""
def __init__(self, email):
self.email = email
super().__init__(f"User with email '{email}' already exists")
class AuthenticationError(ApplicationError):
"""Raised when authentication fails."""
pass
# Usage in a registration system
def register_user(email, password, age):
if not email or "@" not in email:
raise ValidationError("email", "Must be a valid email address")
if len(password) < 8:
raise ValidationError("password", "Must be at least 8 characters")
if not isinstance(age, int) or age < 13:
raise ValidationError("age", "Must be an integer >= 13")
existing_users = ["alice@example.com", "bob@example.com"]
if email in existing_users:
raise DuplicateUserError(email)
return {"email": email, "status": "registered"}
# Callers can catch at different levels of specificity
try:
user = register_user("alice@example.com", "short", 25)
except ValidationError as e:
print(f"Fix your input - {e.field}: {e.message}")
except DuplicateUserError as e:
print(f"Try a different email: {e.email}")
except ApplicationError as e:
print(f"Something went wrong: {e}")
# Output:
# Fix your input - password: Must be at least 8 characters
Exception chaining lets you indicate that one exception was caused by another. This preserves the full causal chain in the traceback, which is invaluable for debugging. Use raise ... from ... to explicitly set the cause.
class ConfigError(Exception):
"""Raised when configuration loading fails."""
pass
def load_config(path):
"""Load config, converting low-level errors to domain errors."""
try:
with open(path) as f:
import json
return json.load(f)
except FileNotFoundError as e:
raise ConfigError(f"Config file missing: {path}") from e
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid JSON in config: {path}") from e
# When this fails, the traceback shows BOTH exceptions:
# FileNotFoundError: [Errno 2] No such file or directory: '/etc/app/config.json'
#
# The above exception was the direct cause of the following exception:
# ConfigError: Config file missing: /etc/app/config.json
You can also suppress the chained context using raise ... from None when the original exception is not relevant to the caller.
def get_user_age(data):
try:
return int(data["age"])
except (KeyError, ValueError, TypeError):
raise ValidationError("age", "Missing or invalid age") from None
Understanding the built-in exception hierarchy helps you write precise exception handlers. Here is the relevant portion of the class tree.
BaseException
+-- SystemExit
+-- KeyboardInterrupt
+-- GeneratorExit
+-- Exception
+-- ArithmeticError
| +-- ZeroDivisionError
| +-- OverflowError
| +-- FloatingPointError
+-- LookupError
| +-- IndexError
| +-- KeyError
+-- OSError
| +-- FileNotFoundError
| +-- PermissionError
| +-- FileExistsError
| +-- IsADirectoryError
| +-- ConnectionError
| +-- ConnectionRefusedError
| +-- ConnectionResetError
| +-- BrokenPipeError
+-- TypeError
+-- ValueError
| +-- UnicodeError
+-- AttributeError
+-- ImportError
| +-- ModuleNotFoundError
+-- RuntimeError
| +-- NotImplementedError
| +-- RecursionError
+-- StopIteration
+-- Warning
Key takeaway: Never catch BaseException — it includes SystemExit and KeyboardInterrupt, which you almost certainly want to let propagate. If you need a broad catch-all, use Exception instead.
| Exception | When It Occurs | Example |
|---|---|---|
ValueError |
Function receives correct type but invalid value | int("abc") |
TypeError |
Operation applied to wrong type | "hello" + 5 |
KeyError |
Dictionary key not found | d = {}; d["missing"] |
IndexError |
Sequence index out of range | [1, 2, 3][10] |
FileNotFoundError |
File or directory does not exist | open("nope.txt") |
AttributeError |
Object has no such attribute | None.split() |
ZeroDivisionError |
Division or modulo by zero | 1 / 0 |
ImportError |
Module cannot be imported | import nonexistent |
PermissionError |
Insufficient file system permissions | open("/etc/shadow") |
RuntimeError |
Generic error that does not fit elsewhere | Detected during execution |
StopIteration |
Iterator has no more items | next(iter([])) |
NotImplementedError |
Abstract method not overridden | Subclass forgot to implement |
Context managers (the with statement) are the Pythonic way to handle resource cleanup. They guarantee that cleanup code runs even if an exception occurs, eliminating the need for try/finally in many cases.
# Instead of try/finally for file handling...
f = None
try:
f = open("data.txt", "r")
content = f.read()
finally:
if f:
f.close()
# ...use a context manager
with open("data.txt", "r") as f:
content = f.read()
# File is automatically closed, even if an exception occurred
You can write your own context managers using the __enter__ and __exit__ methods or the contextlib module.
import contextlib
class DatabaseConnection:
"""Context manager for database connections with guaranteed cleanup."""
def __init__(self, connection_string):
self.connection_string = connection_string
self.connection = None
def __enter__(self):
print(f"Connecting to {self.connection_string}")
self.connection = self._create_connection()
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if self.connection:
self.connection.close()
print("Connection closed")
if exc_type is not None:
print(f"An error occurred: {exc_val}")
# Return False to propagate the exception, True to suppress it
return False
def _create_connection(self):
# Simulated connection
return type("Conn", (), {"close": lambda s: None, "query": lambda s, sql: []})()
# Usage — connection is always closed, even on error
with DatabaseConnection("postgres://localhost/mydb") as conn:
results = conn.query("SELECT * FROM users")
# Using contextlib for simpler context managers
from contextlib import contextmanager
import time
@contextmanager
def timer(label):
"""Context manager that times a block of code."""
start = time.perf_counter()
try:
yield
except Exception as e:
elapsed = time.perf_counter() - start
print(f"[{label}] Failed after {elapsed:.3f}s: {e}")
raise
else:
elapsed = time.perf_counter() - start
print(f"[{label}] Completed in {elapsed:.3f}s")
with timer("data processing"):
data = [i ** 2 for i in range(1_000_000)]
# Output: [data processing] Completed in 0.085s
def safe_divide(numerator, denominator, default=None):
"""
Safely divide two numbers, returning a default value on failure.
Args:
numerator: The dividend
denominator: The divisor
default: Value to return if division fails (default: None)
Returns:
The quotient, or the default value if division is not possible
"""
try:
return numerator / denominator
except ZeroDivisionError:
return default
except TypeError:
return default
# Usage
print(safe_divide(10, 3)) # 3.3333333333333335
print(safe_divide(10, 0)) # None
print(safe_divide(10, 0, 0)) # 0
print(safe_divide("ten", 2)) # None
import json
import os
def read_json_file(filepath):
"""
Read and parse a JSON file with comprehensive error handling.
Returns:
Parsed JSON data, or None on failure
"""
if not os.path.exists(filepath):
print(f"Error: File does not exist: {filepath}")
return None
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
except PermissionError:
print(f"Error: No permission to read {filepath}")
return None
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {filepath} at line {e.lineno}, col {e.colno}")
return None
except UnicodeDecodeError:
print(f"Error: File {filepath} is not valid UTF-8")
return None
except OSError as e:
print(f"Error: Could not read {filepath}: {e}")
return None
else:
print(f"Successfully loaded {filepath}")
return data
import time
import json
import logging
import urllib.request
import urllib.error
logger = logging.getLogger(__name__)
class APIError(Exception):
"""Base exception for API errors."""
def __init__(self, status_code, message):
self.status_code = status_code
self.message = message
super().__init__(f"API error {status_code}: {message}")
class RetryableError(APIError):
"""Raised for transient errors that may succeed on retry."""
pass
def api_call_with_retry(url, max_retries=3, backoff_factor=1.0):
"""
Make an API call with exponential backoff retry logic.
Retries only on transient errors (server errors, timeouts).
Fails fast on client errors (4xx status codes).
"""
last_exception = None
for attempt in range(max_retries):
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
if 400 <= e.code < 500:
# Client errors — do not retry
raise APIError(e.code, f"Client error: {e.reason}") from e
else:
# Server errors — retry
last_exception = RetryableError(e.code, f"Server error: {e.reason}")
except urllib.error.URLError as e:
last_exception = RetryableError(0, f"Connection error: {e.reason}")
except TimeoutError:
last_exception = RetryableError(0, "Request timed out")
# Exponential backoff before retry
if attempt < max_retries - 1:
wait_time = backoff_factor * (2 ** attempt)
logger.warning(
f"Attempt {attempt + 1} failed: {last_exception}. "
f"Retrying in {wait_time:.1f}s..."
)
time.sleep(wait_time)
raise last_exception
import re
class RegistrationError(Exception):
"""Base exception for registration failures."""
pass
class InvalidEmailError(RegistrationError):
def __init__(self, email):
self.email = email
super().__init__(f"Invalid email format: '{email}'")
class WeakPasswordError(RegistrationError):
def __init__(self, reasons):
self.reasons = reasons
super().__init__(f"Weak password: {'; '.join(reasons)}")
class UserExistsError(RegistrationError):
def __init__(self, email):
self.email = email
super().__init__(f"Account already exists for '{email}'")
def validate_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
raise InvalidEmailError(email)
def validate_password(password):
issues = []
if len(password) < 8:
issues.append("must be at least 8 characters")
if not re.search(r'[A-Z]', password):
issues.append("must contain an uppercase letter")
if not re.search(r'[a-z]', password):
issues.append("must contain a lowercase letter")
if not re.search(r'[0-9]', password):
issues.append("must contain a digit")
if issues:
raise WeakPasswordError(issues)
def register_user(email, password):
"""Register a user with full validation."""
# Validate inputs
validate_email(email)
validate_password(password)
# Check for duplicates (simulated)
existing = ["admin@example.com", "user@example.com"]
if email.lower() in existing:
raise UserExistsError(email)
print(f"User {email} registered successfully")
return {"email": email, "status": "active"}
# Demonstration
test_cases = [
("not-an-email", "Password1"),
("valid@email.com", "weak"),
("admin@example.com", "StrongPass1"),
("new@example.com", "StrongPass1"),
]
for email, password in test_cases:
try:
result = register_user(email, password)
print(f" Result: {result}")
except InvalidEmailError as e:
print(f" Email error: {e}")
except WeakPasswordError as e:
print(f" Password error: {e}")
for reason in e.reasons:
print(f" - {reason}")
except UserExistsError as e:
print(f" Duplicate: {e}")
import sqlite3
import logging
logger = logging.getLogger(__name__)
def execute_transaction(db_path, queries):
"""
Execute a series of SQL queries in a single transaction.
Rolls back on any failure and always closes the connection.
"""
conn = None
cursor = None
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
for sql, params in queries:
cursor.execute(sql, params)
conn.commit()
logger.info(f"Transaction committed: {len(queries)} queries executed")
return cursor.lastrowid
except sqlite3.IntegrityError as e:
logger.error(f"Integrity constraint violated: {e}")
if conn:
conn.rollback()
raise
except sqlite3.OperationalError as e:
logger.error(f"Database operational error: {e}")
if conn:
conn.rollback()
raise
except Exception as e:
logger.error(f"Unexpected error during transaction: {e}")
if conn:
conn.rollback()
raise
finally:
if cursor:
cursor.close()
if conn:
conn.close()
logger.debug("Database connection closed")
# Usage
queries = [
("INSERT INTO users (name, email) VALUES (?, ?)", ("Alice", "alice@example.com")),
("INSERT INTO users (name, email) VALUES (?, ?)", ("Bob", "bob@example.com")),
]
try:
execute_transaction("app.db", queries)
except sqlite3.IntegrityError:
print("A user with that email already exists")
These are the mistakes I see most frequently in code reviews. Avoid them.
# BAD — catches everything, including KeyboardInterrupt and SystemExit
try:
do_something()
except:
pass # Silently swallows ALL errors
# GOOD — catch only what you expect
try:
do_something()
except (ValueError, ConnectionError) as e:
logger.error(f"Operation failed: {e}")
# ACCEPTABLE — broad catch with logging (never silently)
try:
do_something()
except Exception as e:
logger.exception(f"Unexpected error: {e}")
raise
# BAD — hides bugs by catching everything
try:
user = get_user(user_id)
send_email(user.email)
update_database(user)
except Exception:
print("Something went wrong")
# GOOD — handle each failure mode specifically
try:
user = get_user(user_id)
except UserNotFoundError:
print(f"No user with ID {user_id}")
else:
try:
send_email(user.email)
except EmailError as e:
logger.warning(f"Failed to send email: {e}")
# Continue anyway — email is not critical
update_database(user)
# BAD — using exceptions as goto statements
def find_item(items, target):
try:
for item in items:
if item == target:
raise StopIteration # Abusing exception for control flow
except StopIteration:
return True
return False
# GOOD — use normal control flow
def find_item(items, target):
for item in items:
if item == target:
return True
return False
# ALSO GOOD — Pythonic
def find_item(items, target):
return target in items
# BAD — silently swallowing errors hides bugs
try:
process_data(data)
except ValueError:
pass # This hides real problems
# GOOD — at minimum, log the error
try:
process_data(data)
except ValueError as e:
logger.warning(f"Skipping invalid data: {e}")
Python follows the EAFP (Easier to Ask Forgiveness than Permission) philosophy rather than LBYL (Look Before You Leap). This means it is more Pythonic to try an operation and handle the exception than to check preconditions beforehand.
# LBYL style (common in other languages)
if "key" in dictionary:
value = dictionary["key"]
else:
value = default_value
# EAFP style (Pythonic)
try:
value = dictionary["key"]
except KeyError:
value = default_value
# Even more Pythonic — use dict.get()
value = dictionary.get("key", default_value)
import logging
logger = logging.getLogger(__name__)
try:
process_order(order)
except Exception as e:
# logger.exception() automatically includes the traceback
logger.exception(f"Failed to process order {order.id}")
raise # Re-raise after logging
# Define a clear hierarchy for your application
class AppError(Exception):
"""Base exception for the application."""
pass
class ServiceError(AppError):
"""Errors from external services."""
pass
class DatabaseError(ServiceError):
"""Database-related errors."""
pass
class CacheError(ServiceError):
"""Cache-related errors."""
pass
class BusinessLogicError(AppError):
"""Business rule violations."""
pass
class InsufficientFundsError(BusinessLogicError):
"""Raised when account balance is too low."""
pass
# Callers can catch at the level of granularity they need:
# - catch InsufficientFundsError for specific handling
# - catch BusinessLogicError for all rule violations
# - catch AppError for any application error
# Prefer context managers over try/finally
# BAD
lock.acquire()
try:
do_work()
finally:
lock.release()
# GOOD
with lock:
do_work()
# BAD — too much code in the try block
try:
data = load_data(path)
processed = transform(data)
result = calculate(processed)
save(result)
except FileNotFoundError:
# Which operation caused this?
handle_error()
# GOOD — narrow try blocks make error sources clear
try:
data = load_data(path)
except FileNotFoundError:
logger.error(f"Data file missing: {path}")
return
processed = transform(data)
result = calculate(processed)
save(result)
except clauses. Catching Exception is acceptable only as a last resort, and always log the error.else for success-only code, finally for guaranteed cleanup.with statement is cleaner and less error-prone.raise ... from ...) to preserve the causal chain when converting low-level exceptions to domain-level ones.
Variables
In Python, every piece of data lives in a variable — a named container that holds a value. Unlike statically typed languages such as Java or C++, Python is dynamically typed. You never declare a type explicitly; Python infers it the moment you assign a value. A variable can even change its type mid-program. Under the hood, every value is an object (an instance of a class), and every data type is actually a class.
# Python is dynamically typed — no type declarations needed name = "Folau" print(type(name)) #name = 42 print(type(name)) # — same variable, different type name = [1, 2, 3] print(type(name)) # — changed again, no error
This flexibility is powerful, but it means you must be deliberate about what your variables hold. Use descriptive names and lean on type() when debugging.
Data Types Overview
Python groups its built-in types into several categories. Here is the full reference table:
| Text Type | str |
| Numeric Types: | int, float, complex |
| Sequence Types: | list, tuple, range |
| Mapping Type: | dict |
| Set Types: | set, frozenset |
| Boolean Type: | bool |
| Binary Types: | bytes, bytearray, memoryview |
Let’s walk through each one in detail.
str — Text Type
Strings are sequences of Unicode characters. You can create them with single quotes, double quotes, or triple quotes (for multi-line text). Python has no separate char type — a single character is simply a string of length one.
When to use: Any time you work with text — user input, file paths, API responses, log messages.
# Creating strings first_name = "Folau" last_name = 'Kaveinga' bio = """Software developer who loves Python.""" print(type(first_name)) #
# String slicing — extract parts of a string language = "Python" print(language[0]) # P — first character print(language[-1]) # n — last character print(language[0:3]) # Pyt — index 0 up to (not including) 3 print(language[::2]) # Pto — every other character print(language[::-1]) # nohtyP — reversed
# f-strings (Python 3.6+) — the modern way to format strings
name = "Folau"
age = 30
print(f"My name is {name} and I am {age} years old.")
# My name is Folau and I am 30 years old.
price = 49.99
print(f"Total: ${price:.2f}") # Total: $49.99
# Common string methods
message = " Hello, Python World! "
print(message.strip()) # "Hello, Python World!" — remove whitespace
print(message.strip().lower()) # "hello, python world!"
print(message.strip().upper()) # "HELLO, PYTHON WORLD!"
print(message.strip().split(",")) # ['Hello', ' Python World!']
print(message.strip().replace("Python", "Java")) # "Hello, Java World!"
print("Python" in message) # True — membership check
print(message.strip().startswith("Hello")) # True
print(message.strip().count("l")) # 3
Common pitfall: Strings are immutable. Every operation that looks like it modifies a string actually creates a new one. If you need to build a string through many concatenations inside a loop, use "".join() or a list — it is significantly faster than repeated +=.
int — Integer Type
Integers represent whole numbers with no decimal point. In Python 3, integers have arbitrary precision — they can be as large as your machine’s memory allows. No overflow errors, ever.
When to use: Counters, indices, loop variables, IDs, quantities — anywhere you need exact whole numbers.
# Basic integer operations count = 10 negative = -42 big_number = 1_000_000 # underscores improve readability (Python 3.6+) print(type(count)) #
# Arithmetic with integers a = 17 b = 5 print(a + b) # 22 — addition print(a - b) # 12 — subtraction print(a * b) # 85 — multiplication print(a / b) # 3.4 — true division (returns float!) print(a // b) # 3 — floor division (returns int) print(a % b) # 2 — modulus (remainder) print(a ** b) # 1419857 — exponentiation
# Arbitrary precision — Python handles huge numbers natively huge = 2 ** 100 print(huge) # 1267650600228229401496703205376 print(type(huge)) #— still an int, no overflow
Common pitfall: The / operator always returns a float, even if both operands are integers and the result is a whole number. Use // when you specifically need integer division.
float — Floating-Point Type
Floats represent decimal numbers. They follow the IEEE 754 double-precision standard and are accurate to about 15–17 significant digits. 1 is an integer; 1.0 is a float.
When to use: Measurements, scientific calculations, percentages, currency calculations (though for money, consider decimal.Decimal for exactness).
# Creating floats temperature = 98.6 pi = 3.14159 scientific = 1.5e3 # 1500.0 — scientific notation num = float(2.5) print(num) # 2.5 print(type(num)) #
# Float arithmetic
price = 19.99
tax_rate = 0.08
total = price * (1 + tax_rate)
print(f"Total: ${total:.2f}") # Total: $21.59
# Rounding
print(round(3.14159, 2)) # 3.14
print(round(2.5)) # 2 — banker's rounding (rounds to even)
# Float precision issue — this is critical to understand
print(0.1 + 0.2) # 0.30000000000000004 — NOT 0.3!
print(0.1 + 0.2 == 0.3) # False
# How to compare floats safely
import math
print(math.isclose(0.1 + 0.2, 0.3)) # True
# For financial calculations, use Decimal
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3 — exact
Common pitfall: Floating-point arithmetic is not exact. Never compare floats with ==. Use math.isclose() or a tolerance threshold. For money, use decimal.Decimal.
complex — Complex Number Type
Complex numbers have a real and imaginary part, written as x + yj where x is the real part and y is the imaginary part.
When to use: Scientific computing, electrical engineering, signal processing, or any domain that works with imaginary numbers.
# Creating complex numbers z = 3 + 4j print(type(z)) #print(z.real) # 3.0 print(z.imag) # 4.0 print(abs(z)) # 5.0 — magnitude (distance from origin) # Arithmetic z1 = 2 + 3j z2 = 1 - 1j print(z1 + z2) # (3+2j) print(z1 * z2) # (5+1j)
Common pitfall: Python uses j for the imaginary unit, not i (which is the convention in mathematics). This trips up people coming from a math background.
list — Ordered Mutable Sequence
A list is an ordered, mutable collection that can hold items of any type. It is the most versatile data structure in Python and the one you will use most often.
When to use: Whenever you need an ordered collection that may change — storing records, building results, stacks, queues.
# Creating lists fruits = ["apple", "banana", "cherry"] mixed = [1, 2.5, "hello", True, None] # mixed types are fine empty = [] from_constructor = list((1, 2, 3)) print(type(fruits)) #
# Accessing and slicing colors = ["red", "green", "blue", "yellow", "purple"] print(colors[0]) # red — first element print(colors[-1]) # purple — last element print(colors[1:3]) # ['green', 'blue'] — slice from index 1 to 3 print(colors[::-1]) # ['purple', 'yellow', 'blue', 'green', 'red'] — reversed
# Modifying lists
tasks = ["write code", "test code"]
tasks.append("deploy") # add to end
tasks.insert(1, "review code") # insert at index 1
print(tasks) # ['write code', 'review code', 'test code', 'deploy']
tasks.remove("test code") # remove by value
popped = tasks.pop() # remove and return last item
print(popped) # deploy
print(tasks) # ['write code', 'review code']
# Iterating and common patterns numbers = [3, 1, 4, 1, 5, 9, 2, 6] # Sort (in-place) numbers.sort() print(numbers) # [1, 1, 2, 3, 4, 5, 6, 9] # List comprehension — the Pythonic way to transform lists squares = [x ** 2 for x in range(1, 6)] print(squares) # [1, 4, 9, 16, 25] evens = [x for x in range(10) if x % 2 == 0] print(evens) # [0, 2, 4, 6, 8] # Useful built-in functions print(len(numbers)) # 8 print(sum(numbers)) # 31 print(min(numbers)) # 1 print(max(numbers)) # 9
Common pitfall: Lists are mutable and passed by reference. If you assign b = a, both variables point to the same list. Modifying one modifies the other. Use b = a.copy() or b = a[:] to create a true copy.
# The mutable reference trap a = [1, 2, 3] b = a # b points to the SAME list b.append(4) print(a) # [1, 2, 3, 4] — a changed too! # The fix a = [1, 2, 3] b = a.copy() # b is an independent copy b.append(4) print(a) # [1, 2, 3] — a is unchanged
tuple — Ordered Immutable Sequence
A tuple is just like a list, except it cannot be modified after creation. Tuples are slightly faster and use less memory than lists.
When to use: Fixed collections of values (coordinates, RGB colors, database rows), dictionary keys (lists cannot be keys), returning multiple values from a function.
# Creating tuples point = (10, 20) rgb = (255, 128, 0) single = (42,) # trailing comma is required for single-element tuples from_constructor = tuple([1, 2, 3]) print(type(point)) #
# Accessing tuple elements
nested_tuple = (1, 2, (3, 4), (5, 6, 7))
print(nested_tuple[0]) # 1
print(nested_tuple[2][0]) # 3
print(nested_tuple[-1]) # (5, 6, 7)
# Tuple unpacking — a very common Python pattern
coordinates = (40.7128, -74.0060)
lat, lng = coordinates
print(f"Latitude: {lat}, Longitude: {lng}")
# Latitude: 40.7128, Longitude: -74.006
# Tuples as function return values
def get_user():
return "Folau", 30, "developer"
name, age, role = get_user()
print(f"{name} is a {age}-year-old {role}")
Common pitfall: A single value in parentheses like (42) is NOT a tuple — it is just the integer 42 in parentheses. You need the trailing comma: (42,).
range — Immutable Sequence of Numbers
The range() function generates a lazy sequence of integers. It does not store all values in memory — it computes them on the fly, making it very memory efficient.
When to use: for loops, generating index sequences, any time you need a predictable series of integers.
# range(start, stop, step)
# start — where to begin (inclusive, defaults to 0)
# stop — where to end (exclusive)
# step — increment size (defaults to 1)
# Basic usage
for i in range(5):
print(i, end=" ") # 0 1 2 3 4
print() # newline
# Even numbers from 2 to 18
even_numbers = range(2, 20, 2)
for num in even_numbers:
print(num, end=" ") # 2 4 6 8 10 12 14 16 18
print()
# Counting backwards
for i in range(5, 0, -1):
print(i, end=" ") # 5 4 3 2 1
# Convert range to list when you need actual list operations numbers = list(range(1, 11)) print(numbers) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] # Memory efficiency — range stores only start, stop, step import sys list_million = list(range(1_000_000)) range_million = range(1_000_000) print(sys.getsizeof(list_million)) # ~8 MB print(sys.getsizeof(range_million)) # 48 bytes!
dict — Mapping Type (Key-Value Pairs)
A dictionary stores data as key-value pairs. Keys must be immutable and unique; values can be anything. Dictionaries are insertion-ordered as of Python 3.7+ and are optimized for fast lookups by key.
When to use: Anytime you need to associate keys with values — configuration settings, JSON-like data, caching, counting occurrences, grouping items.
# Creating dictionaries
person = {"first_name": "Folau", "last_name": "Kaveinga", "age": 30}
from_constructor = dict(first_name="Folau", last_name="Kaveinga")
empty = {}
print(type(person)) #
# Accessing and modifying values
person = {"first_name": "Folau", "last_name": "Kaveinga", "age": 30}
# Access
print(person["first_name"]) # Folau
print(person.get("email", "N/A")) # N/A — .get() with a default avoids KeyError
# Modify
person["age"] = 31 # update existing key
person["email"] = "folau@dev.com" # add new key-value pair
# Remove
del person["email"]
popped_age = person.pop("age") # remove and return value
print(popped_age) # 31
# Iterating over dictionaries
scores = {"Alice": 92, "Bob": 87, "Charlie": 95}
# Loop through keys
for name in scores:
print(name)
# Loop through values
for score in scores.values():
print(score)
# Loop through both (most common)
for name, score in scores.items():
print(f"{name}: {score}")
# Dictionary comprehension
squared = {x: x**2 for x in range(1, 6)}
print(squared) # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Common dict methods
config = {"host": "localhost", "port": 8080}
print(config.keys()) # dict_keys(['host', 'port'])
print(config.values()) # dict_values(['localhost', 8080])
print("host" in config) # True — check if key exists
# Merge two dicts (Python 3.9+)
defaults = {"host": "0.0.0.0", "port": 80, "debug": False}
overrides = {"port": 8080, "debug": True}
final = defaults | overrides
print(final) # {'host': '0.0.0.0', 'port': 8080, 'debug': True}
Common pitfall: Accessing a missing key with dict["key"] raises a KeyError. Always use .get(key, default) when the key might not exist, or use collections.defaultdict for automatic defaults.
set — Unordered Collection of Unique Items
A set is a mutable, unordered collection that automatically removes duplicates. Sets support mathematical operations like union, intersection, and difference.
When to use: Removing duplicates from a list, membership testing (faster than lists), set operations (finding common elements, differences).
# Creating sets
numbers = {1, 2, 3, 4, 5}
from_list = set([1, 2, 2, 3, 3, 3]) # duplicates removed
print(from_list) # {1, 2, 3}
print(type(numbers)) #
# Set operations
frontend = {"HTML", "CSS", "JavaScript", "React"}
backend = {"Python", "Java", "JavaScript", "SQL"}
print(frontend & backend) # {'JavaScript'} — intersection
print(frontend | backend) # all combined — union
print(frontend - backend) # {'HTML', 'CSS', 'React'} — difference
print(frontend ^ backend) # symmetric difference (in one but not both)
# Practical use: removing duplicates names = ["Alice", "Bob", "Alice", "Charlie", "Bob"] unique_names = list(set(names)) print(unique_names) # ['Alice', 'Bob', 'Charlie'] (order may vary)
Common pitfall: You cannot create an empty set with {} — that creates an empty dict. Use set() for an empty set.
frozenset — Immutable Set
A frozenset is an immutable version of a set. Once created, you cannot add or remove elements.
When to use: When you need a set that can serve as a dictionary key or as an element inside another set (regular sets cannot do this because they are mutable).
# Creating a frozenset
vowels = frozenset(['a', 'e', 'i', 'o', 'u'])
print(vowels) # frozenset({'a', 'e', 'i', 'o', 'u'})
print(type(vowels)) #
# frozensets support the same read operations as sets
consonants = frozenset(['b', 'c', 'd', 'f'])
print(vowels & consonants) # frozenset() — no overlap
print(vowels | consonants) # combined
# But you cannot modify them
# vowels.add('y') # AttributeError: 'frozenset' object has no attribute 'add'
bool — Boolean Type
Booleans represent one of two values: True or False. They are a subclass of int (where True == 1 and False == 0). Python also evaluates non-boolean values in boolean context — this is called “truthiness.”
When to use: Conditional logic, flags, filtering, control flow.
# Boolean basics is_active = True is_deleted = False print(type(is_active)) ## Comparison operators return booleans print(10 > 5) # True print(10 == 5) # False print("a" in "abc") # True
# Truthy and falsy values
# These are all falsy:
print(bool(0)) # False
print(bool(0.0)) # False
print(bool("")) # False — empty string
print(bool([])) # False — empty list
print(bool({})) # False — empty dict
print(bool(None)) # False
# Everything else is truthy:
print(bool(1)) # True
print(bool(-1)) # True
print(bool("hello")) # True
print(bool([1, 2])) # True
Common pitfall: True and False must be capitalized. Writing true or false (lowercase) will raise a NameError. Also, since bool is a subclass of int, True + True == 2 — which can lead to surprises in arithmetic.
bytes — Immutable Binary Sequence
The bytes type holds an immutable sequence of integers in the range 0–255. It is used for binary data — file I/O, network protocols, encoding/decoding text.
When to use: Reading binary files, network communication, encoding strings for transmission.
# Creating bytes from a string text = "My name is Folau" encoded = bytes(text, 'utf-8') print(encoded) # b'My name is Folau' print(type(encoded)) ## Shorthand with .encode() encoded2 = "Hello".encode('utf-8') print(encoded2) # b'Hello' # Decoding back to string decoded = encoded.decode('utf-8') print(decoded) # My name is Folau
Common pitfall: Bytes are immutable. You cannot change individual elements. Use bytearray when you need to modify binary data in place.
bytearray — Mutable Binary Sequence
A bytearray is the mutable counterpart of bytes. You can modify individual bytes after creation.
When to use: When you need to manipulate binary data in place, such as building binary protocols or editing binary files.
# Creating a bytearray text = "My name is Folau" arr = bytearray(text, 'utf-8') print(arr) # bytearray(b'My name is Folau') print(type(arr)) ## Unlike bytes, bytearray is mutable arr[0] = 72 # change 'M' (77) to 'H' (72) print(arr.decode()) # Hy name is Folau
memoryview — Buffer Protocol Access
A memoryview lets you access the internal data of a bytes or bytearray object without copying it. This is useful for performance when working with large binary data.
When to use: High-performance binary data manipulation, avoiding unnecessary copies of large buffers.
# Creating a memoryview
data = bytearray('ABC', 'utf-8')
mv = memoryview(data)
print(mv[0]) # 65 — ASCII value of 'A'
print(bytes(mv[0:2])) # b'AB'
print(list(mv[0:3])) # [65, 66, 67]
# Modify original data through the memoryview (zero-copy)
mv[0] = 90 # change 'A' to 'Z'
print(data) # bytearray(b'ZBC')
Type Conversion (Casting)
Python provides built-in functions to convert between types. This is called casting.
# int conversions
print(int(3.9)) # 3 — truncates, does NOT round
print(int("42")) # 42 — string to int
print(int(True)) # 1 — bool to int
# print(int("3.5")) # ValueError — cannot convert float string directly
# float conversions
print(float(10)) # 10.0 — int to float
print(float("3.14")) # 3.14 — string to float
print(float(False)) # 0.0 — bool to float
# string conversions
print(str(42)) # "42"
print(str(3.14)) # "3.14"
print(str(True)) # "True"
# list / tuple conversions
print(list((1, 2, 3))) # [1, 2, 3] — tuple to list
print(tuple([1, 2, 3])) # (1, 2, 3) — list to tuple
print(list("Python")) # ['P', 'y', 't', 'h', 'o', 'n']
print(set([1, 2, 2, 3])) # {1, 2, 3} — list to set (removes dupes)
print(list({1, 2, 3})) # [1, 2, 3] — set to list
Common pitfall: int("3.5") raises a ValueError. You need to convert to float first, then to int: int(float("3.5")).
Type Checking
Python provides two main ways to check the type of a variable:
# type() — returns the exact type name = "Folau" age = 30 scores = [90, 85, 92] print(type(name)) #print(type(age)) # print(type(scores)) # # Comparing types directly if type(age) is int: print("age is an integer")
# isinstance() — the preferred way (supports inheritance) number = 2.5 print(isinstance(number, float)) # True print(isinstance(number, (int, float))) # True — check multiple types print(isinstance(True, int)) # True — bool is a subclass of int # Why isinstance() is preferred over type() # type() does NOT consider inheritance; isinstance() does print(type(True) is int) # False — type is exactly bool, not int print(isinstance(True, int)) # True — bool IS an int (subclass)
As a rule of thumb, use isinstance() in production code because it respects class inheritance. Use type() for debugging and quick inspection.
Key Takeaways
Here are the essential points every Python developer should internalize about data types:
int, bool, and None — is an instance of a class. Use type() to inspect any value.== to compare floats. Use math.isclose() or the decimal module for precision-sensitive work."5" + 3 for you — use int(), str(), float() to cast deliberately.isinstance() over type() for type checking in production code, as it respects class inheritance.
A MySQL trigger is a stored program that is invoked automatically in response to an event such as INSERT, UPDATE, or DELETE to a particualar table.
There are two types of triggers

Advantages of triggers
Disadvantages of triggers
Create a Trigger
DELIMITER $$
CREATE TRIGGER trigger_name
AFTER INSERT
ON table_name FOR EACH ROW
BEGIN
-- statements
-- variable declarations
-- trigger code
END$$
DELIMITER ;
Example
DELIMITER $$
CREATE TRIGGER after_members_insert
AFTER INSERT
ON members FOR EACH ROW
BEGIN
IF NEW.birthDate IS NULL THEN
INSERT INTO reminders(memberId, message)
VALUES(new.id,CONCAT('Hi ', NEW.name, ', please update your date of birth.'));
END IF;
END$$
DELIMITER ;
Triggers only run for the new, updated, or deleted rows. They don’t touch those rows that didn’t get updated.
Show triggers
SHOW TRIGGERS;
SHOW TRIGGERS
[{FROM | IN} database_name]
[LIKE 'pattern' | WHERE search_condition];
SHOW TRIGGERS; -- return all triggers in all databases
SHOW TRIGGERS FROM database_name; -- show triggers from database_name
The only way to edit a trigger is by dropping and recreating it.
DROP TRIGGER [IF EXISTS] [schema_name.]trigger_name;
December 9, 2020