Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Python – Conditional Statements

Introduction

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.

Comparison Operators

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 to
x = 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 ==.

The if Statement

The 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.")

The if-else Statement

When 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.")

The if-elif-else Chain

When 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.")

Nested Conditionals

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)

Logical Operators: and, or, not

Logical 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.")

Combining Logical Operators

# 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.

Truthiness and Falsiness in Python

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):

  • False
  • None
  • 0 (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.

The Ternary Operator (Conditional Expression)

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.

The 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.

The pass Statement

In 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.”

Common Pitfalls

These are mistakes that every Python developer makes at some point. Knowing them ahead of time will save you hours of debugging.

1. Using = 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.

2. Indentation Errors

# 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).

3. Comparing Different Types

# 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.

Best Practices

1. Keep Conditions Simple and Readable

# 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()

2. Use Early Returns to Reduce Nesting

# 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)

3. Prefer Positive Conditions

# 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.

Practical Example: Putting It All Together

# 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)

Key Takeaways

  • Indentation is syntax in Python. Every 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.
  • Comparison operators (==, !=, <, >, <=, >=) produce boolean values that drive your conditions.
  • Logical operators (and, or, not) let you combine and invert conditions. Remember precedence: not > and > or.
  • Truthiness/Falsiness: empty collections, 0, None, and "" are all falsy. Use this to write concise, Pythonic checks.
  • Ternary expressions (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.
  • Avoid deep nesting. Use guard clauses and early returns to keep your code flat and readable.
  • Be explicit about types. Python does not coerce types in comparisons. Always convert user input to the expected type before comparing.

 

Source on Github

March 7, 2021

Python – Class

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.

Class Definition Syntax

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.

The __init__() Constructor

The __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.

The self Parameter

Every 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.

Instance Variables vs. Class Variables

This distinction trips up many developers, so it is worth understanding clearly.

  • Instance variables are defined inside __init__ (or other methods) using self.variable_name. Each object gets its own independent copy.
  • Class variables are defined directly in the class body, outside any method. They are shared across all instances.
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

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)

Class Methods (@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.

Static Methods (@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

When to Use Each Type of Method

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

Properties (@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.

Magic/Dunder Methods

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...>.

Access Modifiers Convention

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.

Practical Example: A Complete BankAccount Class

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

Common Pitfalls

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)

Best Practices

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.

Key Takeaways

  • A class is a blueprint; an object is an instance created from that blueprint.
  • __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.
  • Instance variables (self.x) belong to one object; class variables belong to the class and are shared.
  • Use instance methods for most work, class methods for alternative constructors and class-level logic, and static methods for utility functions.
  • The @property decorator replaces getters and setters with clean, Pythonic attribute access.
  • Dunder methods (__str__, __repr__, __len__, __eq__) let your classes integrate seamlessly with Python's built-in operations.
  • Python signals access levels through naming conventions: no prefix (public), _ (protected), __ (name-mangled).
  • Watch out for mutable class variables — they are shared across all instances and can cause hard-to-find bugs.

 

Source code on Github

 

March 5, 2021

Python – Exception Handling

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.

 

Syntax Errors vs Runtime Exceptions

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 try/except Block — Basics

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.

 

Catching Specific Exceptions

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}")

 

Multiple except Blocks

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

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

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

 

Raising Exceptions

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

 

Custom Exceptions

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 (raise … from …)

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

 

Python’s Exception Hierarchy

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.

 

Common Built-in Exceptions

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 and Exceptions

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

 

Practical Examples

Safe Division Function

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

File Reader with Proper Error Handling

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

API Call with Retry Logic

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

Custom Validation Exceptions for User Registration

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}")

Database Connection with Cleanup in finally

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")

 

Common Pitfalls

These are the mistakes I see most frequently in code reviews. Avoid them.

1. Bare except Clauses

# 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

2. Catching Too Broadly

# 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)

3. Using Exceptions for Flow Control

# 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

4. Swallowing Exceptions Silently

# 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}")

 

Best Practices

EAFP vs LBYL

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)

