Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 

Repository files navigation

🐍 Python Notes

A consolidated, hands-on reference of Python concepts — from the basics to advanced topics — built from real learning and structured for developers at any level.

Whether you're just starting out or brushing up on a specific topic, these notes are designed to be clear, concise, and immediately useful. Every section includes explanations and code snippets you can run and experiment with right away.


🌟 What is Python?

Python is a high-level, general-purpose, interpreted programming language known for its clean syntax, readability, and versatility. It was designed with the philosophy that code should be easy to read and write — almost like writing plain English.

Python is one of the most popular programming languages in the world and is used across a huge range of domains including web development, data science, machine learning, automation, scientific computing, and more.

👨‍💻 Who Invented Python?

Python was created by Guido van Rossum, a Dutch programmer. He began working on Python in the late 1980s and released the first version — Python 0.9.0 — in February 1991.

The name "Python" was inspired not by the snake, but by the British comedy group Monty Python's Flying Circus, which Guido was a fan of.

🎯 Fun fact: Guido van Rossum was known as Python's "Benevolent Dictator For Life" (BDFL) until he stepped down from the role in 2018.

📅 Python Version History

Version Year Key Highlights
Python 0.9.0 1991 First public release — classes, functions, exception handling
Python 1.0 1994 Lambda, map, filter, reduce
Python 2.0 2000 List comprehensions, garbage collection
Python 3.0 2008 Major redesign — print function, Unicode by default, removed Python 2 quirks
Python 3.10+ 2021+ Pattern matching, improved error messages
Python 3.12+ 2023+ Performance improvements, f-string enhancements

⚠️ Python 2 reached end-of-life on January 1, 2020. Always use Python 3.

🏆 Why is Python so Popular?

  • Readable syntax — code reads almost like English, reducing the learning curve
  • Huge ecosystem — thousands of libraries for every domain (NumPy, Pandas, Django, Flask, TensorFlow, etc.)
  • Versatile — used in web dev, data science, ML, automation, scripting, desktop apps, and more
  • Large community — one of the largest developer communities, meaning abundant tutorials, answers, and packages
  • Cross-platform — runs on Windows, macOS, Linux without modification
  • Interpreted — no compilation step; run code immediately and see results
  • Open source — free to use, contribute, and distribute

🔍 What is Python Used For?

Domain Tools / Frameworks
Data Science Pandas, NumPy, Matplotlib, Seaborn
Machine Learning Scikit-learn, TensorFlow, PyTorch, XGBoost
Web Development Django, Flask, FastAPI
Automation / Scripting os, shutil, subprocess, selenium
APIs & Backend FastAPI, Flask, requests
Scientific Computing SciPy, SymPy, Jupyter
DevOps / Cloud boto3 (AWS), Google Cloud SDK
NLP / AI NLTK, spaCy, LangChain, Hugging Face

⚙️ How Python Works

Python is an interpreted language — meaning your code is executed line by line at runtime by the Python interpreter, rather than being compiled into machine code beforehand.

Your .py file
     │
     ▼
Python Interpreter (CPython by default)
     │
     ▼
Bytecode (.pyc) — intermediate representation
     │
     ▼
Python Virtual Machine (PVM) executes it
     │
     ▼
Output / Result

🛠️ Setting Up Python

# Check if Python is installed
python --version        # or python3 --version

# Install pip packages
pip install numpy pandas matplotlib

# Create a virtual environment (recommended for every project)
python -m venv venv
source venv/bin/activate        # macOS / Linux
venv\Scripts\activate           # Windows

# Run a Python file
python my_script.py

# Open the interactive REPL
python

📋 Table of Contents

  1. Basics
  2. Control Flow
  3. Functions & Modules
  4. Object-Oriented Programming
  5. Data Structures
  6. File Handling
  7. Error Handling
  8. Libraries & Packages
  9. Advanced Topics
  10. Working with APIs
  11. Testing & Debugging
  12. Next Steps

1. Basics

The basics form the foundation of every Python program. Before writing any logic, you need to understand how Python stores data (variables and data types), how it performs calculations (operators), and how it works with text (strings). These concepts appear in every Python program regardless of domain.

Variables & Data Types

A variable is a named container that holds a value. In Python, you don't need to declare the type — Python infers it automatically based on the value you assign. This is called dynamic typing.

Python has several built-in data types:

  • int — whole numbers (e.g., 5, -20, 1000)
  • float — decimal numbers (e.g., 3.14, -0.001)
  • complex — complex numbers with real and imaginary parts (e.g., 3+4j)
  • str — text, enclosed in single or double quotes
  • boolTrue or False only
  • NoneType — represents the absence of a value (None)
# Numeric types
age     = 25          # int
price   = 19.99       # float
score   = 3 + 4j      # complex

# Text
name    = "Karthik"   # str

# Boolean
is_active = True      # bool

# None — commonly used as a default or placeholder
result  = None        # NoneType

# Check the type of any variable
print(type(age))      # <class 'int'>
print(type(name))     # <class 'str'>

Type conversion — explicitly converting one type to another:

x = int("42")         # str → int:   42
y = float(10)         # int → float: 10.0
z = str(3.14)         # float → str: "3.14"
b = bool(0)           # 0 = False; any non-zero = True

💡 Python also has implicit conversion — for example, adding an int and a float automatically produces a float.


Operators

Operators are symbols that perform operations on values (called operands). Python has several categories of operators:

Arithmetic operators perform mathematical calculations:

print(10 + 3)   # 13   — addition
print(10 - 3)   # 7    — subtraction
print(10 * 3)   # 30   — multiplication
print(10 / 3)   # 3.33 — true division (always float)
print(10 ** 2)  # 100  — exponentiation (power)
print(10 // 3)  # 3    — floor division (truncates decimal)
print(10 % 3)   # 1    — modulus (remainder after division)

Comparison operators compare two values and return a boolean:

print(5 > 3)    # True  — greater than
print(5 < 3)    # False — less than
print(5 >= 5)   # True  — greater than or equal
print(5 == 5)   # True  — equal to (note: == not =)
print(5 != 4)   # True  — not equal to

Logical operators combine boolean expressions:

print(True and False)   # False — both must be True
print(True or False)    # True  — at least one must be True
print(not True)         # False — reverses the boolean

Assignment shorthand — modify a variable in place:

x = 10
x += 5    # x = x + 5 → 15
x -= 3    # x = x - 3 → 12
x *= 2    # x = x * 2 → 24
x //= 4   # x = x // 4 → 6

Identity and membership operators:

print(type(x) is int)       # True  — checks if same object type
print(type(x) is not str)   # True
print("a" in "Karthik")     # True  — checks if substring exists
print("z" not in "Karthik") # True

Strings

A string is a sequence of characters enclosed in single ('), double ("), or triple (''' / """) quotes. Strings are immutable in Python — you cannot change a character in place, but you can create new strings from existing ones.

Strings are one of the most commonly used data types, especially when working with user input, files, and APIs.

s = "Hello, Python!"

# Common string methods
print(s.upper())                    # HELLO, PYTHON!
print(s.lower())                    # hello, python!
print(s.title())                    # Hello, Python!
print(s.replace("Python", "World")) # Hello, World!
print(s.split(", "))                # ['Hello', 'Python!']
print(s.strip())                    # removes leading/trailing whitespace
print(s.startswith("Hello"))        # True
print(s.endswith("!"))              # True
print(s.find("Python"))             # 7 — index of first occurrence
print(len(s))                       # 15 — number of characters

f-strings (formatted string literals) — the modern, recommended way to embed variables in strings:

name = "Karthik"
age  = 25
role = "Data Scientist"

# f-string — prefix with f, embed expressions in {}
print(f"My name is {name} and I am {age} years old.")
print(f"Role: {role.upper()}")
print(f"Next year I'll be {age + 1}.")
print(f"Pi to 3 decimal places: {3.14159:.3f}")

String slicing — extract portions of a string using index ranges:

s = "Hello, Python!"
#    0123456789...

print(s[0])       # H       — first character
print(s[-1])      # !       — last character
print(s[0:5])     # Hello   — characters 0 to 4
print(s[7:])      # Python! — from index 7 to end
print(s[:5])      # Hello   — from start to index 4
print(s[::2])     # Hlo yhn — every 2nd character
print(s[::-1])    # !nohtyP ,olleH — reversed string

Multi-line strings:

bio = """
Karthik Boodidha
Data Scientist
Greater Hyderabad, India
"""
print(bio)

2. Control Flow

Control flow determines which parts of your code run and in what order. By default, Python executes code top-to-bottom — but control flow statements let you make decisions, repeat actions, and skip code based on conditions. Without control flow, every program would just be a list of instructions with no branching or repetition.

if / elif / else

The if statement lets your program make decisions. Python evaluates a condition — if it's True, the indented block runs; if False, Python moves to the next elif or else.

score = 85

if score >= 90:
    print("A grade — Excellent!")
elif score >= 75:
    print("B grade — Good job!")
elif score >= 60:
    print("C grade — Passing.")
else:
    print("Below passing — keep practicing.")

# Ternary (one-liner) — for simple conditions
result = "Pass" if score >= 60 else "Fail"
print(result)

# Nested if
age = 20
has_id = True
if age >= 18:
    if has_id:
        print("Entry allowed.")
    else:
        print("ID required.")
else:
    print("Too young to enter.")

Loops

Loops allow you to repeat a block of code multiple times without writing it out repeatedly. Python has two types of loops: for (iterate over a sequence) and while (repeat while a condition is true).

for loop — iterates over any iterable (list, string, range, dict, etc.):

# Iterate over a list
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# range(start, stop, step) — generates a sequence of numbers
for i in range(1, 6):      # 1, 2, 3, 4, 5
    print(i)

for i in range(0, 20, 5):  # 0, 5, 10, 15
    print(i)

# Iterate over a string character by character
for char in "Python":
    print(char)

while loop — repeats as long as a condition remains True:

count = 0
while count < 5:
    print(f"Count: {count}")
    count += 1      # always update the condition variable to avoid infinite loops

Loop control statements — modify how loops behave:

# break — immediately exit the loop
for i in range(10):
    if i == 7:
        break       # stops at 7
    print(i)

# continue — skip current iteration and move to next
for i in range(10):
    if i == 3:
        continue    # skip 3, keep going
    print(i)

# else clause on loops — runs if loop completed without break
for i in range(5):
    print(i)
else:
    print("Loop finished normally")

Useful built-in loop tools:

fruits = ["apple", "banana", "cherry"]

# enumerate() — get both index and value
for i, fruit in enumerate(fruits):
    print(f"{i}: {fruit}")

# zip() — iterate two (or more) iterables in parallel
names  = ["Alice", "Bob", "Charlie"]
scores = [90, 85, 78]
for name, score in zip(names, scores):
    print(f"{name}: {score}")

# reversed() — iterate backwards
for fruit in reversed(fruits):
    print(fruit)

List Comprehensions

List comprehensions are a concise, Pythonic way to create new lists by applying an expression to each item in an iterable — optionally filtering with a condition. They replace verbose for loops for simple transformations and are both faster and more readable.

# Standard list comprehension
squares = [x**2 for x in range(1, 6)]
# Equivalent to:
# squares = []
# for x in range(1, 6):
#     squares.append(x**2)
# Result: [1, 4, 9, 16, 25]

# With condition (filter)
evens = [x for x in range(20) if x % 2 == 0]
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

# Transform strings
upper_fruits = [f.upper() for f in ["apple", "banana"]]

# Nested (creates matrix)
matrix = [[i * j for j in range(1, 4)] for i in range(1, 4)]
# [[1, 2, 3], [2, 4, 6], [3, 6, 9]]

# Dict comprehension
word_lengths = {word: len(word) for word in ["python", "is", "great"]}
# {'python': 6, 'is': 2, 'great': 5}

# Set comprehension
unique_lengths = {len(word) for word in ["cat", "dog", "elephant", "ant"]}

3. Functions & Modules

Functions are reusable blocks of code that perform a specific task. Instead of repeating the same logic in multiple places, you define it once in a function and call it wherever needed. This makes code cleaner, easier to test, and easier to maintain — following the DRY (Don't Repeat Yourself) principle.

Modules are Python files (.py) that contain functions, classes, and variables you can import and reuse across different programs.

Defining Functions

# Basic function
def greet(name):
    """Return a personalised greeting. (This is a docstring — always write one!)"""
    return f"Hello, {name}!"

print(greet("Karthik"))   # Hello, Karthik!

# Default parameter — used when caller doesn't provide a value
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Karthik"))              # Hello, Karthik!
print(greet("Karthik", "Welcome"))   # Welcome, Karthik!

# Multiple return values (Python returns a tuple)
def min_max(numbers):
    return min(numbers), max(numbers)

low, high = min_max([3, 1, 9, 5, 2])
print(f"Min: {low}, Max: {high}")

*args and **kwargs

Sometimes you don't know in advance how many arguments a function will receive. Python provides two special syntaxes for this:

  • *args — collects any number of positional arguments into a tuple
  • **kwargs — collects any number of keyword arguments into a dictionary
# *args — variable positional arguments
def sum_all(*args):
    """Sum any number of values."""
    print(f"Received: {args}")   # args is a tuple
    return sum(args)

print(sum_all(1, 2, 3))         # 6
print(sum_all(10, 20, 30, 40))  # 100

# **kwargs — variable keyword arguments
def display_profile(**kwargs):
    """Display any key-value profile information."""
    for key, value in kwargs.items():
        print(f"  {key}: {value}")

display_profile(name="Karthik", role="Data Scientist", city="Hyderabad")

# Combining both
def mixed(required, *args, **kwargs):
    print(f"Required: {required}")
    print(f"Extra args: {args}")
    print(f"Extra kwargs: {kwargs}")

mixed("hello", 1, 2, 3, color="blue", size=10)

Lambda Functions

A lambda is a small, anonymous (nameless) function defined in a single line. It's useful when you need a simple function for a short period — most commonly as an argument to map(), filter(), or sorted().

# Regular function vs lambda
def square(x):
    return x ** 2

square_lambda = lambda x: x ** 2

print(square(5))        # 25
print(square_lambda(5)) # 25

# Lambda with multiple parameters
add = lambda a, b: a + b
print(add(3, 4))        # 7

# Common use cases
nums = [3, 1, 4, 1, 5, 9, 2, 6]

# sorted with custom key
print(sorted(nums, reverse=True))

# map — apply function to every item
doubled = list(map(lambda x: x * 2, nums))
print(doubled)

# filter — keep only items where function returns True
big_nums = list(filter(lambda x: x > 4, nums))
print(big_nums)

# Sort list of dicts by a key
people = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
sorted_people = sorted(people, key=lambda p: p["age"])

Modules

A module is a Python file containing reusable code. Python has a rich standard library (built-in modules) and a massive ecosystem of third-party packages available via pip.

# Import entire module
import math
print(math.sqrt(16))     # 4.0
print(math.pi)           # 3.141592653589793
print(math.ceil(4.2))    # 5
print(math.floor(4.8))   # 4

# Import specific items (avoids loading the whole module)
from math import sqrt, pi
print(sqrt(25))   # 5.0

# Import with an alias (common convention for large libraries)
import numpy as np          # np is the standard alias
import pandas as pd         # pd is the standard alias
import matplotlib.pyplot as plt

# Create your own module
# Save this as utils.py:
def clean_text(text):
    return text.strip().lower()

# Then in another file:
# from utils import clean_text

4. Object-Oriented Programming

Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — bundles of data (attributes) and behaviour (methods). Instead of writing a series of standalone functions, you model real-world entities as classes.

OOP is important because it makes complex programs easier to organise, reuse, and maintain. Python supports full OOP with classes, inheritance, encapsulation, and polymorphism.

Key concepts:

  • Class — a blueprint or template for creating objects
  • Object / Instance — a specific realisation of a class
  • Attribute — a variable attached to an object (its data)
  • Method — a function defined inside a class (its behaviour)

Classes & Objects

class Employee:
    # Class attribute — shared by ALL instances
    company = "TechCorp"

    def __init__(self, name, role, salary):
        """
        __init__ is the constructor — called automatically when you
        create a new instance. 'self' refers to the current object.
        """
        self.name   = name      # instance attributes — unique per object
        self.role   = role
        self.salary = salary

    def describe(self):
        """Instance method — uses self to access the object's data."""
        return f"{self.name}{self.role} at {self.company}"

    def give_raise(self, amount):
        self.salary += amount
        return self.salary

    def __str__(self):
        """Called when you print() the object."""
        return f"Employee({self.name})"

    def __repr__(self):
        """Called in the REPL or for debugging — should be unambiguous."""
        return f"Employee(name={self.name!r}, role={self.role!r})"


# Creating instances
emp1 = Employee("Karthik", "Data Scientist", 80000)
emp2 = Employee("Alice", "ML Engineer", 95000)

print(emp1.describe())
print(emp1.give_raise(10000))   # 90000
print(emp2.name)                # Alice
print(Employee.company)         # TechCorp — via class
print(emp1.company)             # TechCorp — via instance

Inheritance

Inheritance allows a class (child) to inherit attributes and methods from another class (parent). This promotes code reuse — you define common behaviour in the parent and specialise it in children.

class Manager(Employee):
    """Manager inherits everything from Employee and adds team_size."""

    def __init__(self, name, salary, team_size):
        # super() calls the parent class's __init__
        super().__init__(name, "Manager", salary)
        self.team_size = team_size

    def describe(self):
        # Override the parent method, then extend it
        base = super().describe()
        return f"{base} | Team size: {self.team_size}"

    def hold_meeting(self):
        return f"{self.name} is holding a meeting with {self.team_size} people."


mgr = Manager("Alice", 120000, 8)
print(mgr.describe())
print(mgr.hold_meeting())
print(isinstance(mgr, Employee))   # True — Manager IS an Employee
print(isinstance(mgr, Manager))    # True

Encapsulation & Properties

Encapsulation means hiding internal implementation details and exposing only what's necessary. Python uses naming conventions:

  • _var — protected (by convention — don't access from outside)
  • __var — name-mangled (harder to access accidentally)

@property lets you define getter/setter logic while keeping attribute-style access syntax.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner    = owner
        self._balance = balance     # _balance is "protected"

    @property
    def balance(self):
        """Getter — called when you read acc.balance"""
        return self._balance

    @balance.setter
    def balance(self, value):
        """Setter — called when you write acc.balance = X"""
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount


acc = BankAccount("Karthik", 1000)
print(acc.balance)      # 1000 — calls getter
acc.balance = 1500      # calls setter — validates first
acc.deposit(500)
acc.withdraw(200)
print(acc.balance)      # 1800

Dunder (Magic) Methods

Dunder methods (double underscore) let you define how Python's built-in operators and functions behave for your custom objects. They're what makes +, len(), print(), [], and == work on your classes.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        """Enable v1 + v2"""
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        """Enable v1 - v2"""
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        """Enable v * 3"""
        return Vector(self.x * scalar, self.y * scalar)

    def __len__(self):
        """Enable len(v)"""
        return 2

    def __eq__(self, other):
        """Enable v1 == v2"""
        return self.x == other.x and self.y == other.y

    def __str__(self):
        return f"Vector({self.x}, {self.y})"


v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)    # Vector(4, 6)
print(v1 * 3)     # Vector(3, 6)
print(len(v1))    # 2
print(v1 == v2)   # False

5. Data Structures

Data structures are containers that organise and store data in different ways. Choosing the right structure for your data dramatically affects both the clarity of your code and its performance. Python has four built-in collection types — each with different properties around ordering, mutability, and uniqueness.

Structure Ordered Mutable Allows Duplicates Key Use
List General-purpose sequence
Tuple Fixed data, dict keys
Set Unique items, fast lookup
Dictionary ✅ (3.7+) Keys: ❌ / Values: ✅ Key-value pairs

Lists

A list is an ordered, mutable sequence. It's the most commonly used data structure in Python — great for storing collections of items where order matters and you may need to add, remove, or change items.

# Creating lists
nums    = [1, 2, 3, 4, 5]
mixed   = [1, "hello", 3.14, True]   # can hold any type
nested  = [[1, 2], [3, 4], [5, 6]]   # list of lists

# Accessing elements (zero-indexed)
print(nums[0])    # 1   — first element
print(nums[-1])   # 5   — last element
print(nums[-2])   # 4   — second to last

# Slicing [start:stop:step]
print(nums[1:4])   # [2, 3, 4]
print(nums[::2])   # [1, 3, 5] — every other
print(nums[::-1])  # [5, 4, 3, 2, 1] — reversed

# Modifying
nums.append(6)          # add to end
nums.insert(0, 0)       # insert at specific index
nums.extend([7, 8])     # add multiple items

nums.remove(3)          # remove first occurrence of value
popped = nums.pop()     # remove & return last item
popped = nums.pop(0)    # remove & return item at index 0
del nums[1]             # delete item at index

# Sorting
nums.sort()             # sort in place (ascending)
nums.sort(reverse=True) # sort descending
sorted_copy = sorted(nums)   # returns new sorted list

# Useful operations
print(len(nums))           # length
print(sum(nums))           # sum
print(min(nums))           # minimum
print(max(nums))           # maximum
print(nums.count(2))       # count occurrences of 2
print(nums.index(4))       # index of first occurrence of 4
print(3 in nums)           # check membership

Tuples

A tuple is an ordered, immutable sequence. Once created, its contents cannot be changed. Use tuples for data that should not change — coordinates, RGB values, database rows, or as dictionary keys.

# Creating tuples
point   = (10, 20)
rgb     = (255, 128, 0)
single  = (42,)          # note the comma — required for single-element tuple
empty   = ()

# Accessing (same as lists)
print(point[0])    # 10
print(point[-1])   # 20

# Unpacking — assign tuple values to variables
x, y = point
print(f"x={x}, y={y}")

# Swap variables elegantly using tuple unpacking
a, b = 5, 10
a, b = b, a
print(a, b)   # 10 5

# Extended unpacking
first, *rest = (1, 2, 3, 4, 5)
print(first)   # 1
print(rest)    # [2, 3, 4, 5]

# Tuple methods
coords = (1, 2, 3, 1, 2)
print(coords.count(1))   # 2 — how many times 1 appears
print(coords.index(3))   # 2 — index of first 3

# Tuples as dict keys (lists can't be used as keys)
locations = {(10, 20): "office", (0, 0): "origin"}

Sets

A set is an unordered collection of unique elements. Sets are extremely fast at checking membership (in operator) compared to lists. Use sets when you need to eliminate duplicates or perform mathematical set operations.

# Creating sets
tags    = {"python", "data", "ml", "python"}  # duplicates removed
print(tags)    # {'python', 'data', 'ml'} — order not guaranteed

empty_set = set()   # NOT {} — that creates an empty dict

# Adding / removing
tags.add("ai")
tags.discard("data")    # remove if exists (no error if missing)
tags.remove("ml")       # remove — raises KeyError if missing

# Set operations — the real power of sets
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

print(a | b)    # union:           {1, 2, 3, 4, 5, 6}
print(a & b)    # intersection:    {3, 4}
print(a - b)    # difference:      {1, 2}
print(a ^ b)    # symmetric diff:  {1, 2, 5, 6}

# Subset / superset checks
print({1, 2}.issubset({1, 2, 3}))     # True
print({1, 2, 3}.issuperset({1, 2}))   # True

# Fast duplicate removal from a list
nums = [1, 2, 2, 3, 3, 3, 4]
unique_nums = list(set(nums))

Dictionaries

A dictionary stores data as key-value pairs — like a real dictionary where you look up a word (key) to get its definition (value). Keys must be unique and immutable; values can be anything. Dictionaries are the backbone of Python's data model and are used everywhere.

# Creating a dictionary
person = {
    "name":   "Karthik",
    "role":   "Data Scientist",
    "skills": ["Python", "SQL", "ML"],
    "active": True
}

# Accessing values
print(person["name"])                  # Karthik — raises KeyError if missing
print(person.get("age"))               # None — safe, no error
print(person.get("age", "Unknown"))    # Unknown — with default

# Modifying
person["age"]  = 25                    # add new key
person["role"] = "Senior Data Scientist"   # update existing key
person.update({"city": "Hyderabad", "years_exp": 2})   # update multiple

# Removing
del person["active"]
removed = person.pop("years_exp")      # remove and return value
person.popitem()                       # remove and return last inserted pair

# Iterating
for key in person:                     # iterate keys
    print(key)

for key, value in person.items():     # iterate key-value pairs
    print(f"  {key}: {value}")

for value in person.values():         # iterate values only
    print(value)

# Useful methods
print("name" in person)               # True — key existence check
print(list(person.keys()))            # all keys as list
print(list(person.values()))          # all values as list

# Nested dictionary
employees = {
    "E001": {"name": "Karthik", "dept": "Data"},
    "E002": {"name": "Alice",   "dept": "Engineering"},
}
print(employees["E001"]["name"])      # Karthik

# Dictionary comprehension
squares = {x: x**2 for x in range(1, 6)}
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# defaultdict — avoids KeyError for missing keys
from collections import defaultdict
word_count = defaultdict(int)
for word in "the quick brown fox jumps over the lazy dog".split():
    word_count[word] += 1

6. File Handling

File handling lets your programs read from and write to files on disk — essential for working with data files, logs, configurations, and reports. Python's built-in open() function handles this, and the with statement ensures files are always properly closed even if an error occurs.

# Writing a file (creates it if it doesn't exist, overwrites if it does)
with open("notes.txt", "w") as f:
    f.write("Python is awesome!\n")
    f.write("Line 2\n")
    f.writelines(["Line 3\n", "Line 4\n"])   # write multiple lines at once
# File is automatically closed here — even if an error occurs

# Reading the entire file at once
with open("notes.txt", "r") as f:
    content = f.read()
    print(content)

# Reading line by line (memory-efficient for large files)
with open("notes.txt", "r") as f:
    for line in f:
        print(line.strip())   # strip() removes the trailing \n

# Read all lines into a list
with open("notes.txt", "r") as f:
    lines = f.readlines()
    print(lines)   # ['Python is awesome!\n', 'Line 2\n', ...]

# Appending — adds to end without overwriting
with open("notes.txt", "a") as f:
    f.write("Appended line\n")

File modes:

Mode Description
"r" Read only (default) — error if file doesn't exist
"w" Write — creates file or overwrites existing
"a" Append — adds to end, creates if doesn't exist
"x" Create — fails with error if file already exists
"rb" Read binary (images, PDFs, etc.)
"wb" Write binary

Working with file paths using pathlib — the modern, recommended approach:

from pathlib import Path

# Create a Path object
p = Path("data/notes.txt")

# Check properties
print(p.exists())       # True/False
print(p.is_file())      # True
print(p.is_dir())       # False
print(p.suffix)         # .txt
print(p.stem)           # notes
print(p.parent)         # data
print(p.name)           # notes.txt

# Create directories
Path("data/output").mkdir(parents=True, exist_ok=True)

# Read/write with pathlib
p.write_text("Hello from pathlib!")
content = p.read_text()

# List all .csv files in a folder
csv_files = list(Path("data").glob("*.csv"))

Working with CSV files:

import csv

# Write CSV
with open("data.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["Name", "Age", "Score"])       # header
    writer.writerows([["Alice", 25, 90], ["Bob", 30, 85]])

# Read CSV
with open("data.csv", "r") as f:
    reader = csv.DictReader(f)   # reads rows as dicts
    for row in reader:
        print(row["Name"], row["Score"])

7. Error Handling

Errors are inevitable — files may not exist, users provide bad input, networks go down. Error handling lets your program respond gracefully to these situations instead of crashing.

Python uses exceptions — special objects that represent errors. The try/except block lets you intercept exceptions and decide what to do.

Common built-in exceptions:

Exception When It Occurs
ValueError Wrong value type or range
TypeError Wrong data type for operation
KeyError Dict key doesn't exist
IndexError List index out of range
FileNotFoundError File doesn't exist
ZeroDivisionError Division by zero
AttributeError Object doesn't have the attribute
ImportError Module can't be imported
# Basic try/except
try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(f"Caught an error: {e}")

# Catching multiple specific exceptions
try:
    value = int(input("Enter a number: "))
    print(10 / value)
except ValueError:
    print("That's not a valid number.")
except ZeroDivisionError:
    print("Cannot divide by zero.")
except Exception as e:
    # Catch-all — use sparingly; always handle specific exceptions first
    print(f"Unexpected error: {type(e).__name__}: {e}")
finally:
    # Always runs — whether or not an exception occurred
    # Use for cleanup (closing files, database connections, etc.)
    print("This always executes.")

# else block — runs ONLY if no exception was raised in try
try:
    data = int("42")
except ValueError:
    print("Conversion failed.")
else:
    print(f"Successfully converted: {data}")   # runs here
finally:
    print("Done.")

Custom Exceptions

Define your own exception classes to make errors more descriptive and meaningful to callers of your code.

class InsufficientFundsError(Exception):
    """Raised when a withdrawal exceeds the account balance."""
    def __init__(self, amount, balance):
        self.amount  = amount
        self.balance = balance
        super().__init__(
            f"Cannot withdraw ₹{amount:,.2f}. "
            f"Current balance: ₹{balance:,.2f}. "
            f"Shortfall: ₹{amount - balance:,.2f}."
        )


class InvalidAgeError(ValueError):
    """Raised when age is outside a valid range."""
    pass


def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("Withdrawal amount must be positive.")
    if amount > balance:
        raise InsufficientFundsError(amount, balance)
    return balance - amount


def register_user(age):
    if not (0 < age < 120):
        raise InvalidAgeError(f"Age {age} is not valid.")
    return f"User registered with age {age}."


# Using the custom exceptions
try:
    withdraw(500, 1000)
except InsufficientFundsError as e:
    print(e)

try:
    register_user(200)
except InvalidAgeError as e:
    print(f"Registration failed: {e}")

8. Libraries & Packages

One of Python's greatest strengths is its enormous ecosystem of libraries. A library is a collection of pre-written code that you can import and use in your own programs. Instead of building everything from scratch, you leverage what the community has already created and tested.

Install third-party packages with:

pip install package-name

NumPy

NumPy (Numerical Python) is the foundation of Python's data science ecosystem. It provides fast, memory-efficient n-dimensional arrays and vectorised mathematical operations. Under the hood, NumPy operations are written in C, making them far faster than Python loops.

import numpy as np

# Creating arrays
arr     = np.array([1, 2, 3, 4, 5])            # 1D array
matrix  = np.array([[1, 2, 3], [4, 5, 6]])     # 2D array (matrix)
zeros   = np.zeros((3, 4))                      # 3x4 matrix of zeros
ones    = np.ones((2, 3))                       # 2x3 matrix of ones
eye     = np.eye(3)                             # 3x3 identity matrix
rand    = np.random.randn(4, 4)                 # random normal values
arange  = np.arange(0, 10, 2)                  # [0, 2, 4, 6, 8]
linspace = np.linspace(0, 1, 5)                # [0, 0.25, 0.5, 0.75, 1.0]

# Array properties
print(arr.shape)     # (5,)     — dimensions
print(matrix.shape)  # (2, 3)
print(arr.dtype)     # int64    — data type
print(arr.ndim)      # 1        — number of dimensions

# Element-wise operations (vectorised — no loops needed)
print(arr + 10)           # [11 12 13 14 15]
print(arr * 2)            # [2 4 6 8 10]
print(arr ** 2)           # [1 4 9 16 25]
print(arr > 3)            # [False False False True True]

# Statistical methods
print(arr.mean())         # 3.0
print(arr.std())          # standard deviation
print(arr.sum())          # 15
print(arr.min(), arr.max())

# Indexing and slicing
print(matrix[0])          # first row
print(matrix[:, 1])       # second column (all rows)
print(matrix[1, 2])       # row 1, column 2 → 6

# Reshaping
reshaped = arr.reshape(1, 5)     # 1 row, 5 columns
flat     = matrix.flatten()      # always returns 1D copy

# Matrix operations
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print(np.dot(A, B))              # matrix multiplication
print(A.T)                       # transpose

Pandas

Pandas is the go-to library for data analysis. It provides two main structures: Series (1D) and DataFrame (2D table). A DataFrame is like an Excel spreadsheet with superpowers — you can load, clean, transform, filter, aggregate, and export data with minimal code.

import pandas as pd

# Creating a DataFrame
df = pd.DataFrame({
    "Name":       ["Alice", "Bob", "Charlie", "Diana"],
    "Department": ["Data", "Engineering", "Data", "ML"],
    "Age":        [25, 30, 28, 32],
    "Salary":     [60000, 85000, 70000, 95000],
    "Active":     [True, True, False, True]
})

# Exploring the DataFrame
print(df.shape)           # (4, 5) — rows × columns
print(df.dtypes)          # data type of each column
print(df.head(2))         # first 2 rows
print(df.tail(2))         # last 2 rows
print(df.info())          # summary: columns, dtypes, non-null counts
print(df.describe())      # statistics for numeric columns
print(df.isnull().sum())  # count missing values per column

# Selecting data
print(df["Name"])                   # single column → Series
print(df[["Name", "Salary"]])       # multiple columns → DataFrame
print(df.iloc[0])                   # row by index position
print(df.iloc[0:2])                 # rows 0 and 1
print(df.loc[df["Age"] > 28])       # rows where Age > 28

# Filtering
active_df   = df[df["Active"] == True]
data_dept   = df[df["Department"] == "Data"]
high_earners = df[df["Salary"] > 75000]

# Multiple conditions
filtered = df[(df["Age"] > 26) & (df["Salary"] > 65000)]

# Adding / transforming columns
df["Salary_K"]      = df["Salary"] / 1000
df["Seniority"]     = df["Age"].apply(lambda x: "Senior" if x >= 30 else "Junior")
df["Name_Upper"]    = df["Name"].str.upper()

# Renaming and dropping
df = df.rename(columns={"Active": "Is_Active"})
df = df.drop(columns=["Name_Upper"])

# Aggregation
print(df["Salary"].mean())
print(df.groupby("Department")["Salary"].mean())
print(df.groupby("Department").agg({"Salary": ["mean", "max"], "Age": "mean"}))

# Sorting
df_sorted = df.sort_values("Salary", ascending=False)

# Handling missing values
df.fillna(0, inplace=True)          # fill NaN with 0
df.dropna(inplace=True)             # drop rows with any NaN

# Reading / writing
df.to_csv("output.csv", index=False)
df = pd.read_csv("data.csv")
df = pd.read_excel("data.xlsx")
df.to_excel("output.xlsx", index=False)

Matplotlib & Seaborn

Matplotlib is the foundational plotting library — it gives you full control over every element of a chart. Seaborn is built on top of Matplotlib and provides beautiful statistical visualisations with minimal code.

import matplotlib.pyplot as plt
import seaborn as sns

# ── Matplotlib ──────────────────────────────────────────────────

# Line plot
x = [1, 2, 3, 4, 5]
y = [2, 4, 6, 8, 10]

plt.figure(figsize=(9, 4))
plt.plot(x, y, marker="o", color="steelblue", linewidth=2, label="Growth")
plt.title("Sample Line Chart", fontsize=14, fontweight="bold")
plt.xlabel("X Axis")
plt.ylabel("Y Axis")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

# Bar chart
categories = ["Python", "SQL", "ML", "DL"]
values     = [90, 80, 75, 65]

plt.figure(figsize=(8, 4))
plt.bar(categories, values, color=["steelblue", "salmon", "green", "orange"])
plt.title("Skill Proficiency Scores")
plt.ylabel("Score")
plt.tight_layout()
plt.show()

# Save instead of show
plt.savefig("chart.png", dpi=150, bbox_inches="tight")

# ── Seaborn ─────────────────────────────────────────────────────

tips = sns.load_dataset("tips")   # built-in sample dataset

# Distribution plot
sns.histplot(tips["total_bill"], kde=True)
plt.show()

# Box plot — shows distribution, median, and outliers
sns.boxplot(x="day", y="total_bill", data=tips, palette="Set2")
plt.show()

# Scatter plot with regression line
sns.regplot(x="total_bill", y="tip", data=tips)
plt.show()

# Heatmap — great for correlation matrices
corr = tips.select_dtypes("number").corr()
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm")
plt.show()

# Pair plot — scatter plots for all pairs of numeric columns
sns.pairplot(tips, hue="sex")
plt.show()

Scikit-learn

Scikit-learn is Python's primary machine learning library. It provides consistent, easy-to-use implementations of dozens of ML algorithms along with tools for preprocessing, model evaluation, and pipeline building.

from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (accuracy_score, classification_report,
                              confusion_matrix, roc_auc_score)
from sklearn.pipeline import Pipeline
import joblib

# 1. Prepare data
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42,
    stratify=y          # preserve class distribution in both splits
)

# 2. Build a pipeline — chains preprocessing + model together
pipeline = Pipeline([
    ("scaler", StandardScaler()),           # step 1: scale features
    ("model",  RandomForestClassifier(      # step 2: train model
        n_estimators=100,
        random_state=42
    ))
])

# 3. Train
pipeline.fit(X_train, y_train)

# 4. Evaluate
y_pred = pipeline.predict(X_test)
y_prob = pipeline.predict_proba(X_test)[:, 1]   # probability of positive class

print(f"Accuracy:  {accuracy_score(y_test, y_pred):.4f}")
print(f"AUC-ROC:   {roc_auc_score(y_test, y_prob):.4f}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred))

# 5. Cross-validation — more robust than single train/test split
cv_scores = cross_val_score(pipeline, X, y, cv=5, scoring="roc_auc")
print(f"CV AUC-ROC: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}")

# 6. Save and load model
joblib.dump(pipeline, "model/classifier.pkl")
loaded_model = joblib.load("model/classifier.pkl")

9. Advanced Topics

These topics go beyond the fundamentals and are what separate intermediate Python developers from advanced ones. They help you write more elegant, efficient, and Pythonic code.

Decorators

A decorator is a function that wraps another function to extend or modify its behaviour — without changing the original function's source code. Decorators follow the Open/Closed Principle: open for extension, closed for modification.

They're used extensively for logging, timing, caching, authentication, input validation, and more.

import time
from functools import wraps

# Basic decorator
def timer(func):
    """Measure and print a function's execution time."""
    @wraps(func)    # preserves the original function's name and docstring
    def wrapper(*args, **kwargs):
        start  = time.time()
        result = func(*args, **kwargs)           # call the original function
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_computation():
    """Simulates a slow operation."""
    time.sleep(1)
    return sum(range(1_000_000))

result = slow_computation()   # automatically timed

# Decorator with arguments
def retry(max_attempts=3, delay=1.0):
    """Retry a function up to max_attempts times on exception."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt} failed: {e}")
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise RuntimeError(f"{func.__name__} failed after {max_attempts} attempts")
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
    import requests
    response = requests.get(url, timeout=5)
    response.raise_for_status()
    return response.json()

# Stacking decorators
@timer
@retry(max_attempts=2)
def unreliable_function():
    pass

Generators

A generator is a function that produces values lazily — one at a time — using the yield keyword. Unlike regular functions that return everything at once, generators only compute each value when asked. This makes them extremely memory-efficient for large datasets or infinite sequences.

# Generator function
def fibonacci(n):
    """Yield the first n Fibonacci numbers — without storing them all."""
    a, b = 0, 1
    for _ in range(n):
        yield a           # pause here and return a; resume next time
        a, b = b, a + b

# Iterate over the generator
for num in fibonacci(10):
    print(num, end=" ")   # 0 1 1 2 3 5 8 13 21 34

# Generator vs list — memory comparison
import sys

gen  = (x**2 for x in range(1_000_000))   # generator expression
lst  = [x**2 for x in range(1_000_000)]   # list comprehension

print(f"Generator size: {sys.getsizeof(gen)} bytes")    # ~120 bytes
print(f"List size:      {sys.getsizeof(lst)} bytes")    # ~8+ MB

# Controlling generators manually
gen = fibonacci(5)
print(next(gen))   # 0
print(next(gen))   # 1
print(next(gen))   # 1

# Infinite generator — never exhausted
def counter(start=0, step=1):
    n = start
    while True:
        yield n
        n += step

from itertools import islice
first_10_evens = list(islice(counter(0, 2), 10))
print(first_10_evens)   # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

Context Managers

A context manager manages resources automatically — ensuring setup and cleanup happen correctly even if errors occur. The with statement is Python's syntax for using context managers.

They're essential when working with files, database connections, locks, network sockets, or any resource that needs to be properly released.

# Built-in context manager (most common example)
with open("file.txt", "r") as f:
    data = f.read()
# f.close() is called automatically here — even if an exception occurs

# Custom context manager using a class
class DatabaseConnection:
    """Manages a database connection lifecycle."""
    def __init__(self, db_url):
        self.db_url = db_url

    def __enter__(self):
        print(f"Connecting to {self.db_url}...")
        # self.conn = create_connection(self.db_url)
        return self       # the value assigned to 'as'

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Closing connection.")
        # self.conn.close()
        return False   # False = don't suppress exceptions (let them propagate)

with DatabaseConnection("postgresql://localhost/mydb") as db:
    print("Executing queries...")
    # db.execute(...)

# Custom context manager using @contextmanager decorator (simpler)
from contextlib import contextmanager
import time

@contextmanager
def timed_block(label):
    """Time a block of code."""
    start = time.time()
    try:
        yield    # control passes to the with block here
    finally:
        elapsed = time.time() - start
        print(f"{label}: {elapsed:.4f}s")

with timed_block("Data processing"):
    time.sleep(0.3)
    result = sum(range(1_000_000))

Itertools & Functools

The itertools and functools modules provide powerful tools for working with iterables and functions — enabling functional programming patterns that make code more expressive and efficient.

from itertools import (chain, combinations, permutations,
                        product, groupby, islice, cycle, repeat)
from functools import reduce, partial, lru_cache

# itertools.chain — flatten multiple iterables into one
combined = list(chain([1, 2], [3, 4], [5, 6]))
# [1, 2, 3, 4, 5, 6]

# itertools.combinations — all unique combinations
pairs = list(combinations([1, 2, 3, 4], 2))
# [(1,2), (1,3), (1,4), (2,3), (2,4), (3,4)]

# itertools.product — cartesian product (like nested for loops)
grid = list(product([0, 1], repeat=3))   # all 3-bit binary combos

# itertools.groupby — group consecutive elements by key
data = [("A", 1), ("A", 2), ("B", 3), ("B", 4), ("C", 5)]
for key, group in groupby(data, key=lambda x: x[0]):
    print(key, list(group))

# functools.lru_cache — memoize (cache) expensive function calls
@lru_cache(maxsize=None)
def factorial(n):
    """Cached recursive factorial — second call with same n is instant."""
    return 1 if n <= 1 else n * factorial(n - 1)

print(factorial(10))   # computed
print(factorial(10))   # retrieved from cache instantly

# functools.partial — fix some arguments, create a new callable
def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
cube   = partial(power, exp=3)
print(list(map(square, [1, 2, 3, 4])))   # [1, 4, 9, 16]
print(list(map(cube,   [1, 2, 3, 4])))   # [1, 8, 27, 64]

# functools.reduce — cumulatively apply function to sequence
total   = reduce(lambda x, y: x + y, [1, 2, 3, 4, 5])   # 15
product = reduce(lambda x, y: x * y, [1, 2, 3, 4, 5])   # 120

10. Working with APIs

An API (Application Programming Interface) lets your Python program communicate with external services over the internet — fetching live data, sending messages, updating records, and more. The requests library is the standard tool for making HTTP requests in Python.

HTTP Methods:

Method Purpose
GET Retrieve data
POST Send / create data
PUT Update (replace) data
PATCH Partial update
DELETE Delete data

Making HTTP Requests

import requests

# ── GET request — retrieve data ──────────────────────────────────
response = requests.get("https://api.github.com/users/Karthik-1221")
print(response.status_code)    # 200 = OK, 404 = Not Found, etc.
print(response.headers["Content-Type"])
data = response.json()         # parse JSON response body
print(data["name"])
print(data["public_repos"])

# With query parameters — appended to URL as ?key=value
params   = {"q": "python machine learning", "sort": "stars"}
response = requests.get(
    "https://api.github.com/search/repositories",
    params=params
)
repos = response.json()["items"]

# ── POST request — send data ─────────────────────────────────────
payload = {"title": "New Post", "body": "Content here", "userId": 1}
headers = {"Content-Type": "application/json"}

response = requests.post(
    "https://jsonplaceholder.typicode.com/posts",
    json=payload,       # automatically serialises dict to JSON
    headers=headers
)
print(response.status_code)   # 201 = Created
print(response.json())

# ── Authentication ───────────────────────────────────────────────
# Bearer token (most common for modern APIs)
headers = {"Authorization": f"Bearer {api_key}"}

# Basic auth
response = requests.get(url, auth=("username", "password"))

# ── Request with timeout ─────────────────────────────────────────
response = requests.get(url, timeout=10)   # fail after 10 seconds

Error Handling for APIs

def fetch_data(url: str, params: dict = None) -> dict:
    """
    Safely fetch JSON data from a URL.
    Returns empty dict on any error.
    """
    try:
        response = requests.get(url, params=params, timeout=10)
        response.raise_for_status()   # raises HTTPError for 4xx/5xx
        return response.json()

    except requests.exceptions.Timeout:
        print("⏰ Request timed out — server took too long to respond.")
    except requests.exceptions.ConnectionError:
        print("🔌 Connection failed — check your internet connection.")
    except requests.exceptions.HTTPError as e:
        status = e.response.status_code
        if status == 401:
            print("🔐 Unauthorised — check your API key.")
        elif status == 404:
            print("🔍 Resource not found — check the URL.")
        elif status == 429:
            print("⚠️ Rate limited — too many requests.")
        else:
            print(f"HTTP error {status}: {e}")
    except requests.exceptions.RequestException as e:
        print(f"❌ Unexpected error: {e}")

    return {}

Environment Variables for API Keys

import os
from dotenv import load_dotenv

load_dotenv()   # reads .env file and sets environment variables

api_key    = os.getenv("MY_API_KEY")
base_url   = os.getenv("API_BASE_URL", "https://api.example.com")  # with default
# .env — NEVER commit this file to Git
MY_API_KEY=sk-your-secret-key-here
API_BASE_URL=https://api.example.com
# .gitignore — add this line
.env

⚠️ Security rule: Never hardcode API keys in your source code. Always use environment variables. Add .env to .gitignore before your first commit.


11. Testing & Debugging

Writing code is only half the job — you also need to verify it works correctly and fix it when it doesn't. Testing ensures your code behaves as expected across normal and edge cases. Debugging is the process of finding and fixing errors.

Professional Python code is always tested. Untested code is buggy code waiting to be discovered in production.

Unit Testing with pytest

pytest is the most popular Python testing framework. It's simple to write, powerful to run, and produces clear output.

# src/calculator.py — the code being tested
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def is_even(n):
    return n % 2 == 0
# tests/test_calculator.py — the tests
import pytest
from src.calculator import add, subtract, divide, is_even

# Basic tests — function name must start with test_
def test_add_two_positives():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, -1) == -2

def test_add_zero():
    assert add(0, 5) == 5

def test_subtract():
    assert subtract(10, 4) == 6

def test_divide_normal():
    assert divide(10, 2) == 5.0

def test_divide_by_zero_raises():
    """Test that the right exception is raised."""
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

def test_is_even_true():
    assert is_even(4) is True

def test_is_even_false():
    assert is_even(7) is False

# Parametrized test — run same test with multiple inputs
@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-5, 5, 0),
    (100, 200, 300),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected

# Fixtures — reusable setup code
@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum_of_sample(sample_data):
    assert sum(sample_data) == 15
# Running tests
pytest                         # run all tests
pytest tests/                  # run tests in specific folder
pytest tests/test_calculator.py # run specific file
pytest -v                      # verbose — shows test names
pytest -k "divide"             # run only tests with "divide" in name
pytest --tb=short              # shorter traceback
pytest --cov=src               # code coverage report

Debugging Techniques

# 1. Print debugging — quick and dirty, useful for simple issues
def process(data):
    print(f"DEBUG: input={data}, type={type(data)}")
    result = [x * 2 for x in data]
    print(f"DEBUG: result={result}")
    return result

# 2. assert statements — check assumptions in your code
def calculate_average(nums):
    assert len(nums) > 0, "List cannot be empty"
    assert all(isinstance(n, (int, float)) for n in nums), "All items must be numeric"
    return sum(nums) / len(nums)

# 3. Python Debugger (pdb) — interactive step-through debugging
import pdb

def buggy_function(data):
    total = 0
    pdb.set_trace()   # execution pauses here — type 'n' to step, 'p var' to print
    for item in data:
        total += item
    return total

# pdb commands: n(next), s(step into), c(continue), p(print), q(quit), l(list)

# 4. breakpoint() — Python 3.7+ cleaner alternative to pdb.set_trace()
def another_function(x):
    breakpoint()   # same as pdb.set_trace() but cleaner
    return x ** 2

# 5. Logging — production-grade debugging
import logging

# Configure once at the top of your main script
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
    handlers=[
        logging.StreamHandler(),               # print to console
        logging.FileHandler("app.log"),        # write to file
    ]
)

logger = logging.getLogger(__name__)   # use module name

def process_records(records):
    logger.info(f"Processing {len(records)} records")
    for i, record in enumerate(records):
        logger.debug(f"Processing record {i}: {record}")
        try:
            result = record["value"] * 2
        except KeyError:
            logger.warning(f"Record {i} missing 'value' key — skipping")
            continue
        except Exception as e:
            logger.error(f"Failed to process record {i}: {e}", exc_info=True)
    logger.info("Processing complete")

Logging levels — when to use each:

Level When to Use
DEBUG Detailed diagnostic info — only during development
INFO Confirmation that things are working normally
WARNING Something unexpected happened but the app can continue
ERROR A serious problem — something failed
CRITICAL The application cannot continue running

🚀 Next Steps

These notes cover the full Python fundamentals-to-advanced spectrum — but the best way to cement this knowledge is to build things.

  • 🔧 Run every snippet — every code block here is copy-paste runnable. Experiment and modify things to see what happens
  • 📓 Explore the notebooks — check the notebooks/ folder for interactive .ipynb versions of each section
  • 🏗️ Build a project — combine multiple sections: data pipeline (Pandas + File Handling + Error Handling), ML model (NumPy + Scikit-learn + Testing), or an API client (requests + OOP + Logging)
  • 📖 Official documentationdocs.python.org is comprehensive and well-written; bookmark it
  • 🧪 Write tests for everything — get in the habit of writing pytest tests alongside every function you write
  • 💬 Open a GitHub Discussion — found an error, have a cleaner approach, or want to add a topic? All contributions welcome!
  • Star the repo — if this helped you, a star helps others discover it

Made with 🐍 by Karthik Boodidha

LinkedIn GitHub Portfolio Kaggle YouTube