Python Intermediate Guide
Level up your Python with decorators, generators, advanced OOP, databases, APIs, testing, and real-world projects.
Start LearningAdvanced Functions
What you'll learn
- Accept any number of arguments with *args
- Accept keyword arguments with **kwargs
- Return multiple values from a function
Beginner functions take a fixed number of parameters. Intermediate functions handle flexible input: *args collects positional arguments into a tuple, **kwargs collects keyword arguments into a dict, and Python lets you return multiple values as a tuple.
def describe_person(name, *scores, **details):
print(name)
print("Scores:", scores)
print("Details:", details)
return len(scores), sum(scores)
count, total = describe_person("Sufyan", 85, 90, 78, city="Karachi", role="dev")
print(count, total)
Try it yourself
Write a function that takes a name and any number of marks, then returns the average.
def average(name, *marks):
if not marks:
return 0
return sum(marks) / len(marks)
print(average("Ali", 80, 90, 100))Scope & Closures
What you'll learn
- Understand local, enclosing, and global scope
- Use global and nonlocal
- Build a closure that remembers state
Scope decides where a variable is visible. A variable inside a function is local. nonlocal modifies a variable in an outer function, and global modifies a module-level variable. A closure is an inner function that remembers variables from its outer function even after that function returns.
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter())
print(counter())
print(counter())
Try it yourself
Create a counter that increments by 5 each time it is called.
def make_step_counter(step):
total = 0
def step_up():
nonlocal total
total += step
return total
return step_up
c = make_step_counter(5)
print(c())
print(c())Decorators
What you'll learn
- Explain what a decorator is
- Write a simple decorator with @
- Pass arguments through with *args and **kwargs
A decorator is a function that wraps another function to add behaviour without changing its code. You apply it with @decorator above the function. Inside, the wrapper calls the original function and can run code before or after it.
import functools
def announce(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}...")
result = func(*args, **kwargs)
print("Finished.")
return result
return wrapper
@announce
def add(a, b):
return a + b
print(add(3, 4))
Try it yourself
Write a decorator that prints the return value of a function after it runs.
import functools
def show_result(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print("Result:", result)
return result
return wrapper
@show_result
def double(x):
return x * 2
double(21)functools Tools
What you'll learn
- Use lru_cache to speed up repeated calls
- Use partial to pre-fill function arguments
- Use reduce to combine values
The functools module provides powerful utilities for functions. lru_cache memoizes results so expensive calls run once. partial freezes some arguments, creating a simpler function. reduce repeatedly applies a function to combine values in a sequence.
from functools import lru_cache, partial, reduce
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30))
add_five = partial(lambda a, b: a + b, 5)
print(add_five(10))
print(reduce(lambda a, b: a * b, [1, 2, 3, 4]))
Try it yourself
Use partial to create a function that multiplies any number by 3.
from functools import partial triple = partial(lambda a, b: a * b, 3) print(triple(7))
Advanced Collections
What you'll learn
- Use defaultdict to avoid key errors
- Use Counter to count items fast
- Use deque for fast appends and pops
collections offers specialised containers. defaultdict provides a default value for missing keys. Counter counts hashable items into a dict-like object. deque is a double-ended queue with fast operations on both ends — better than a list for queues.
from collections import defaultdict, Counter, deque
word_count = defaultdict(int)
for word in ["a", "b", "a", "c", "a"]:
word_count[word] += 1
print(dict(word_count))
print(Counter(["apple", "apple", "banana"]))
d = deque([1, 2, 3])
d.appendleft(0)
d.append(4)
print(d)
Try it yourself
Create a defaultdict(list) and append two values to the same missing key.
from collections import defaultdict d = defaultdict(list) d['x'].append(1) d['x'].append(2) print(d['x'])
Iterators
What you'll learn
- Understand iter() and next()
- Build a custom iterator class
- Handle StopIteration
An iterator is an object that produces values one at a time. iter() converts an iterable into an iterator. next() returns the next value and raises StopIteration when done. Custom iterators implement __iter__ and __next__.
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current < 0:
raise StopIteration
value = self.current
self.current -= 1
return value
for n in Countdown(3):
print(n)
Try it yourself
Create an iterator that counts up from 1 to 5.
class CountUp:
def __init__(self):
self.current = 0
def __iter__(self):
return self
def __next__(self):
self.current += 1
if self.current > 5:
raise StopIteration
return self.current
for n in CountUp():
print(n)Generators
What you'll learn
- Use yield to create a generator
- Understand lazy evaluation and memory savings
- Use yield from and generator expressions
A generator is a function that uses yield instead of return. It produces values lazily — one at a time — so large sequences don't consume memory. yield from delegates to another generator, and generator expressions are like list comprehensions but lazy.
def squares(limit):
for n in range(1, limit + 1):
yield n ** 2
for value in squares(5):
print(value)
print(sum(x for x in range(1, 101)))
Try it yourself
Write a generator that yields only even numbers up to 10.
def evens(limit):
for n in range(limit + 1):
if n % 2 == 0:
yield n
print(list(evens(10)))Comprehensions Deep Dive
What you'll learn
- Write nested comprehensions
- Add conditions to comprehensions
- Compare comprehensions with map and filter
Comprehensions build collections in one readable expression. You can nest loops, add if conditions, and choose between list, dict, and set forms. For simple transformations they are clearer than map/filter; for complex logic, a normal loop is better.
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [n for row in matrix for n in row]
print(flat)
evens = [n for n in range(10) if n % 2 == 0]
print(evens)
word_lengths = {w: len(w) for w in ["go", "python", "code"]}
print(word_lengths)
Try it yourself
Flatten a 2x2 matrix and keep only odd numbers.
matrix = [[1, 2], [3, 4]] result = [n for row in matrix for n in row if n % 2 == 1] print(result)
Class Methods & Static Methods
What you'll learn
- Use @classmethod and cls
- Use @staticmethod
- Build factory methods
Instance methods take self. Class methods take cls and can create objects — useful for alternate constructors (factory methods). Static methods take neither self nor cls and behave like plain functions placed inside a class for organisation.
class Employee:
company = "DocoDive"
def __init__(self, name, salary):
self.name = name
self.salary = salary
@classmethod
def from_string(cls, text):
name, salary = text.split("-")
return cls(name, int(salary))
@staticmethod
def is_valid_salary(salary):
return salary > 0
e = Employee.from_string("Sufyan-50000")
print(e.name, e.salary, e.company)
print(Employee.is_valid_salary(50000))
Try it yourself
Add a classmethod that creates an Employee from a dict.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
@classmethod
def from_dict(cls, data):
return cls(data["name"], data["salary"])
e = Employee.from_dict({"name": "Ali", "salary": 40000})
print(e.name, e.salary)Properties
What you'll learn
- Use @property for read-only access
- Add setters with validation
- Use deleters
Properties let you control attribute access. @property exposes a method as an attribute. The @setter validates values before assigning. This gives you clean attribute syntax with custom logic behind the scenes.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.area)
c.radius = 10
print(c.area)
Try it yourself
Create a Temperature class where the setter rejects values below -273.
class Temperature:
def __init__(self, c):
self.celsius = c
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273:
raise ValueError("Too cold")
self._celsius = value
t = Temperature(25)
print(t.celsius)Inheritance
What you'll learn
- Create subclasses with super()
- Override parent methods
- Use isinstance and issubclass
Inheritance lets a class reuse and extend another class. The child class inherits methods and attributes, can override them, and can call the parent with super(). isinstance checks an object's type, issubclass checks class relationships.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"
d = Dog("Rex")
c = Cat("Milo")
print(d.speak())
print(c.speak())
print(isinstance(d, Animal), issubclass(Dog, Animal))
Try it yourself
Create a Bird subclass whose speak() returns 'tweet' with the name.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Bird(Animal):
def speak(self):
return f"{self.name} says tweet!"
b = Bird("Kiwi")
print(b.speak())Dunder Methods
What you'll learn
- Use __str__ and __repr__
- Implement __eq__ for equality
- Implement __len__ and __lt__
Dunder (double underscore) methods control built-in behaviour. __str__ gives a readable string for users, __repr__ gives a developer-friendly representation. __eq__ defines ==, __len__ defines len(), and __lt__ enables sorting with <.
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
def __str__(self):
return f"{self.title}"
def __repr__(self):
return f"Book('{self.title}', {self.pages})"
def __eq__(self, other):
return self.pages == other.pages
def __len__(self):
return self.pages
b1 = Book("Python", 300)
b2 = Book("Java", 300)
print(str(b1))
print(repr(b1))
print(b1 == b2)
print(len(b1))
Try it yourself
Add __lt__ to Book so books can be sorted by pages.
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
def __lt__(self, other):
return self.pages < other.pages
books = [Book("B", 200), Book("A", 100)]
print([b.title for b in sorted(books)])Dataclasses
What you'll learn
- Use @dataclass to reduce boilerplate
- Add default values and fields
- Convert dataclasses to dicts
Dataclasses automatically generate __init__, __repr__, and __eq__ for simple data-holding classes. You add @dataclass, declare fields with type hints, and get a clean class with defaults and sorting for free.
from dataclasses import dataclass, asdict
@dataclass
class Product:
name: str
price: float
stock: int = 0
p = Product("Laptop", 999.99, 10)
print(p)
print(p == Product("Laptop", 999.99, 10))
print(asdict(p))
Try it yourself
Create a dataclass Person with name, age, and city defaulting to 'Unknown'.
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
city: str = "Unknown"
p = Person("Sufyan", 19)
print(p)Exception Handling Deep Dive
What you'll learn
- Create custom exception classes
- Use raise from to chain errors
- Use contextlib for clean handling
Beyond try/except, you can define your own exceptions by subclassing Exception. raise from preserves the original error when re-raising. contextlib.suppress cleanly ignores specific errors when you expect them.
class NegativeValueError(ValueError):
pass
def set_age(age):
if age < 0:
raise NegativeValueError("Age cannot be negative")
return age
try:
set_age(-5)
except NegativeValueError as e:
print("Caught:", e)
Try it yourself
Raise a custom error when a deposit amount is zero.
class InvalidDepositError(Exception):
pass
def deposit(amount):
if amount <= 0:
raise InvalidDepositError("Amount must be positive")
return amount
try:
deposit(0)
except InvalidDepositError as e:
print(e)File Handling & Pathlib
What you'll learn
- Use pathlib for cross-platform paths
- Read and write CSV and JSON
- Build context managers with with
pathlib provides an object-oriented, cross-platform way to work with paths — better than raw string paths. Python's csv and json modules handle structured data, and the with statement ensures files close correctly.
from pathlib import Path
import json, csv
base = Path("./data")
base.mkdir(exist_ok=True)
data = {"name": "Sufyan", "age": 19}
(base / "info.json").write_text(json.dumps(data))
print((base / "info.json").read_text())
with (base / "users.csv").open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["name", "age"])
writer.writerow(["Ali", 25])
print((base / "users.csv").exists())
Try it yourself
Create a reports/ folder and write a hello.txt inside it.
from pathlib import Path
p = Path("reports")
p.mkdir(exist_ok=True)
(p / "hello.txt").write_text("hi")
print((p / "hello.txt").read_text())Regular Expressions
What you'll learn
- Understand regex patterns and re functions
- Use match groups
- Match emails and phones with patterns
Regular expressions search and match text patterns. re.search finds the first match, re.findall returns all matches, and parentheses create groups. Patterns like \d+ match digits and [a-z]+ match lowercase words.
import re email = "Contact: [email protected]" phone = "Call 0300-1234567" match = re.search(r"([\w.]+)@([\w.]+)", email) print(match.group(1)) print(match.group(2)) print(re.findall(r"\d+", phone))
Try it yourself
Extract all words that start with 'p' from a sentence.
import re text = "python is powerful and practical" print(re.findall(r"\bp\w+", text))
Working with APIs
What you'll learn
- Make GET requests with requests
- Parse JSON responses
- Handle status codes and errors
APIs let your program talk to external services over HTTP. The requests library makes GET calls, the .json() method parses responses, and checking status_code helps you handle failures gracefully.
import requests
try:
response = requests.get("https://api.github.com/users/programmingpioneer", timeout=10)
response.raise_for_status()
data = response.json()
print("User:", data.get("login"))
print("Public repos:", data.get("public_repos"))
except requests.RequestException as e:
print("Request failed:", e)
Try it yourself
Fetch a JSON placeholder post and print its title.
import requests
r = requests.get("https://jsonplaceholder.typicode.com/posts/1")
print(r.json()["title"])SQLite with Python
What you'll learn
- Connect to a SQLite database
- Run CRUD operations
- Use parameterized queries safely
SQLite is a lightweight database built into Python. The sqlite3 module connects to a file, a cursor executes SQL, and parameterized queries (?) prevent SQL injection. CRUD means Create, Read, Update, Delete.
import sqlite3
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Sufyan",))
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Ali",))
conn.commit()
cursor.execute("SELECT * FROM users")
for row in cursor.fetchall():
print(row)
conn.close()
Try it yourself
Create a books table with title and author, then insert one book.
import sqlite3
conn = sqlite3.connect(":memory:")
c = conn.cursor()
c.execute("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT, author TEXT)")
c.execute("INSERT INTO books (title, author) VALUES (?, ?)", ("Python Basics", "Sufyan"))
conn.commit()
print(c.execute("SELECT * FROM books").fetchall())
conn.close()Testing with pytest
What you'll learn
- Write test functions with assertions
- Use fixtures for setup
- Handle expected exceptions
Testing proves your code works. pytest discovers test_* functions and runs assertions. Fixtures provide reusable setup data. pytest.raises checks that errors are raised as expected — the foundation of reliable code.
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide():
assert divide(10, 2) == 5
assert divide(1, 4) == 0.25
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(1, 0)
print("All tests passed.")
Try it yourself
Write a test that checks len('hello') == 5.
def test_length():
assert len("hello") == 5Capstone: Expense Tracker CLI
What you'll learn
- Combine OOP, dataclasses, and SQLite
- Build a complete CLI application
- Apply testing to real code
This capstone combines everything: dataclasses for expenses, sqlite3 for storage, pathlib for files, and custom exceptions for validation. You'll build a CLI that adds, lists, and totals expenses — a real-world project.
from dataclasses import dataclass
import sqlite3
from pathlib import Path
@dataclass
class Expense:
name: str
amount: float
class ExpenseTracker:
def __init__(self, db_path="expenses.db"):
self.conn = sqlite3.connect(db_path)
self.conn.execute("CREATE TABLE IF NOT EXISTS expenses (id INTEGER PRIMARY KEY, name TEXT, amount REAL)")
def add(self, name, amount):
self.conn.execute("INSERT INTO expenses (name, amount) VALUES (?, ?)", (name, amount))
self.conn.commit()
def total(self):
row = self.conn.execute("SELECT COALESCE(SUM(amount), 0) FROM expenses").fetchone()
return row[0]
def all(self):
return self.conn.execute("SELECT name, amount FROM expenses").fetchall()
def close(self):
self.conn.close()
app = ExpenseTracker(":memory:")
app.add("Lunch", 500)
app.add("Books", 1200)
print("Total:", app.total())
print("All:", app.all())
app.close()
Try it yourself
Add a delete(name) method that removes an expense by name.
def delete(self, name):
self.conn.execute("DELETE FROM expenses WHERE name = ?", (name,))
self.conn.commit()Intermediate Projects
Build real tools with the skills you've learned. Click each project to open its full guide.
About this project
Build a simple CLI task scheduler that stores tasks with due dates and priorities, then sorts them so the most urgent work appears first.
What you'll practice
Step-by-step: How it works
add_task()collects a task name, due date, and priority level.- Each task is stored as a dictionary with all three pieces.
sort_tasks()usessorted()with akeythat sorts by date first, then priority.- The display loop prints tasks in the sorted order with a clean format.
- Running the example shows how the scheduler orders mixed priorities.
from datetime import date
tasks = []
def add_task(title, due, priority):
tasks.append({"title": title, "due": due, "priority": priority})
def sort_tasks():
return sorted(tasks, key=lambda t: (t["due"], t["priority"]))
add_task("Write report", date(2026, 8, 25), 2)
add_task("Fix login bug", date(2026, 8, 23), 1)
add_task("Plan sprint", date(2026, 8, 23), 3)
for task in sort_tasks():
print(f"{task['due']} — P{task['priority']} — {task['title']}")
About this project
Fetch data from a public JSON API and display the first few posts cleanly. It teaches safe network requests and JSON parsing.
What you'll practice
Step-by-step: How it works
requests.get()fetches the API response.- We check
status_code == 200before using the data. .json()parses the response into a list of dictionaries.- We slice the first 3 results and print the title and body for each.
- A
try/exceptblock catches network errors gracefully.
import requests
url = "https://jsonplaceholder.typicode.com/posts"
try:
response = requests.get(url)
if response.status_code != 200:
print("Request failed:", response.status_code)
else:
posts = response.json()
for post in posts[:3]:
print("Title:", post["title"])
print("Body:", post["body"][:60], "...")
print("-" * 40)
except requests.RequestException as err:
print("Network error:", err)
About this project
Store contacts permanently in an SQLite database. Add, view, and delete contacts using a clean class that wraps the database.
What you'll practice
Step-by-step: How it works
ContactBook.__init__creates a database and the contacts table if needed.add()inserts a new row using a parameterized query.view_all()fetches every contact withfetchall().delete()removes a contact by name safely.- The example adds two contacts, prints them, then deletes one.
import sqlite3
class ContactBook:
def __init__(self, db="contacts.db"):
self.conn = sqlite3.connect(db)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS contacts (name TEXT, phone TEXT)"
)
def add(self, name, phone):
self.conn.execute(
"INSERT INTO contacts VALUES (?, ?)", (name, phone)
)
self.conn.commit()
def view_all(self):
return self.conn.execute(
"SELECT name, phone FROM contacts ORDER BY name"
).fetchall()
def delete(self, name):
self.conn.execute(
"DELETE FROM contacts WHERE name = ?", (name,)
)
self.conn.commit()
def close(self):
self.conn.close()
book = ContactBook(":memory:")
book.add("Ali", "0300-1234567")
book.add("Sufyan", "0301-7654321")
print(book.view_all())
book.delete("Ali")
print(book.view_all())
book.close()
You've completed all 20 lessons. Ready to continue?
Continue to Python Advanced for professional-grade concepts.