Log Exceptions Properly

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

Build a Custom Exception Hierarchy

# 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

Use Context Managers for Resource Cleanup

# Prefer context managers over try/finally
# BAD
lock.acquire()
try:
    do_work()
finally:
    lock.release()

# GOOD
with lock:
    do_work()

Keep try Blocks Small

# 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)

 

Key Takeaways

  1. Always catch specific exceptions — never use bare except clauses. Catching Exception is acceptable only as a last resort, and always log the error.
  2. Use try/except/else/finallyelse for success-only code, finally for guaranteed cleanup.
  3. Prefer context managers over try/finally for resource management. The with statement is cleaner and less error-prone.
  4. Build custom exception hierarchies for non-trivial applications. They make your code self-documenting and give callers control over error granularity.
  5. Use exception chaining (raise ... from ...) to preserve the causal chain when converting low-level exceptions to domain-level ones.
  6. Follow EAFP — try the operation and handle failure, rather than checking preconditions. It is more Pythonic, more concise, and avoids race conditions.
  7. Never swallow exceptions silently — at minimum, log them. Silent failures are the hardest bugs to track down in production.
  8. Keep try blocks small — wrap only the operation that might fail, so you know exactly what caused the exception.
  9. Re-raise when appropriate — if your handler cannot fully resolve the problem, log and re-raise so the caller can decide what to do.
  10. Test your error paths — exception handling code is code too. Make sure your except blocks actually do what you think they do.

 

Source code on Github

 

February 8, 2021

Python – Data Types

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: intfloatcomplex
Sequence Types: listtuplerange
Mapping Type: dict
Set Types: setfrozenset
Boolean Type: bool
Binary Types: bytesbytearraymemoryview

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:

  1. Everything is an object. Every value in Python — even int, bool, and None — is an instance of a class. Use type() to inspect any value.
  2. Mutable vs. immutable matters. Lists, dicts, sets, and bytearrays can be changed in place. Strings, tuples, frozensets, and bytes cannot. Understanding this distinction prevents some of the most common Python bugs.
  3. Dynamic typing is a feature, not a flaw. Python infers types at runtime. This gives you flexibility, but you should use clear variable names and type hints (in larger projects) to keep code readable.
  4. Float arithmetic is approximate. Never use == to compare floats. Use math.isclose() or the decimal module for precision-sensitive work.
  5. Use the right type for the job. Need order + mutability? Use a list. Need immutability? Use a tuple. Need fast key-based lookups? Use a dict. Need uniqueness? Use a set.
  6. Type conversion is explicit. Python will not silently convert "5" + 3 for you — use int(), str(), float() to cast deliberately.
  7. Prefer isinstance() over type() for type checking in production code, as it respects class inheritance.

 

Source code on Github

February 8, 2021

MySQL Trigger

 

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

  1. Row-level trigger: a row-level trigger is activated for each row that is inserted, updated, or deleted.
  2. Statement-level trigger: a statement-level trigger is executed once for each transaction regardless of how many rows are inserted, updated, or deleted. MySQL supports only row-level triggers. It doesn’t support statement-level triggers.

Advantages of triggers

  1. Triggers provide another way to check the integrity of data
  2. Triggers handle errors from the database layer
  3. Triggers are another way to run cron jobs.
  4. Triggers can be useful for auditing the data changes in tables.

Disadvantages of triggers

  1. Triggers can only use extended validations but not all validations. You can use NOT NULL, UNIQUE, CHECK, and FOREIGN KEY contraints.
  2. Triggers can be difficult to troubleshoot because they execute automatically in the database which may not be visible to clients.
  3. Triggers may increase the overhead of the MySQL server.

Create a Trigger

  1. Specify the name of the trigger – CREATE TRIGGER trigger_name
  2. Specify trigger type – AFTER INSERT
  3. Specify the name of the table for which you want to create the trigger for – ON table_name
  4. Specify the trigger body that will run every the trigger is invoked.
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