How To Test Python Code With pytest

How To Test Python Code With pytest

Intermediate

You have written a Python function that works perfectly — until someone changes a dependency, refactors a helper, or feeds it unexpected input. Without tests, you discover these problems the same way your users do: in production. Testing is how professional developers protect their code from silent regressions, and pytest is the tool that makes writing tests almost enjoyable.

The great news is that pytest requires zero boilerplate to get started. There are no test classes to inherit from, no special assertion methods to memorize, and no XML configuration files to maintain. You write plain Python functions that start with test_, use regular assert statements, and pytest handles the rest — discovery, execution, rich failure diffs, and detailed reporting. Install it with pip install pytest and you are ready to go.

In this article, we will cover everything you need to start testing Python code with pytest. We will begin with a quick example, then explain why pytest beats the built-in unittest module for most projects. From there, we will walk through writing basic tests, using fixtures to manage setup and teardown, parametrizing tests to cover multiple inputs, mocking external dependencies, and organizing test files. We will finish with a complete real-life project that tests a shopping cart module end to end.

Testing Python Code With pytest: Quick Example

Here is the fastest way to see pytest in action. We will create a tiny function and a test for it, then run pytest from the command line.

# test_quick.py
import pytest

def add(a, b):
    return a + b

def test_add_positive_numbers():
    assert add(2, 3) == 5

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

def test_add_mixed():
    assert add(-1, 1) == 0

Output:

$ pytest test_quick.py -v
========================= test session starts =========================
collected 3 items

test_quick.py::test_add_positive_numbers PASSED
test_quick.py::test_add_negative_numbers PASSED
test_quick.py::test_add_mixed PASSED

========================= 3 passed in 0.01s ==========================

That is the entire workflow: write functions that start with test_, use assert to check results, and run pytest from the terminal. No classes, no inheritance, no ceremony. When a test fails, pytest shows you exactly what the expected and actual values were, which makes debugging fast.

Want to learn about fixtures, parametrize, mocking, and real-world project testing? Keep reading — we cover all of that below.

Debug Dee examining checkmark shields - pytest test results
Three passing, one failing. The test suite never lies — your code does.

What Is pytest and Why Use It?

pytest is a testing framework for Python that makes it simple to write small, readable tests — and scales to support complex functional testing for applications and libraries. It has been the dominant Python testing tool for years, used by major projects like Django, Flask, and requests.

Python ships with a built-in testing module called unittest, which works fine but requires more boilerplate. Here is how the two compare:

Featurepytestunittest
Test discoveryAutomatic (files/functions starting with test_)Requires TestCase classes
AssertionsPlain assert statementsself.assertEqual, self.assertTrue, etc.
Setup/TeardownFixtures (flexible, composable)setUp/tearDown methods
ParametrizationBuilt-in @pytest.mark.parametrizeRequires third-party library or loops
Plugin ecosystem1,000+ plugins (coverage, mock, async, etc.)Limited built-in extensions
Output on failureRich diffs showing exact valuesBasic assertion error messages
BoilerplateMinimal (plain functions)Heavy (classes, inheritance, special methods)

The biggest practical advantage is readability. A pytest test reads like regular Python code, which means teammates who have never written tests before can understand what each test checks just by reading it. This matters more than any technical feature because tests that nobody reads are tests that nobody maintains.

Writing Basic Tests

Let us start with the fundamentals. We will create a small module with a few functions, then write tests for each one. This pattern — source code in one file, tests in another — is how real Python projects are organized.

# calculator.py
def divide(a, b):
    """Divide a by b, raising ValueError for zero division."""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def is_even(n):
    """Return True if n is even."""
    return n % 2 == 0

def clamp(value, minimum, maximum):
    """Clamp value between minimum and maximum."""
    return max(minimum, min(value, maximum))

Now let us write tests for these functions. Notice how each test function has a descriptive name that explains what scenario it checks. This makes the test output act as living documentation for your code.

# test_calculator.py
import pytest
from calculator import divide, is_even, clamp

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

def test_divide_returns_float():
    result = divide(7, 2)
    assert result == 3.5

def test_divide_by_zero_raises():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

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

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

def test_clamp_within_range():
    assert clamp(5, 0, 10) == 5

def test_clamp_below_minimum():
    assert clamp(-5, 0, 10) == 0

def test_clamp_above_maximum():
    assert clamp(15, 0, 10) == 10

Output:

$ pytest test_calculator.py -v
========================= test session starts =========================
collected 8 items

test_calculator.py::test_divide_positive PASSED
test_calculator.py::test_divide_returns_float PASSED
test_calculator.py::test_divide_by_zero_raises PASSED
test_calculator.py::test_is_even_with_even PASSED
test_calculator.py::test_is_even_with_odd PASSED
test_calculator.py::test_clamp_within_range PASSED
test_calculator.py::test_clamp_below_minimum PASSED
test_calculator.py::test_clamp_above_maximum PASSED

========================= 8 passed in 0.02s ==========================

Two key patterns to notice here. First, pytest.raises is a context manager that catches expected exceptions — you use it to verify that your code fails correctly when it should. The match parameter checks the exception message with a regex pattern, so you catch the right error for the right reason. Second, each test checks exactly one behavior. This makes failures precise: when test_clamp_below_minimum fails, you know instantly that the lower-bound logic is broken, not some vague “calculator is broken” signal.

Fixtures: Managing Test Setup and Teardown

Real-world tests need shared setup: database connections, temporary files, sample data objects. Copying setup code into every test function creates duplication and makes tests fragile. Fixtures solve this by letting you define reusable setup logic that pytest automatically injects into any test that needs it.

# test_user_service.py
import pytest

class User:
    def __init__(self, name, email, role="member"):
        self.name = name
        self.email = email
        self.role = role

    def promote(self):
        self.role = "admin"

    def display_name(self):
        return f"{self.name} ({self.role})"

@pytest.fixture
def sample_user():
    """Create a fresh user for each test."""
    return User("Alice", "alice@example.com")

@pytest.fixture
def admin_user():
    """Create a pre-promoted admin user."""
    user = User("Bob", "bob@example.com")
    user.promote()
    return user

def test_new_user_is_member(sample_user):
    assert sample_user.role == "member"

def test_promote_changes_role(sample_user):
    sample_user.promote()
    assert sample_user.role == "admin"

def test_admin_display_name(admin_user):
    assert admin_user.display_name() == "Bob (admin)"

def test_member_display_name(sample_user):
    assert sample_user.display_name() == "Alice (member)"

Output:

$ pytest test_user_service.py -v
========================= test session starts =========================
collected 4 items

test_user_service.py::test_new_user_is_member PASSED
test_user_service.py::test_promote_changes_role PASSED
test_user_service.py::test_admin_display_name PASSED
test_user_service.py::test_member_display_name PASSED

========================= 4 passed in 0.01s ==========================

Each fixture is a function decorated with @pytest.fixture. When a test function includes the fixture name as a parameter, pytest calls the fixture function and passes the return value into the test automatically. Every test gets a fresh instance, so test_promote_changes_role modifying the user does not affect test_member_display_name. This isolation is what makes fixtures so much better than shared global state.

Loop Larry juggling toolboxes - pytest fixtures illustration
Fixtures handle the setup so your tests can focus on breaking things properly.

Parametrize: Testing Multiple Inputs Without Repetition

When you need to test the same logic with different inputs, writing separate test functions for each case gets tedious fast. The @pytest.mark.parametrize decorator lets you run a single test function across multiple input-output combinations, keeping your test suite compact and easy to extend.

# test_string_utils.py
import pytest

def slugify(text):
    """Convert text to a URL-friendly slug."""
    return text.lower().strip().replace(" ", "-")

@pytest.mark.parametrize("input_text, expected", [
    ("Hello World", "hello-world"),
    ("Python Is Great", "python-is-great"),
    ("  spaces  ", "spaces"),
    ("ALLCAPS", "allcaps"),
    ("already-slugged", "already-slugged"),
])
def test_slugify(input_text, expected):
    assert slugify(input_text) == expected

Output:

$ pytest test_string_utils.py -v
========================= test session starts =========================
collected 5 items

test_string_utils.py::test_slugify[Hello World-hello-world] PASSED
test_string_utils.py::test_slugify[Python Is Great-python-is-great] PASSED
test_string_utils.py::test_slugify[  spaces  -spaces] PASSED
test_string_utils.py::test_slugify[ALLCAPS-allcaps] PASSED
test_string_utils.py::test_slugify[already-slugged-already-slugged] PASSED

========================= 5 passed in 0.01s ==========================

The decorator takes a comma-separated string of parameter names and a list of tuples with the values. Each tuple becomes a separate test case with its own pass/fail status. If you later realize you need to handle punctuation in slugs, you just add another tuple to the list — no new function needed. The test output also labels each case with the inputs, making it obvious which specific scenario broke when something fails.

Mocking External Dependencies

Tests should be fast, deterministic, and independent of external systems. If your function calls an API, reads from a database, or sends an email, you do not want your tests to actually do those things. Mocking replaces those external dependencies with controlled fakes so you can test your logic in isolation.

Python includes unittest.mock in the standard library, and pytest works with it seamlessly. The key tool is patch, which temporarily replaces an object during a test.

# weather.py
import requests

def get_temperature(city):
    """Fetch current temperature for a city from a weather API."""
    response = requests.get(
        f"https://api.weather-service.com/current?city={city}"
    )
    data = response.json()
    return data["temperature"]

def weather_advice(city):
    """Return clothing advice based on temperature."""
    temp = get_temperature(city)
    if temp > 30:
        return "Wear shorts and sunscreen"
    elif temp > 15:
        return "A light jacket should do"
    else:
        return "Bundle up, it is cold"
# test_weather.py
from unittest.mock import patch, MagicMock
from weather import weather_advice

@patch("weather.requests.get")
def test_hot_weather_advice(mock_get):
    # Configure the mock to return a hot temperature
    mock_response = MagicMock()
    mock_response.json.return_value = {"temperature": 35}
    mock_get.return_value = mock_response

    result = weather_advice("Sydney")
    assert result == "Wear shorts and sunscreen"
    mock_get.assert_called_once()

@patch("weather.requests.get")
def test_cold_weather_advice(mock_get):
    mock_response = MagicMock()
    mock_response.json.return_value = {"temperature": 5}
    mock_get.return_value = mock_response

    result = weather_advice("Moscow")
    assert result == "Bundle up, it is cold"

@patch("weather.requests.get")
def test_mild_weather_advice(mock_get):
    mock_response = MagicMock()
    mock_response.json.return_value = {"temperature": 20}
    mock_get.return_value = mock_response

    result = weather_advice("London")
    assert result == "A light jacket should do"

Output:

$ pytest test_weather.py -v
========================= test session starts =========================
collected 3 items

test_weather.py::test_hot_weather_advice PASSED
test_weather.py::test_cold_weather_advice PASSED
test_weather.py::test_mild_weather_advice PASSED

========================= 3 passed in 0.03s ==========================

The @patch decorator replaces requests.get inside the weather module with a MagicMock object for the duration of the test. We configure the mock’s .json() return value to simulate different API responses. This means our tests never make real HTTP requests, run in milliseconds, and work even when there is no internet connection. The assert_called_once() check verifies that our code actually tried to call the API — catching bugs where the function might skip the API call entirely.

Cache Katie comparing real and puppet figures - pytest mocking
mock_get.return_value = whatever_you_want. Reality is optional in test land.

Organizing Test Files in a Real Project

As your project grows, you need a consistent structure for your test files. The standard convention is to mirror your source directory with a tests/ directory. Each source module gets a corresponding test file.

# Example project structure
my_project/
    src/
        __init__.py
        calculator.py
        user_service.py
        weather.py
    tests/
        __init__.py
        test_calculator.py
        test_user_service.py
        test_weather.py
        conftest.py
    pyproject.toml

Output:

$ pytest tests/ -v
========================= test session starts =========================
collected 15 items

tests/test_calculator.py::test_divide_positive PASSED
tests/test_calculator.py::test_divide_returns_float PASSED
tests/test_calculator.py::test_divide_by_zero_raises PASSED
tests/test_user_service.py::test_new_user_is_member PASSED
tests/test_user_service.py::test_promote_changes_role PASSED
tests/test_weather.py::test_hot_weather_advice PASSED
...
========================= 15 passed in 0.05s ==========================

The conftest.py file is a special pytest file that holds shared fixtures available to all test files in the same directory. You do not need to import from it — pytest discovers and loads it automatically. Put fixtures that multiple test files need (like database connections or test data factories) in conftest.py instead of duplicating them across files.

# tests/conftest.py
import pytest

@pytest.fixture
def sample_users():
    """Shared fixture available to all test files."""
    return [
        {"name": "Alice", "email": "alice@example.com", "role": "admin"},
        {"name": "Bob", "email": "bob@example.com", "role": "member"},
        {"name": "Charlie", "email": "charlie@example.com", "role": "member"},
    ]

Any test file in the tests/ directory can now use sample_users as a fixture parameter without any import statement. This keeps your test setup DRY and makes it easy for new team members to find shared test data in one place.

Useful pytest Command-Line Flags

Knowing a few key command-line flags makes pytest significantly more productive in daily development. Here are the ones you will use constantly.

# Run only tests matching a keyword pattern
$ pytest -k "divide" -v

# Stop on first failure (great for debugging)
$ pytest -x

# Show print() output from tests (normally captured)
$ pytest -s

# Run only tests that failed last time
$ pytest --lf

# Show the 5 slowest tests
$ pytest --durations=5

# Run tests in parallel (requires pytest-xdist)
$ pytest -n auto

Output (for -k “divide”):

$ pytest -k "divide" -v
========================= test session starts =========================
collected 15 items / 12 deselected / 3 selected

tests/test_calculator.py::test_divide_positive PASSED
tests/test_calculator.py::test_divide_returns_float PASSED
tests/test_calculator.py::test_divide_by_zero_raises PASSED

========================= 3 passed, 12 deselected in 0.02s ===========

The -k flag is invaluable when you are working on a specific feature and want to run only the related tests. The -x flag prevents pytest from running hundreds of tests after the first failure — when something is broken, you want to see the first failure immediately. And --lf (last failed) re-runs only the tests that failed in your previous run, giving you a tight feedback loop during debugging.

Pyro Pete pulling levers on control panel - pytest CLI options
pytest -x –lf -v — because running all 400 tests every time is for the patient.

Real-Life Example: Testing a Shopping Cart Module

Let us put everything together in a realistic scenario. We will build a simple shopping cart module and write a comprehensive test suite that uses fixtures, parametrize, and exception testing.

# cart.py
class Product:
    def __init__(self, name, price):
        if price < 0:
            raise ValueError("Price cannot be negative")
        self.name = name
        self.price = price

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add(self, product, quantity=1):
        """Add a product to the cart."""
        if quantity < 1:
            raise ValueError("Quantity must be at least 1")
        self.items.append({"product": product, "quantity": quantity})

    def total(self):
        """Calculate the total price of all items."""
        return sum(
            item["product"].price * item["quantity"]
            for item in self.items
        )

    def item_count(self):
        """Return the total number of individual items."""
        return sum(item["quantity"] for item in self.items)

    def remove(self, product_name):
        """Remove all entries of a product by name."""
        before = len(self.items)
        self.items = [
            item for item in self.items
            if item["product"].name != product_name
        ]
        if len(self.items) == before:
            raise ValueError(f"Product '{product_name}' not in cart")

    def apply_discount(self, percent):
        """Return the total after a percentage discount."""
        if not 0 <= percent <= 100:
            raise ValueError("Discount must be between 0 and 100")
        return self.total() * (1 - percent / 100)

Now the test suite that exercises every method, edge case, and error condition:

# test_cart.py
import pytest
from cart import Product, ShoppingCart

# --- Fixtures ---

@pytest.fixture
def apple():
    return Product("Apple", 1.50)

@pytest.fixture
def laptop():
    return Product("Laptop", 999.99)

@pytest.fixture
def cart():
    return ShoppingCart()

@pytest.fixture
def loaded_cart(apple, laptop):
    cart = ShoppingCart()
    cart.add(apple, quantity=3)
    cart.add(laptop, quantity=1)
    return cart

# --- Product Tests ---

def test_product_creation():
    product = Product("Widget", 9.99)
    assert product.name == "Widget"
    assert product.price == 9.99

def test_product_negative_price():
    with pytest.raises(ValueError, match="Price cannot be negative"):
        Product("Bad", -5.00)

# --- Cart Basic Tests ---

def test_empty_cart_total(cart):
    assert cart.total() == 0

def test_empty_cart_count(cart):
    assert cart.item_count() == 0

def test_add_single_item(cart, apple):
    cart.add(apple)
    assert cart.item_count() == 1
    assert cart.total() == 1.50

def test_add_multiple_quantity(cart, apple):
    cart.add(apple, quantity=5)
    assert cart.item_count() == 5
    assert cart.total() == 7.50

def test_add_invalid_quantity(cart, apple):
    with pytest.raises(ValueError, match="Quantity must be at least 1"):
        cart.add(apple, quantity=0)

# --- Loaded Cart Tests ---

def test_loaded_cart_total(loaded_cart):
    expected = (1.50 * 3) + (999.99 * 1)  # 1004.49
    assert loaded_cart.total() == pytest.approx(expected)

def test_loaded_cart_count(loaded_cart):
    assert loaded_cart.item_count() == 4

# --- Remove Tests ---

def test_remove_item(loaded_cart):
    loaded_cart.remove("Apple")
    assert loaded_cart.item_count() == 1
    assert loaded_cart.total() == pytest.approx(999.99)

def test_remove_nonexistent_item(loaded_cart):
    with pytest.raises(ValueError, match="not in cart"):
        loaded_cart.remove("Banana")

# --- Discount Tests ---

@pytest.mark.parametrize("percent, expected_total", [
    (0, 1004.49),
    (10, 904.041),
    (50, 502.245),
    (100, 0.0),
])
def test_discount(loaded_cart, percent, expected_total):
    assert loaded_cart.apply_discount(percent) == pytest.approx(
        expected_total, rel=1e-2
    )

def test_invalid_discount(loaded_cart):
    with pytest.raises(ValueError, match="Discount must be between"):
        loaded_cart.apply_discount(110)

Output:

$ pytest test_cart.py -v
========================= test session starts =========================
collected 16 items

test_cart.py::test_product_creation PASSED
test_cart.py::test_product_negative_price PASSED
test_cart.py::test_empty_cart_total PASSED
test_cart.py::test_empty_cart_count PASSED
test_cart.py::test_add_single_item PASSED
test_cart.py::test_add_multiple_quantity PASSED
test_cart.py::test_add_invalid_quantity PASSED
test_cart.py::test_loaded_cart_total PASSED
test_cart.py::test_loaded_cart_count PASSED
test_cart.py::test_remove_item PASSED
test_cart.py::test_remove_nonexistent_item PASSED
test_cart.py::test_discount[0-1004.49] PASSED
test_cart.py::test_discount[10-904.041] PASSED
test_cart.py::test_discount[50-502.245] PASSED
test_cart.py::test_discount[100-0.0] PASSED
test_cart.py::test_invalid_discount PASSED

========================= 16 passed in 0.03s =========================

This test suite demonstrates how fixtures, parametrize, and exception testing work together in a real project. The loaded_cart fixture composes two other fixtures (apple and laptop) to build a realistic test scenario without duplicating setup code. The parametrized discount test covers four boundary conditions in five lines instead of twenty. And pytest.approx handles floating-point comparison so you do not get bitten by rounding errors when comparing monetary totals.

Sudo Sam standing on checkmark shield - all pytest tests passing
16 passed, 0 failed. Ship it before someone adds a new requirement.

Frequently Asked Questions

How do I run a single test file or a single test function?

To run a single file, pass its path directly: pytest test_calculator.py. To run a specific function within that file, use double-colon syntax: pytest test_calculator.py::test_divide_positive. You can also use -k "keyword" to match test names by pattern, which is faster when you cannot remember the exact function name but know it involves "divide".

What is the difference between a fixture and a regular helper function?

A fixture is managed by pytest's dependency injection system -- you declare it as a parameter and pytest calls it for you, handles cleanup, and ensures fresh instances per test. A regular helper function is just a Python function you call manually. Use fixtures when you need automatic setup/teardown or when multiple test files share the same setup logic. Use helpers for simple utility operations that do not need lifecycle management.

How do I check code coverage with pytest?

Install pytest-cov with pip install pytest-cov, then run pytest --cov=src tests/ where src is your source directory. It will print a coverage report showing which lines and branches are exercised by your tests. For an HTML report that lets you see uncovered lines visually, add --cov-report html and open the generated htmlcov/index.html in your browser.

Can pytest run unittest-style test classes?

Yes, pytest is fully backwards compatible with unittest.TestCase classes. It will discover and run them alongside pytest-style functions. This makes migration gradual -- you can start writing new tests in pytest style while your old unittest tests continue to work. Over time, you can convert them to pytest functions to take advantage of fixtures and parametrize.

How do I skip a test or mark it as expected to fail?

Use @pytest.mark.skip(reason="Not implemented yet") to skip a test unconditionally. Use @pytest.mark.skipif(condition, reason="...") to skip based on a runtime condition (like the Python version or OS). For tests that document a known bug, use @pytest.mark.xfail -- pytest runs them but does not count them as failures, and it will alert you when the bug is fixed and the test starts passing unexpectedly.

Conclusion

We covered the complete pytest workflow in this article: writing basic tests with assert, managing test setup with fixtures, testing multiple inputs with @pytest.mark.parametrize, mocking external dependencies with unittest.mock.patch, organizing test files with conftest.py, and using command-line flags for productive debugging. The shopping cart project showed how all these pieces fit together in a realistic codebase.

Try extending the shopping cart tests as practice: add a coupon code feature, implement a maximum cart size, or add a loyalty points calculation. Each new feature is an opportunity to write the test first (test-driven development) and watch it go from red to green.

For the complete pytest documentation, including advanced features like custom markers, plugin development, and async testing, visit the official docs at docs.pytest.org.

How To Send Emails with Python Using smtplib and Gmail

How To Send Emails with Python Using smtplib and Gmail

Beginner

You have built a Python script that generates a report, scrapes a website, or monitors a server — and now you need it to tell you what happened. Maybe you want a daily summary email, an alert when something breaks, or a confirmation that a scheduled job finished successfully. Sending email programmatically is one of those kills every Python developer eventually needs, and the good news is that Python has everything you need built right in.

Python’sstandard library includes smtplib for connecting to mail servers and email for building properly formatted messages. You do not need to install any third-party packages. All you need is a Gmail account with an App Password (we will walk through setting that up) and about 10 lines of code to send your first email.

tter properly. You need both: email.message.EmailMessage builds a correctly formatted email (headers, body, attachments), and smtplib delivers it to the mail server.

ModulePurposePart of Standard Library?
smtplibConnect to SMTP server, authenticate, sendYes
email.messageBuild email messages (headers, body, MIME)Yes
email.mimeLegacy API for building MIME messagesYes (use EmailMessage instead)
sslSecure socket layer for encrypted connectionsYes

The modern approach uses EmailMessage (introduced in Python 3.6) instead of the older MIMEText/MIMEMultipart classes. EmailMessage handles plain text, HTML, and attachments through a single clean API. We will use it throughout this tutorial.

Setting Up Gmail App Passwords

Gmail does not allow you to log in with your regular password from a script — it requires an App Password instead. An App Password is a 16-character code that gives your script access to your Gmail account without exposing your main password. Here is how to set one up.

First, you need to enable 2-Step Verification on your Google account if you have not already. Go to myaccount.google.com/security, scroll to “How you sign in to Google,” and turn on 2-Step Verification. Once that is active, go to myaccount.google.com/apppasswords, enter a name like “Python Script,” and click Create. Google will show you a 16-character password — copy it immediately because you will not see it again.

# secure_config.py
import os

# Store your App Password as an environment variable -- never hardcode it
# Set it in your terminal first:
#   export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
#   export GMAIL_ADDRESS="your_email@gmail.com"

gmail_address = os.environ.get("GMAIL_ADDRESS")
gmail_password = os.environ.get("GMAIL_APP_PASSWORD")

if not gmail_address or not gmail_password:
    raise ValueError("Set GMAIL_ADDRESS and GMAIL_APP_PASSWORD environment variables")

print(f"Configured for: {gmail_address}")
print(f"Password loaded: {'*' * len(gmail_password)}")

Output:

Configured for: your_email@gmail.com
Password loaded: ****************

Never hardcode your App Password directly in your Python files. Use environment variables or a .env file (with python-dotenv) to keep credentials out of your source code. If you accidentally commit a password to Git, revoke the App Password immediately from your Google account settings and create a new one.

SMTP_SSL vs STARTTLS: Choosing a Connection Method

There are two ways to establish a secure connection to an SMTP server: SMTP_SSL and SMTP with STARTTLS. Both encrypt your email traffic, but they work differently.

MethodPortHow It WorksWhen to Use
SMTP_SSL465Encrypted from the startPreferred for Gmail
SMTP + starttls()587Starts unencrypted, upgradesSome corporate servers

Here is how the STARTTLS approach looks in practice. The connection starts as plaintext on port 587, then upgrades to TLS before sending any sensitive data.

# starttls_example.py
import smtplib
import os
from email.message import EmailMessage

msg = EmailMessage()
msg["Subject"] = "STARTTLS Test"
msg["From"] = os.environ["GMAIL_ADDRESS"]
msg["To"] = os.environ["GMAIL_ADDRESS"]  # Send to yourself for testing
msg.set_content("This email was sent using STARTTLS on port 587.")

with smtplib.SMTP("smtp.gmail.com", 587) as server:
    server.ehlo()       # Identify ourselves to the server
    server.starttls()   # Upgrade connection to TLS
    server.ehlo()       # Re-identify after TLS upgrade
    server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
    server.send_message(msg)

print("Email sent via STARTTLS!")

Output:

Email sent via STARTTLS!

For Gmail, SMTP_SSL on port 465 is simpler and preferred — it encrypts the connection from the first byte. Use the STARTTLS approach only if your email provider specifically requires port 587, or if you need to connect to a server that does not support direct SSL.

Sudo Sam choosing between padlocks - SMTP SSL vs STARTTLS comparison
SMTP_SSL wraps the whole conversation in encryption. STARTTLS hopes nobody is listening to the handshake.

Sending HTML-Formatted Emails

Plain text emails get the job done, but HTML emails let you include formatting, links, tables, and images. The EmailMessage class makes it easy to send an email with both a plain-text fallback and an HTML version — email clients that support HTML will display the rich version, while older clients fall back to plain text.

“Subject”] = “Weekly Python Report” msg[“From”] = os.environ[“GMAIL_ADDRESS”] msg[“To”] = os.environ[“GMAIL_ADDRESS”] # Plain text version (fallback) msg.set_content(“Your weekly report: 42 scripts ran, 0 failures, 15.2s avg runtime.”) # HTML version (preferred by most email clients) html_content = “””\

Weekly Python Report

Here is your automated summary for the week:

Metric Value 420Avg Runtime
Scripts Executed
Failures
15.2 seconds

All systems operational. Have a great week!

""" msg.add_alternative(html_content, subtype="html") with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"]) server.send_message(msg) print("HTML email sent!")

Output:

HTML email sent!

The key method here is msg.add_alternative(html_content, subtype="html"). This tells the email that it has two versions: the plain text you set with set_content() and the HTML alternative. Always provide both -- some email clients strip HTML entirely, and a plain-text fallback ensures your message is readable everywhere.

Adding File Attachments

Sending reports, logs, or CSV files as email attachments is a common automation task. The EmailMessage class handles this with add_attachment(), which automatically detects the file type and encodes it correctly.

# attachment_email.py
import smtplib
import os
import mimetypes
from email.message import EmailMessage
from pathlib import Path

def send_email_with_attachment(to_address, subject, body, file_path):
    """Send an email with a file attachment."""
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = os.environ["GMAIL_ADDRESS"]
    msg["To"] = to_address
    msg.set_content(body)

    # Read and attach the file
    filepath = Path(file_path)
    if not filepath.exists():
        raise FileNotFoundError(f"Attachment not found: {file_path}")

    mime_type, _ = mimetypes.guess_type(str(filepath))
    if mime_type is None:
        mime_type = "application/octet-stream"
    maintype, subtype = mime_type.split("/")

    with open(filepath, "rb") as f:
        msg.add_attachment(
            f.read(),
            maintype=maintype,
            subtype=subtype,
            filename=filepath.name
        )

    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
        server.send_message(msg)

    print(f"Email sent to {to_address} with attachment: {filepath.name}")


# Create a sample CSV file for testing
sample_csv = "name,score,grade\nAlice,95,A\nBob,87,B\nCharlie,72,C\n"
Path("report.csv").write_text(sample_csv)

# Send it
send_email_with_attachment(
    to_address=os.environ["GMAIL_ADDRESS"],
    subject="Monthly Report Attached",
    body="Please find the monthly report attached to this email.",
    file_path="report.csv"
)

Output:

Email sent to your_email@gmail.com with attachment: report.csv

The mimetypes.guess_type() function automatically detects the correct MIME type from the file extension -- text/csv for CSV files, application/pdf for PDFs, image/png for images, and so on. If the type cannot be determined, we fall back to application/octet-stream which tells the email client to treat it as a generic binary file. You can attach multiple files by calling add_attachment() multiple times on the same message.

Debug Dee attaching gift box to envelope - Python email attachments
add_attachment() handles the MIME type guessing. You just hand it the file and hope for the best.

Sending to Multiple Recipients

Often you need to send the same email to several people -- maybe a team notification or a batch of personalized messages. There are two approaches: sending one email to multiple recipients (everyone sees all addresses) or sending individual emails (each person sees only their address).

# multiple_recipients.py
import smtplib
import os
from email.message import EmailMessage

def send_to_group(recipients, subject, body)
   """Send one email to multiple recipients (all visible in To field)."""
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = os.environ["GMAIL_ADDRESS"]
    msg["To"] = ", ".join(recipients)
    msg.set_content(body)

    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
        server.send_message(msg)

    print(f"Group email sent to {len(recipients)} recipients")


def send_individual(recipients, subject_template, body_template):
    """Send personalized emails to each recipient individualy."""
    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])

        for name, email_addr in recipients:
            msg = EmailMessage()
            msg["Subject"] = subject_template.format(name=name)
            msg["From"] = os.environ["GMAIL_ADDRESS"]
            msg["To"] = email_addr
            msg.set_content(body_template.format(name=name))
            server.send_message(msg)
            print(f"Sent to {name} ({email_addr})")

    print(f"All {len(recipients)} individual emails sent!")


# Example: group email
team = ["alice@example.com", "bob@example.com", "charlie@example.com"]
send_to_group(team, "Team Update", "Sprint review meeting moved to 3 PM.")

# Example: personalized emails
contacts = [("Alice", "alice@example.com"), ("Bob", "bob@example.com")]
send_individual(
    contacts,
    subject_template="Hey {name}, your weekly summary",
    body_template="Hi {name},\n\nHere is your personalized weekly summary.\n\nBest regards"
)

Real-Life Example: Automated Error Alert System

Let us tie everything together with a practical project. This script monitors a log file for errors and sends an HTML alert email with a summary of any issues found. It combines plain text processing, HTML email formatting, and the EmailNotifier class pattern you can reuse in any project.

# Create a sample log file for demonstration
sample_log = """2026-03-31 08:00:01 INFO  Starting data pipeline
2026-03-31 08:00:05 INFO  Connected to database
2026-03-31 08:00:12 WARNING  Slow query detected (2.3s)
2026-03-31 08:00:15 ERROR  Failed to fetch API data: ConnectionTimeout
2026-03-31 08:00:16 ERROR  Retry 1 of 3 failed
2026-03-31 08:00:20 INFO  Retry 2 succeeded
2026-03-31 08:00:45 CRITICAL  Database connection lost
2026-03-31 08:00:46 INFO  Reconnecting to database...
2026-03-31 08:01:00 INFO  Pipeline completed with errors
"""
Path("app.log").write_text(sample_log)

# Check the log and send an alert if errors are found
def check_log_for_errors(log_path):
    """Scan a log file and return any lines containing ERROR or CRITICAL."""
    errors = []
    path = Path(log_path)
    if not path.exists():
        return errors

    with open(path, "r") as f:
        for line_num, line in enumerate(f, 1):
            stripped = line.strip()
            if "ERROR" in stripped or "CRITICAL" in stripped:
                errors.append({"line": line_num, "text": stripped})

    return errors


def build_alert_html(log_file, errors):
    """Build an HTML alert email from error entries."""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    rows = ""
    for err in errors:
        rows += f'{err["line"]}'
        rows += f'{err["text"]}'

    return f"""""

sample_log = """2026-03-31 08:00:01 INFO  Starting data pipeline
2026-03-31 08:00:05 INFO  Connected to database
2026-03-31 08:00:12 WARNING  Slow query detected (2.3s)
2026-03-31 08:00:15 ERROR  Failed to fetch API data: ConnectionTimeout
2026-03-31 08:00:16 ERROR  Retry 1 of 3 failed
2026-03-31 08:00:20 INFO  Retry 2 succeeded
2026-03-31 08:00:45 CRITICAL  Database connection lost
2026-03-31 08:00:46 INFO  Reconnecting to database...
2026-03-31 08:01:00 INFO  Pipeline completed with errors
"""
Path("app.log").write_text(sample_log)

# Check the log and send an alert if errors are found
class EmailNotifier:
    """Reusable email notification system" HTML alert built {len(errors)} chars)
In production: notifier.send(admin_email, subject, body, html)
else:
    print("No errors found. All clear!")

Output:

Found 3 error(s) in app.log:
  Line 4: 2026-03-31 08:00:15 ERROR  Failed to fetch API data: ConnectionTimeout
  Line 5: 2026-03-31 08:00:16 ERROR  Retry 1 of 3 failed
  Line 7: 2026-03-31 08:00:45 CRITICAL  Database connection lost

HTML alert built (698 chars)
In production: notifier.send(admin_email, subject, body, html)

This notification system is designed to be dropped into any existing project. The EmailNotifier class handles all the SMTP details, check_log_for_errors() scans for problems, and build_alert_html() creates a readable alert email. You could schedule this to run every hour with cron or the schedule library, and you would have a lightweight monitoring system without any third-party services.

Gmail Sending Limits and Best Practices

Gmail enforces rate limits on how many emails you can send per day. Knowing these limits prevents your script from getting temporarily blocked.

Account TypeDaily LimitPer MinuteMax Recipients Per Email
Free Gmail500 emails~20500
Google Workspace2,000 emails~302,000

If you hit these limits, Gmail returns an SMTPDataError with code 421 or 550. Your script should catch this and wait before retrying. For high-volume sending (marketing emails, large mailing lists), use a dedicated email service like SendGrid, Mailgun, or Amazon SES instead of Gmail -- they are designed for bulk sending and provide analytics, bounce handling, and higher limits.

Frequently Asked Questions

How do I create a Gmail App Password?

Go to myaccount.google.com/apppasswords after enabling 2-Step Verification on your account. Click "Select app," choose "Other," type a name like "Python Script," and click Generate. Copy the 16-character password and use it in your server.login() call instead of your regular Gmail password. You can revoke it anytime from the same page.

Why does Gmail reject my login with SMTPAuthenticationError?

This almost always means you are using your regular Gmail password instead of an App Password. Google disabled "Less Secure App Access" permanently in 2022. You must use an App Password (see the section above) or switch to OAuth2 for more complex applications. Double-check that there are no extra spaces in your password string.

My script hangs when connecting to the SMTP server. What is wrong?

Add a timeout parameter to your SMTP connection: smtplib.SMTP_SSL("smtp.gmail.com", 465, timeout=30). This prevents the script from hanging forever if the server is unreachable. Common causes include corporate firewalls blocking port 465 or 587, VPN interference, or DNS resolution failures. Try pinging smtp.gmail.com from your terminal to verify connectivity.

How do I send emails with special characters or non-English text?

The EmailMessage class handles Unicode correctly by default. Just pass your text normally: msg.set_content("Bonjour! Voici votre rapport."). The message will be encoded as UTF-8 automatically. If you are using the older MIMEText API, explicitly set the charset: MIMEText(body, "plain", "utf-8").

How can I test email sending without actually sending emails?

Python has a built-in debugging SMTP server that prints emails to the terminal instead of sending them. Run python -m smtpd -n -c DebuggingServer localhost:1025 in one terminal, then connect your script to localhost:1025 using smtplib.SMTP("localhost", 1025) (no SSL, no login). You will see the full email content printed to the terminal. For Python 3.12+, use aiosmtpd instead since the built-in smtpd module was removed.

Conclusion

You now have everything you need to send emails from Python scripts: plain text messages with set_content(), HTML-formatted emails with add_alternative(), file attachments with add_attachment(), and a robust error-handling wrapper with retry logic. The notification system project gives you a ready-to-use template for monitoring any automated task.

Try extending the notification system to watch multiple log files, send daily digest emails instead of per-error alerts, or add Slack webhook notifications alongside email. The EmailNotifier class is designed to be subclassed and customized for your specific needs.

For the complete API reference, see the official Python documentation for smtplib and email.message.

How To Use Python dataclasses Effectively

How To Use Python dataclasses Effectively

Intermediate

If you’ve been writing Python classes for a while, you’ve probably noticed a lot of boilerplate code — endless __init__ methods, repetitive __repr__ implementations, and manual equality comparisons. The dataclasses module, introduced in Python 3.7, offers an elegant solution to this problem. Instead of writing pages of constructor code, you can define your class structure with type hints and let Python handle the rest.

Don’t worry if decorators and metaclass magic intimidate you. Dataclasses are surprisingly straightforward once you understand the fundamentals. They’re not replacing traditional classes — they’re complementing them. You’ll learn practical techniques that make your code cleaner, more maintainable, and far easier to reason about.

In this guide, we’ll explore dataclasses from the ground up. You’ll discover when to use them, how to leverage advanced features like frozen instances and field validation, and how to build real-world applications that are both efficient and elegant. We’ll walk through progressive examples that show you exactly how each feature works and why you’d want to use it.

Quick Example

Let’s start with the simplest possible dataclass. This shows the core concept before we dive deeper:

# quick_dataclass_example.py
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
    email: str

# Create an instance
person = Person(name="Alice Johnson", age=28, email="alice@example.com")
print(person)
print(f"Name: {person.name}, Age: {person.age}")

Output:

Person(name='Alice Johnson', age=28, email='alice@example.com')
Name: Alice Johnson, Age: 28

See how clean that is? With just type hints and the @dataclass decorator, Python automatically generates __init__, __repr__, and __eq__ methods for you. No boilerplate required.

What Are dataclasses and Why Use Them?

Dataclasses are a syntactic sugar for creating classes that primarily hold data. They leverage the @dataclass decorator from the dataclasses module to automatically generate special methods based on class attributes. Think of them as a structured way to define objects that store information — like database records, API responses, or configuration objects.

The genius of dataclasses is that they reduce boilerplate while maintaining flexibility. You get automatic __init__ methods, readable string representations, and proper equality checking without writing a single method yourself. Python 3.10 added slots=True for memory efficiency, and Python 3.13 introduced kw_only_args for even more control.

Here’s how dataclasses compare to alternative approaches:

Feature Regular Class namedtuple dataclass
Auto __init__ No Yes Yes
Mutable Yes No Yes (unless frozen)
Auto __repr__ No Yes Yes
Default values Manual No support Built-in
Type hints Optional No Required
Post-init logic Yes No Yes (__post_init__)
Inheritance Yes Limited Yes
Memory efficient No Yes Yes (with slots=True)

Dataclasses strike the perfect balance between the simplicity of namedtuples and the flexibility of regular classes. They’re ideal for cases where you need a proper class with methods and inheritance, but you don’t want to spend your time writing constructor boilerplate.

Getting Started: Basic Dataclass Definition

The foundation of dataclasses is the @dataclass decorator. When you apply it to a class, Python inspects the type hints and generates methods accordingly. Every attribute with a type hint becomes an initialization parameter in the auto-generated __init__ method.

# basic_dataclass.py
from dataclasses import dataclass

@dataclass
class Book:
    title: str
    author: str
    pages: int
    isbn: str

# Creating instances
book1 = Book(title="Python Crash Course", author="Eric Matthes", pages=544, isbn="978-1593279288")
book2 = Book("Fluent Python", "Luciano Ramalho", 825, "978-1491946237")

print(book1)
print(book2)
print(f"Author of {book1.title}: {book1.author}")

Output:

Book(title='Python Crash Course', author='Eric Matthes', pages=544, isbn='978-1593279288')
Book(title='Fluent Python', author='Luciano Ramalho', pages=825, isbn='978-1491946237')
Author of Python Crash Course: Eric Matthes

Notice that the __repr__ output shows all attributes clearly. This is generated automatically — no __repr__ method needed. Instances are also comparable by default: two Book instances with identical attributes would be equal.

Adding Default Values

Real-world classes often have optional attributes with sensible defaults. Dataclasses handle this elegantly with type hints and default values. You can provide defaults directly in the class body, or use the field() function for advanced scenarios.

# dataclass_with_defaults.py
from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    quantity: int = 1
    in_stock: bool = True
    tags: list = None

    def __post_init__(self):
        if self.tags is None:
            self.tags = []

# Creating products
product1 = Product(name="Laptop", price=999.99)
product2 = Product(name="Mouse", price=29.99, quantity=50, in_stock=True, tags=["electronics", "accessories"])

print(product1)
print(product2)

Output:

Product(name='Laptop', price=999.99, quantity=1, in_stock=True, tags=[])
Product(name='Mouse', price=29.99, quantity=50, in_stock=True, tags=['electronics', 'accessories'])

Here’s a critical rule: fields with defaults must come after fields without defaults. Python enforces this to prevent confusion about which arguments are required. The __post_init__ method (which we’ll explore next) is perfect for initializing mutable defaults like lists and dicts.

Using the field() Function

For more control over individual fields, the field() function provides powerful options. You can set default factories for mutable types, exclude fields from comparison, hide fields from repr, or even make fields metadata-only.

# dataclass_field_function.py
from dataclasses import dataclass, field
from typing import List

@dataclass
class Student:
    name: str
    student_id: int
    grades: List[float] = field(default_factory=list)
    notes: str = field(default="No notes", repr=False)
    internal_flag: bool = field(default=False, compare=False)

student = Student(name="Bob Smith", student_id=12345)
student.grades.append(95.5)
student.grades.append(87.0)

print(student)
print(f"Notes: {student.notes}")
print(f"Grades: {student.grades}")

student2 = Student(name="Bob Smith", student_id=12345, internal_flag=True)
print(f"Students are equal: {student == student2}")  # True, because internal_flag is not compared

Output:

Student(name='Bob Smith', student_id=12345, grades=[95.5, 87.0])
Notes: No notes
Grades: [95.5, 87.0]
Students are equal: True

The default_factory parameter is essential when using mutable defaults. It accepts a callable that gets invoked each time an instance is created, ensuring each instance gets its own list or dict. Without it, all instances would share the same mutable object — a notorious Python gotcha.

Frozen Dataclasses

Sometimes you want immutable objects. The frozen=True parameter prevents any attribute modifications after initialization. This is useful for hashable objects that can be dictionary keys or set members.

# frozen_dataclass.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    x: float
    y: float
    z: float

    def distance_from_origin(self):
        return (self.x**2 + self.y**2 + self.z**2) ** 0.5

coord = Coordinate(x=3.0, y=4.0, z=0.0)
print(f"Coordinate: {coord}")
print(f"Distance from origin: {coord.distance_from_origin()}")

# Try to modify
try:
    coord.x = 5.0
except Exception as e:
    print(f"Error: {type(e).__name__} - {e}")

# Frozen objects are hashable
coords_set = {Coordinate(1, 0, 0), Coordinate(0, 1, 0), Coordinate(0, 0, 1)}
print(f"Set of coordinates: {coords_set}")

Output:

Coordinate: Coordinate(x=3.0, y=4.0, z=0.0)
Distance from origin: 5.0
Error: FrozenInstanceError - cannot assign to field 'x'
Set of coordinates: {Coordinate(x=1, y=0, z=0), Coordinate(x=0, y=1, z=0), Coordinate(x=0, y=0, z=1)}

Frozen dataclasses are automatically hashable (assuming all their fields are hashable), making them perfect for use as dictionary keys or in sets. This is a huge advantage over regular classes, where adding __hash__ and making objects immutable requires manual work.

Post-Init Logic with __post_init__

The __post_init__ method runs automatically after __init__ completes. It’s perfect for validation, computed properties, or initialization that depends on multiple fields.

# post_init_example.py
from dataclasses import dataclass
from datetime import datetime

@dataclass
class Order:
    order_id: str
    amount: float
    tax_rate: float = 0.08
    created_at: datetime = None
    total: float = 0.0

    def __post_init__(self):
        if self.created_at is None:
            self.created_at = datetime.now()

        # Validate
        if self.amount < 0:
            raise ValueError("Amount cannot be negative")
        if not (0 <= self.tax_rate <= 1):
            raise ValueError("Tax rate must be between 0 and 1")

        # Calculate total
        self.total = round(self.amount * (1 + self.tax_rate), 2)

order = Order(order_id="ORD-001", amount=100.00, tax_rate=0.08)
print(f"Order {order.order_id}: ${order.total}")

try:
    bad_order = Order(order_id="ORD-002", amount=-50.00)
except ValueError as e:
    print(f"Validation error: {e}")

Output:

Order ORD-001: $108.0
Validation error: Amount cannot be negative

Using __post_init__ keeps your initialization logic clean and centralized. It's called with no arguments after the auto-generated __init__, so you have access to all instance attributes. This makes it ideal for calculations, transformations, or validation that can't be expressed as simple defaults.

Ordering and Comparison

The order=True parameter generates ordering methods (__lt__, __le__, __gt__, __ge__), enabling sorting and comparisons. Fields are compared in declaration order.

# ordering_example.py
from dataclasses import dataclass

@dataclass(order=True)
class VersionNumber:
    major: int
    minor: int
    patch: int

versions = [
    VersionNumber(1, 0, 5),
    VersionNumber(2, 1, 0),
    VersionNumber(1, 1, 0),
    VersionNumber(1, 0, 3),
]

sorted_versions = sorted(versions)
for v in sorted_versions:
    print(f"v{v.major}.{v.minor}.{v.patch}")

print()
print(VersionNumber(1, 0, 0) < VersionNumber(1, 0, 1))
print(VersionNumber(2, 0, 0) > VersionNumber(1, 9, 9))

Output:

v1.0.3
v1.0.5
v1.1.0
v2.1.0

True
True

Comparison is lexicographic -- it uses the first field to compare, then the second if the first fields are equal, and so on. This is perfect for scenarios like version numbers, timestamps, or any data that has a natural ordering.

Using slots=True for Memory Efficiency

Python 3.10 introduced the slots=True parameter, which adds __slots__ to your dataclass. This reduces memory overhead by storing instance attributes in a fixed tuple rather than a dictionary. It's a game-changer for data-heavy applications.

# slots_example.py
from dataclasses import dataclass
import sys

@dataclass
class TraditionalPoint:
    x: float
    y: float

@dataclass(slots=True)
class SlottedPoint:
    x: float
    y: float

# Compare memory usage
traditional = TraditionalPoint(1.0, 2.0)
slotted = SlottedPoint(1.0, 2.0)

print(f"Traditional Point size: {sys.getsizeof(traditional)} bytes")
print(f"Traditional __dict__: {sys.getsizeof(traditional.__dict__)} bytes")
print(f"Slotted Point size: {sys.getsizeof(slotted)} bytes")
print(f"Slotted has __dict__: {hasattr(slotted, '__dict__')}")

# Create many instances to see the difference
traditional_points = [TraditionalPoint(i, i*2) for i in range(10000)]
slotted_points = [SlottedPoint(i, i*2) for i in range(10000)]

print(f"\n10,000 traditional points: ~{sum(sys.getsizeof(p) + sys.getsizeof(p.__dict__) for p in traditional_points[:100]) * 100} bytes estimate")
print(f"10,000 slotted points: ~{sum(sys.getsizeof(p) for p in slotted_points[:100]) * 100} bytes estimate")

Output:

Traditional Point size: 56 bytes
Traditional __dict__: 240 bytes
Slotted Point size: 56 bytes
Slotted has __dict__: False

Using slots=True is especially valuable when creating thousands of instances. The memory savings compound -- not only is each instance smaller, but there's no dictionary overhead. Just note that slotted dataclasses are slightly less flexible; you can't add arbitrary attributes at runtime.

Inheritance with Dataclasses

Dataclasses support inheritance gracefully. When you inherit from a dataclass, the parent's fields appear first in the generated __init__ method, followed by the child's fields. Always put fields with defaults in parent classes, and fields without defaults in child classes.

# inheritance_example.py
from dataclasses import dataclass

@dataclass
class Vehicle:
    make: str
    model: str
    year: int
    color: str = "Black"

@dataclass
class Car(Vehicle):
    doors: int = 4
    fuel_type: str = "Gasoline"

@dataclass
class ElectricCar(Car):
    battery_capacity_kwh: float
    range_miles: float

car = Car(make="Toyota", model="Camry", year=2022)
print(car)

electric = ElectricCar(
    make="Tesla",
    model="Model 3",
    year=2023,
    color="White",
    doors=4,
    fuel_type="Electric",
    battery_capacity_kwh=75.0,
    range_miles=358.0
)
print(electric)

Output:

Car(make='Toyota', model='Camry', year=2022, color='Black', doors=4, fuel_type='Gasoline')
ElectricCar(make='Tesla', model='Model 3', year=2023, color='White', doors=4, fuel_type='Electric', battery_capacity_kwh=75.0, range_miles=358.0)

Inheritance with dataclasses maintains proper field ordering and allows you to build hierarchies without repeating field definitions. This is especially useful for domain models where you have base classes with common attributes and subclasses with specialized behavior.

Converting to Dictionaries and Tuples

The asdict() and astuple() functions convert dataclass instances into plain dictionaries or tuples. These are invaluable for serialization, logging, or interfacing with code expecting standard Python types.

# asdict_astuple_example.py
from dataclasses import dataclass, asdict, astuple
from typing import List

@dataclass
class Employee:
    name: str
    employee_id: int
    department: str
    salary: float

emp = Employee(name="Carol White", employee_id=1001, department="Engineering", salary=95000.00)

# Convert to dict
emp_dict = asdict(emp)
print("As dictionary:")
print(emp_dict)
print(f"Type: {type(emp_dict)}")

# Convert to tuple
emp_tuple = astuple(emp)
print("\nAs tuple:")
print(emp_tuple)
print(f"Type: {type(emp_tuple)}")

# Useful for database operations
import json
emp_json = json.dumps(emp_dict)
print(f"\nJSON serialized: {emp_json}")

# Recreate from dict
new_emp = Employee(**emp_dict)
print(f"\nRecreated from dict: {new_emp}")

Output:

As dictionary:
{'name': 'Carol White', 'employee_id': 1001, 'department': 'Engineering', 'salary': 95000.0}
Type: <class 'dict'>

As tuple:
('Carol White', 1001, 'Engineering', 95000.0)
Type: <class 'tuple'>

JSON serialized: {"name": "Carol White", "employee_id": 1001, "department": "Engineering", "salary": 95000.0}

Recreated from dict: Employee(name='Carol White', employee_id=1001, department='Engineering', salary=95000.0)

Converting to dictionaries is especially useful for APIs and database operations. You can easily serialize to JSON, pass to SQL queries, or send over HTTP. The ability to reconstruct from a dictionary (using **dict_instance) makes round-tripping seamless.

Customizing __repr__ and __eq__

While dataclasses generate __repr__ and __eq__ automatically, you can override them with custom implementations. You can also use the decorator parameters to control what gets included in these methods.

# custom_repr_eq.py
from dataclasses import dataclass, field

@dataclass
class Product:
    sku: str
    name: str
    price: float
    inventory_notes: str = field(repr=False, compare=False)
    warehouse_location: str = field(repr=False, compare=False)

    def __repr__(self):
        return f"<Product {self.sku}: {self.name} (${self.price:.2f})>"

    def __eq__(self, other):
        if not isinstance(other, Product):
            return False
        return self.sku == other.sku

# Test custom __repr__
p1 = Product(sku="PROD-001", name="Wireless Mouse", price=29.99,
             inventory_notes="Low stock", warehouse_location="Shelf A-12")
print(p1)

# Test custom __eq__
p2 = Product(sku="PROD-001", name="Different Name", price=99.99,
             inventory_notes="High stock", warehouse_location="Shelf B-5")
print(f"p1 == p2: {p1 == p2}")  # True because SKU is same

# Test with different SKU
p3 = Product(sku="PROD-002", name="Wireless Mouse", price=29.99,
             inventory_notes="In stock", warehouse_location="Shelf A-12")
print(f"p1 == p3: {p1 == p3}")  # False because SKU differs

Output:

<Product PROD-001: Wireless Mouse ($29.99)>
p1 == p2: True
p1 == p3: False

Custom equality is often necessary in business logic. For example, two products with the same SKU might be considered equal even if their prices or locations differ. Custom __repr__ lets you create compact, readable representations that focus on what matters for your domain.

Real-Life Example: Building an Inventory System with dataclasses

Let's bring everything together in a practical example -- an inventory management system for a warehouse. This demonstrates inheritance, field validation, post-init logic, serialization, and ordering.

# inventory_system.py
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import List
from datetime import datetime

class WarehouseZone(Enum):
    COLD_STORAGE = "cold"
    DRY_STORAGE = "dry"
    OVERFLOW = "overflow"

@dataclass
class InventoryItem:
    item_id: str
    name: str
    quantity: int
    unit_cost: float
    zone: WarehouseZone
    last_updated: datetime = field(default_factory=datetime.now)
    supplier: str = "Generic"

    def __post_init__(self):
        if self.quantity < 0:
            raise ValueError("Quantity cannot be negative")
        if self.unit_cost <= 0:
            raise ValueError("Unit cost must be positive")

    def total_value(self) -> float:
        return self.quantity * self.unit_cost

    def low_stock(self, threshold: int = 50) -> bool:
        return self.quantity < threshold

@dataclass(order=True)
class InventoryAlert:
    priority: int
    message: str
    item_id: str = field(compare=False)
    timestamp: datetime = field(default_factory=datetime.now, compare=False)

class Warehouse:
    def __init__(self):
        self.items: List[InventoryItem] = []
        self.alerts: List[InventoryAlert] = []

    def add_item(self, item: InventoryItem):
        self.items.append(item)
        if item.low_stock():
            self.alerts.append(InventoryAlert(
                priority=1,
                message=f"Low stock: {item.name}",
                item_id=item.item_id
            ))

    def remove_item(self, item_id: str, quantity: int):
        for item in self.items:
            if item.item_id == item_id:
                if quantity > item.quantity:
                    raise ValueError(f"Cannot remove {quantity} units; only {item.quantity} available")
                item.quantity -= quantity
                item.last_updated = datetime.now()
                return
        raise KeyError(f"Item {item_id} not found")

    def get_inventory_report(self) -> List[dict]:
        return [asdict(item) for item in self.items]

    def get_low_stock_alerts(self) -> List[str]:
        sorted_alerts = sorted(self.alerts)
        return [f"[P{a.priority}] {a.message}" for a in sorted_alerts]

# Usage
warehouse = Warehouse()

warehouse.add_item(InventoryItem(
    item_id="SKU-001",
    name="Premium Coffee Beans",
    quantity=25,
    unit_cost=12.50,
    zone=WarehouseZone.DRY_STORAGE,
    supplier="Mountain Valley"
))

warehouse.add_item(InventoryItem(
    item_id="SKU-002",
    name="Frozen Vegetables",
    quantity=200,
    unit_cost=3.75,
    zone=WarehouseZone.COLD_STORAGE,
    supplier="Fresh Farms Inc"
))

print("Low Stock Alerts:")
for alert in warehouse.get_low_stock_alerts():
    print(alert)

print("\nInventory Report (first item):")
report = warehouse.get_inventory_report()
print(f"Item: {report[0]['name']}")
print(f"Quantity: {report[0]['quantity']}")
print(f"Total Value: ${report[0]['quantity'] * report[0]['unit_cost']:.2f}")

Output:

Low Stock Alerts:
[P1] Low stock: Premium Coffee Beans

Inventory Report (first item):
Item: Premium Coffee Beans
Quantity: 25
Total Value: $312.50

This example shows dataclasses in action across a realistic scenario. We used inheritance implicitly (through composition), validation in __post_init__, ordering for alerts, serialization with asdict(), and practical business logic. Notice how clean the code is compared to implementing all these features manually.

Frequently Asked Questions

Q: Can I add methods to a dataclass?

Yes! Dataclasses are regular Python classes. You can add as many methods as you want. Methods don't interfere with the dataclass machinery at all.

Q: What's the difference between dataclass(frozen=True) and namedtuple?

Frozen dataclasses are more flexible. You can add methods, use inheritance, customize behavior, and even have mutable fields. Namedtuples are more restrictive but have been around longer and have minimal overhead.

Q: Do dataclasses work with type checking tools like mypy?

Absolutely. Type hints in dataclasses are preserved and work perfectly with mypy, pylint, and other type checkers. This is one of their key advantages over namedtuples or regular classes.

Q: Can I make some fields required and others optional in a dataclass?

Yes, fields without defaults are required. Fields with defaults or field(default_factory=...) are optional. You can use the Optional type hint as well: name: Optional[str] = None.

Q: How do I prevent users from adding arbitrary attributes to instances?

Use slots=True (Python 3.10+) or inherit from a base class with __slots__ defined. This restricts attribute assignment to the declared fields.

Q: Can dataclasses be used with JSON serialization?

Yes, use asdict() to convert to a dictionary, then serialize with json.dumps(). For deserialization, parse the JSON back to a dict and instantiate using ClassName(**dict).

Q: What happens if I use a mutable default without default_factory?

All instances will share the same mutable object, leading to unexpected behavior. Always use field(default_factory=list) or field(default_factory=dict) for mutable defaults.

Conclusion

Dataclasses represent a significant improvement in how you structure data-holding classes in Python. They eliminate boilerplate, reduce bugs, and integrate seamlessly with modern Python tooling. Whether you're building APIs, working with databases, or creating domain models, dataclasses can simplify your code substantially.

The key takeaway is this: dataclasses aren't a replacement for all classes, but they're perfect for the most common case -- when you need a clean, simple class to hold structured data. Once you start using them, you'll wonder how you ever lived without them.

For a complete reference, visit the official Python documentation: dataclasses documentation

How To Use Type Hints in Python with Mypy

How To Use Type Hints in Python with Mypy

Intermediate

How To Use Type Hints in Python with Mypy

Python is known for its simplicity and readability, but this comes at a cost — it’s dynamically typed, which means you can assign any type of value to a variable at any time. While this flexibility is powerful, it can lead to subtle bugs that only appear at runtime. Imagine debugging a function that crashes because you accidentally passed a string where an integer was expected, only to discover the issue after hours of investigation. Type hints solve this problem by letting you specify what types your functions and variables should accept, catching errors before your code ever runs.

If you’re worried that adding type hints will make your Python code feel like Java or C++, rest assured — Python’s type hints are optional, unobtrusive, and entirely optional at runtime. They exist purely for documentation, IDE support, and static analysis. Your code runs exactly the same with or without them, but with type hints, you unlock powerful tools like Mypy that can catch entire categories of bugs before deployment.

In this tutorial, you’ll learn everything you need to start using type hints effectively. We’ll cover the basics of annotating variables and functions, explore the typing module, understand how Mypy validates your code, and work through real-world examples that demonstrate the power of static type checking. By the end, you’ll understand why type hints are becoming standard practice in professional Python codebases.

Quick Example

Here’s a minimal example that shows type hints in action. Don’t worry if it looks unfamiliar — we’ll break down each component in detail:

# file: quick_example.py
def greet(name: str, age: int) -> str:
    return f"Hello {name}, you are {age} years old"

def add_numbers(a: int, b: int) -> int:
    return a + b

result = add_numbers(5, 10)
message = greet("Alice", 30)
print(message)
print(result)

Without type hints, Python wouldn’t catch it if you accidentally called `add_numbers(“5”, 10)` with a string instead of an integer. With type hints and Mypy, this error is caught instantly before you run the code.

What Are Type Hints and Why Use Them?

Type hints are annotations that specify what types your functions, variables, and return values should be. They’re written using Python’s typing syntax and don’t affect how your code executes — Python’s interpreter completely ignores them at runtime. Their purpose is to help you, your team, and automated tools understand what types of data flow through your code.

The primary benefits of type hints include catching bugs before runtime, improving code readability, enabling better IDE autocompletion, serving as documentation, and refactoring with confidence. When you annotate your code with types, tools like Mypy can analyze it statically and warn you about potential type mismatches without executing the code.

Let’s compare typed versus untyped code side by side:

Without Type Hints With Type Hints
def process(data): def process(data: list) -> int:
Unclear what types are expected Clear intent and expectations
IDE can’t provide smart autocomplete IDE knows what methods are available
Runtime errors when wrong types passed Static analysis catches type errors
Hard to refactor safely Mypy ensures refactoring doesn’t break contracts

Type hints scale tremendously in larger codebases. A function with type hints serves as a contract — callers know exactly what to pass, and the function author knows exactly what to expect. This reduces bugs, improves collaboration, and makes code easier to maintain.

Basic Type Hints: Built-in Types

Let’s start with the simplest type hints — annotations for built-in Python types. You can annotate variables when you create them, and annotate function parameters and return values.

# file: basic_types.py
# Simple variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.8
is_active: bool = True

# Function with type hints
def calculate_age_in_days(age: int) -> int:
    return age * 365

def greet_user(name: str, age: int) -> str:
    days = calculate_age_in_days(age)
    return f"{name} is {days} days old"

# Using the functions
print(greet_user("Bob", 25))
print(calculate_age_in_days(40))

Output:

Bob is 9125 days old
14600

In the example above, the colon (:) separates the parameter name from its type. For return types, the arrow (->) comes after the parameter list. These annotations tell anyone reading the code — and tools like Mypy — exactly what types are expected. If you tried to call `calculate_age_in_days(“thirty”)`, Mypy would immediately flag it as an error.

The basic types you’ll use most often are `str`, `int`, `float`, and `bool`. But what if you need to work with collections like lists or dictionaries? That’s where things get interesting.

Collections: Lists, Dicts, and Tuples

When you want to annotate a list, you can’t just write `list` — you need to specify what type of items the list contains. This is where the `typing` module comes in. The `typing` module provides generic types like `List`, `Dict`, and `Tuple` that let you specify what they contain.

# file: collections_example.py
from typing import List, Dict, Tuple

# List of integers
scores: List[int] = [95, 87, 92, 88]

# List of strings
names: List[str] = ["Alice", "Bob", "Charlie"]

# Dictionary with string keys and integer values
age_map: Dict[str, int] = {"Alice": 30, "Bob": 25, "Charlie": 28}

# Tuple with fixed types
location: Tuple[float, float] = (40.7128, -74.0060)

# Function that processes a list
def sum_scores(scores: List[int]) -> int:
    return sum(scores)

# Function that works with dictionaries
def get_age(person_name: str, ages: Dict[str, int]) -> int:
    return ages[person_name]

# Using the functions
total = sum_scores(scores)
alice_age = get_age("Alice", age_map)
print(f"Total scores: {total}")
print(f"Alice's age: {alice_age}")
print(f"Location: {location}")

Output:

Total scores: 362
Alice's age: 30
Location: (40.7128, -74.006)

The syntax `List[int]` means “a list containing integers”. Similarly, `Dict[str, int]` means “a dictionary with string keys and integer values”, and `Tuple[float, float]` means “a tuple containing exactly two floats”. This specificity is what makes type checking powerful — Mypy can now verify that you’re not accidentally passing a list of strings to a function expecting a list of integers.

Optional and Union Types

Sometimes a function might return either a value or `None`, or it might accept multiple different types. Python provides `Optional` and `Union` for these scenarios. `Optional[T]` is shorthand for “either a value of type T or None”, while `Union` lets you specify multiple possible types.

# file: optional_union.py
from typing import Optional, Union

# Function that might return None
def find_user_age(name: str, users: dict) -> Optional[int]:
    if name in users:
        return users[name]["age"]
    return None

# Function that accepts multiple types
def process_value(value: Union[int, str]) -> str:
    if isinstance(value, int):
        return f"Number: {value * 2}"
    else:
        return f"Text: {value.upper()}"

# Dictionary of user data
users_db = {
    "Alice": {"age": 30},
    "Bob": {"age": 25}
}

# Using Optional
age = find_user_age("Alice", users_db)
print(f"Alice's age: {age}")

missing_age = find_user_age("Charlie", users_db)
print(f"Charlie's age: {missing_age}")

# Using Union
result1 = process_value(10)
result2 = process_value("hello")
print(result1)
print(result2)

Output:

Alice's age: 30
Charlie's age: None
Number: 20
Text: HELLO

The `Optional` type is essential in Python because None is a valid value in many scenarios. By marking a return type as `Optional[int]`, you’re telling callers “this function might return an integer or None, and you should handle both cases”. This prevents a whole class of bugs where code forgets to check for None before using a value.

The Typing Module: List, Dict, Tuple, and More

We briefly introduced `List`, `Dict`, and `Tuple` from the typing module. Let’s explore more capabilities and understand when to use them. In Python 3.9+, you can actually use built-in `list`, `dict`, and `tuple` directly for type hints, but the `typing` module versions work in all Python versions and provide more features.

# file: typing_module.py
from typing import List, Dict, Set, Tuple, Any

# Specific typed collections
numbers: List[int] = [1, 2, 3, 4, 5]
name_ages: Dict[str, int] = {"Alice": 30, "Bob": 25}
unique_tags: Set[str] = {"python", "tutorial", "typing"}
coordinates: Tuple[int, int, int] = (10, 20, 30)

# Using Any when type is truly unknown (use sparingly!)
# This disables type checking for this variable
unknown_value: Any = "could be anything"

# Function with complex typing
def process_data(
    items: List[Dict[str, Any]],
    filters: Set[str]
) -> List[str]:
    results = []
    for item in items:
        if item.get("type") in filters:
            results.append(item.get("name", "Unknown"))
    return results

# Sample data
data = [
    {"name": "Alice", "type": "user"},
    {"name": "Bob", "type": "admin"},
    {"name": "Document", "type": "file"}
]

# Using the function
filtered = process_data(data, {"user", "admin"})
print(f"Filtered results: {filtered}")

Output:

Filtered results: ['Alice', 'Bob']

The `Any` type is a special case that essentially says “this can be any type” and disables type checking for that variable. Use `Any` sparingly — it defeats the purpose of type hints. It’s useful for truly dynamic situations or when working with third-party code you can’t control, but typed alternatives are almost always better.

Function Annotations: Parameters and Returns

Function annotations are where type hints shine. By annotating parameters and return types, you create a contract that documents what a function expects and what it produces. This makes functions self-documenting and enables powerful static analysis.

# file: function_annotations.py
from typing import List, Optional

def calculate_average(scores: List[float]) -> float:
    """Calculate the average of a list of scores."""
    if not scores:
        return 0.0
    return sum(scores) / len(scores)

def find_maximum(numbers: List[int]) -> Optional[int]:
    """Return the maximum number, or None if list is empty."""
    return max(numbers) if numbers else None

def format_report(
    title: str,
    items: List[str],
    show_count: bool = True
) -> str:
    """Format items into a report string."""
    report = f"=== {title} ===\n"
    for item in items:
        report += f"- {item}\n"
    if show_count:
        report += f"Total: {len(items)}"
    return report

# Using the functions
test_scores = [85.5, 90.0, 78.5, 92.0]
average = calculate_average(test_scores)
print(f"Average score: {average}")

numbers = [45, 23, 89, 12, 56]
max_num = find_maximum(numbers)
print(f"Maximum number: {max_num}")

report = format_report("Tasks", ["Write code", "Review PR", "Deploy"], show_count=True)
print(report)

Output:

Average score: 86.5
Maximum number: 89
=== Tasks ===
- Write code
- Review PR
- Deploy
Total: 3

Notice that we’re using `Optional[int]` for functions that might return None, and `List[float]` for functions that accept collections. Default parameter values (like `show_count: bool = True`) work naturally with type hints — the type annotation comes before the equals sign.

Class Annotations and Instance Variables

Type hints work wonderfully with classes. You can annotate instance variables, method parameters, and return types. This makes class structure clear and helps catch errors when using class instances.

# file: class_annotations.py
from typing import List, Optional
from datetime import datetime

class Person:
    """Represents a person with type-annotated attributes."""

    # Class-level type annotations
    name: str
    age: int
    email: Optional[str]

    def __init__(self, name: str, age: int, email: Optional[str] = None) -> None:
        self.name = name
        self.age = age
        self.email = email

    def get_info(self) -> str:
        """Return a formatted string with person information."""
        return f"{self.name} ({self.age} years old)"

    def is_adult(self) -> bool:
        """Check if the person is an adult."""
        return self.age >= 18

class Team:
    """Represents a team of people."""

    name: str
    members: List[Person]

    def __init__(self, name: str) -> None:
        self.name = name
        self.members = []

    def add_member(self, person: Person) -> None:
        """Add a person to the team."""
        self.members.append(person)

    def get_adult_members(self) -> List[Person]:
        """Return only adult team members."""
        return [m for m in self.members if m.is_adult()]

    def member_count(self) -> int:
        """Return the number of team members."""
        return len(self.members)

# Using the classes
alice = Person("Alice", 30, "alice@example.com")
bob = Person("Bob", 17)
charlie = Person("Charlie", 25, "charlie@example.com")

team = Team("Development")
team.add_member(alice)
team.add_member(bob)
team.add_member(charlie)

print(f"Team: {team.name}")
print(f"Total members: {team.member_count()}")
print(f"Adults: {len(team.get_adult_members())}")
for member in team.members:
    print(f"  - {member.get_info()}")

Output:

Team: Development
Total members: 3
Adults: 2
  - Alice (30 years old)
  - Bob (17 years old)
  - Charlie (25 years old)

Class annotations make the structure of your objects immediately clear. Anyone reading this code knows exactly what attributes a `Person` has and what types they hold. The `__init__` method also has type hints showing what it expects and that it returns `None` (all constructors return None since they don’t return anything explicitly).

Generics Basics: Writing Flexible Type-Safe Code

Generics allow you to write functions and classes that work with multiple types while maintaining type safety. Instead of using `Any`, you can use type variables to specify “this function works with lists of any type, but the type must be consistent”.

# file: generics_example.py
from typing import TypeVar, List, Generic

# Define a type variable -- T is a placeholder for any type
T = TypeVar('T')

# Generic function that works with any type
def get_first(items: List[T]) -> T:
    """Return the first item from a list."""
    if not items:
        raise ValueError("List is empty")
    return items[0]

def reverse_list(items: List[T]) -> List[T]:
    """Return a reversed copy of the list."""
    return items[::-1]

# Generic class
class Container(Generic[T]):
    """A simple container that holds one item of any type."""

    def __init__(self, item: T) -> None:
        self.item = item

    def get_item(self) -> T:
        return self.item

    def set_item(self, item: T) -> None:
        self.item = item

# Using generic functions
int_list = [10, 20, 30, 40]
str_list = ["apple", "banana", "cherry"]

first_int = get_first(int_list)  # Type checker knows this is int
first_str = get_first(str_list)  # Type checker knows this is str

print(f"First int: {first_int}")
print(f"First string: {first_str}")
print(f"Reversed ints: {reverse_list(int_list)}")

# Using generic class
int_container = Container(42)
str_container = Container("hello")

print(f"Int container: {int_container.get_item()}")
print(f"String container: {str_container.get_item()}")

Output:

First int: 10
First string: apple
Reversed ints: [40, 30, 20, 10]
Int container: 42
String container: hello

Generics are powerful because they preserve type information. When you call `get_first(int_list)`, type checkers understand that the return value is an `int`, not just some unknown `T`. This is much safer than using `Any` and provides excellent IDE support — your editor can offer correct autocompletion based on the actual type.

Installing and Running Mypy

Mypy is a static type checker for Python that analyzes your code without running it. Installation is straightforward using pip, and running it is even simpler. Let’s set up Mypy and check our type hints.

First, install Mypy using pip:

# file: terminal
pip install mypy

Once installed, you can check a single file or an entire directory. Create a test file with some intentional type errors to see how Mypy catches them:

# file: mypy_test.py
def add_numbers(a: int, b: int) -> int:
    return a + b

# This is correct
result1 = add_numbers(5, 10)
print(result1)

# This will cause a Mypy error
result2 = add_numbers("5", 10)
print(result2)

Now run Mypy on this file:

# file: terminal
mypy mypy_test.py

Mypy will output something like:

mypy_test.py:8: error: Argument 1 to "add_numbers" has incompatible type "str"; expected "int"

This error tells you exactly where the problem is — line 8, argument 1 of the `add_numbers` call. Even though the code would run fine if `add_numbers` could handle string input, Mypy caught the type mismatch before you ran the code. For larger projects, you can run mypy on an entire directory:

# file: terminal
mypy your_project/

You can also configure Mypy’s strictness using a `mypy.ini` file. A basic configuration might look like:

# file: mypy.ini
[mypy]
python_version = 3.9
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True

The `disallow_untyped_defs = True` option enforces that every function must have type hints. This is strict but catches a lot of bugs in larger codebases.

Common Mypy Errors and How to Fix Them

Let’s explore the most common type errors you’ll encounter with Mypy and how to fix them. Understanding these patterns will help you write type-safe code quickly.

Error: Incompatible Type Assignment

This is the most common error — you’re assigning a value of the wrong type to a variable:

# file: error_incompatible.py
# WRONG: str assigned to int variable
count: int = "five"

# CORRECT: assign actual integer
count: int = 5

# WRONG: list of strings assigned to list of ints
numbers: list[int] = ["1", "2", "3"]

# CORRECT: list of integers
numbers: list[int] = [1, 2, 3]

Fix: Make sure the value type matches the annotated type. Convert types if needed:

# file: fix_incompatible.py
# Convert before assigning
count: int = int("five")  # Could raise ValueError, but type is correct
numbers: list[int] = [int(x) for x in ["1", "2", "3"]]

Error: Missing Return Type

When a function doesn’t explicitly return a value on all code paths, Mypy complains:

# file: error_missing_return.py
def check_age(age: int) -> str:
    if age >= 18:
        return "Adult"
    # Missing return statement -- what if age < 18?

# CORRECT:
def check_age(age: int) -> str:
    if age >= 18:
        return "Adult"
    else:
        return "Minor"

Fix: Ensure all code paths return a value, or change the return type to `Optional[str]` if None is acceptable:

# file: fix_missing_return.py
from typing import Optional

def check_age(age: int) -> Optional[str]:
    if age >= 18:
        return "Adult"
    return None  # Explicitly return None

Error: Argument Has Incompatible Type

You’re passing the wrong type to a function:

# file: error_argument.py
def process_list(items: list[int]) -> int:
    return sum(items)

# WRONG: passing list of strings
result = process_list(["1", "2", "3"])

# CORRECT: convert to integers first
result = process_list([1, 2, 3])

Fix: Convert the argument to the correct type or check your function call:

# file: fix_argument.py
def process_list(items: list[int]) -> int:
    return sum(items)

# Convert strings to ints
result = process_list([int(x) for x in ["1", "2", "3"]])
print(result)  # Output: 6

Error: Item Is None (Need Optional Check)

You’re accessing an attribute on something that might be None:

# file: error_none_access.py
from typing import Optional

def get_name(person: Optional[dict]) -> str:
    return person["name"]  # person could be None!

# CORRECT:
def get_name(person: Optional[dict]) -> Optional[str]:
    if person is not None:
        return person.get("name")
    return None

Fix: Always check for None before using Optional values:

# file: fix_none_access.py
from typing import Optional

def get_name(person: Optional[dict]) -> str:
    if person is None:
        return "Unknown"
    return person.get("name", "Unknown")

# Test it
result = get_name(None)
print(result)  # Output: Unknown

Error: Return Type Mismatch

Your function returns a different type than what’s annotated:

# file: error_return_mismatch.py
def calculate(value: int) -> str:
    # WRONG: returning int instead of str
    return value * 2

# CORRECT:
def calculate(value: int) -> str:
    return str(value * 2)

Fix: Ensure your return statement returns the correct type, or change the annotation:

# file: fix_return_mismatch.py
def calculate(value: int) -> str:
    result = value * 2
    return str(result)

print(calculate(5))  # Output: "10"

Real-Life Example: Type-Safe Contact Manager

Let’s bring everything together with a practical example — a contact manager application with full type hints. This demonstrates how type hints make complex code safe and maintainable:

# file: contact_manager.py
from typing import List, Optional, Dict
from datetime import datetime

class Contact:
    """Represents a single contact with full type annotations."""

    name: str
    email: str
    phone: Optional[str]
    created_at: datetime

    def __init__(self, name: str, email: str, phone: Optional[str] = None) -> None:
        if not name or not email:
            raise ValueError("Name and email are required")
        self.name = name
        self.email = email
        self.phone = phone
        self.created_at = datetime.now()

    def get_display_name(self) -> str:
        """Return formatted contact name."""
        return self.name.upper()

    def has_phone(self) -> bool:
        """Check if contact has phone number."""
        return self.phone is not None

class ContactManager:
    """Manages a collection of contacts."""

    contacts: List[Contact]

    def __init__(self) -> None:
        self.contacts = []

    def add_contact(self, contact: Contact) -> None:
        """Add a new contact to the manager."""
        self.contacts.append(contact)

    def find_by_email(self, email: str) -> Optional[Contact]:
        """Find a contact by email address."""
        for contact in self.contacts:
            if contact.email == email:
                return contact
        return None

    def find_by_name(self, name: str) -> List[Contact]:
        """Find all contacts matching a name (partial match)."""
        return [c for c in self.contacts if name.lower() in c.name.lower()]

    def get_all_with_phone(self) -> List[Contact]:
        """Return contacts that have phone numbers."""
        return [c for c in self.contacts if c.has_phone()]

    def get_contact_summary(self) -> Dict[str, int]:
        """Return summary statistics about contacts."""
        return {
            "total": len(self.contacts),
            "with_phone": len(self.get_all_with_phone()),
            "without_phone": len(self.contacts) - len(self.get_all_with_phone())
        }

    def export_emails(self) -> List[str]:
        """Export all contact emails."""
        return [c.email for c in self.contacts]

# Using the contact manager
manager = ContactManager()

# Add contacts
alice = Contact("Alice Johnson", "alice@example.com", "+1-555-0101")
bob = Contact("Bob Smith", "bob@example.com")
charlie = Contact("Charlie Brown", "charlie@example.com", "+1-555-0103")

manager.add_contact(alice)
manager.add_contact(bob)
manager.add_contact(charlie)

# Query contacts
print(f"Total contacts: {len(manager.contacts)}")
print(f"Contacts with phones: {len(manager.get_all_with_phone())}")

found = manager.find_by_email("alice@example.com")
if found:
    print(f"Found: {found.name} ({found.phone})")

johns = manager.find_by_name("john")
print(f"Contacts named 'john': {len(johns)}")

summary = manager.get_contact_summary()
print(f"Summary: {summary}")

emails = manager.export_emails()
print(f"All emails: {emails}")

Output:

Total contacts: 3
Contacts with phones: 2
Found: Alice Johnson (+1-555-0101)
Contacts named 'john': 1
Summary: {'total': 3, 'with_phone': 2, 'without_phone': 1}
All emails: ['alice@example.com', 'bob@example.com', 'charlie@example.com']

This contact manager demonstrates several key principles: every method has clear type annotations, return types are explicit (including `Optional` and `List`), class attributes are type-annotated, and the code is self-documenting. If you run Mypy on this file, it will validate that every function returns the correct type and every variable receives compatible values. This gives you confidence that the code works as intended without having to manually trace through every function call.

Best Practices for Type Hints

Now that you understand the mechanics of type hints, here are some best practices to follow in your projects. First, be consistent — if you use type hints in one file, use them throughout your project. Second, use the most specific type possible; don’t settle for `Any` when `List[int]` would work. Third, use type hints in all public functions but you can be more relaxed with private helper functions. Fourth, combine docstrings with type hints; while type hints show what types a function expects, docstrings explain what it does.

Another best practice is to use `Optional` only when None is truly an acceptable value. If a function should always return a string, don’t use `Optional[str]` just to be safe. Fifth, keep your types as simple as possible — deeply nested types like `Dict[str, List[Tuple[int, Optional[str]]]]` become hard to read. Consider breaking these into type aliases or separate functions. Finally, use tools like Mypy and pylint in your CI/CD pipeline to catch type errors automatically before code is merged.

Frequently Asked Questions

Do type hints affect performance or runtime behavior?

No, type hints are completely ignored at runtime. Python’s interpreter removes them during compilation, so they have zero impact on how fast or slow your code runs. Type hints exist purely for documentation and static analysis by tools like Mypy.

Can I use type hints with older Python versions?

Type hints were introduced in Python 3.5, so any Python 3.5+ supports basic type hints. However, some advanced features like union using the pipe operator (`int | str`) require Python 3.10+. For maximum compatibility, use the `typing` module imports like `Union[int, str]`.

What’s the difference between `List` from typing and built-in `list`?

In Python 3.9+, you can use built-in `list[int]` instead of `typing.List[int]`. They’re equivalent, but the built-in versions are preferred in newer code. The typing module versions work in older Python versions, so use those if you need to support Python 3.8 and earlier.

How strict should I be with type hints?

Start with type hints on all public functions and class methods. As your codebase grows and you become comfortable with types, increase strictness. Mypy has a `disallow_untyped_defs` option that enforces types everywhere, but it’s strict and requires more discipline. Find a balance that works for your team.

Can I type hint dictionaries with multiple value types?

Yes, use `Dict[str, Union[int, str]]` to indicate a dictionary with string keys and values that can be either int or str. You can also use `Any` if values are truly unknown, but try to be more specific when possible.

Should I use type hints in scripts and small projects?

Even small projects benefit from type hints, especially if you’ll return to them later or share them with others. Type hints serve as documentation and help you catch bugs. The investment in adding them pays off quickly.

Conclusion

Type hints are a powerful tool for writing safer, more maintainable Python code. They transform Python from a language where type errors hide until runtime into one where you catch them during development. Combined with Mypy, type hints let you refactor code with confidence, understand complex codebases faster, and collaborate more effectively with teammates.

The journey to type-safe Python starts simple with basic annotations and grows as your codebase becomes more complex. Begin by adding type hints to your public functions, run Mypy regularly, and gradually increase your type coverage. The investment in type hints pays dividends in code quality and developer productivity.

To learn more, check out the official Python typing module documentation and the Mypy documentation. Both resources provide comprehensive references and advanced patterns for type hints.

How To Write Unit Tests with pytest in Python

How To Write Unit Tests with pytest in Python

Beginner

Introduction

Writing unit tests is one of the most important practices in modern software development, yet many beginners skip it thinking it slows them down. The truth is the opposite — testing saves time by catching bugs early, making refactoring safer, and helping you understand your own code better. In this guide, you will learn how pytest makes testing so simple that you will actually enjoy writing tests.

If you are worried that testing is complex or requires special knowledge, put that fear to rest. pytest is designed to be intuitive and beginner-friendly. You will write test functions that look almost identical to regular Python functions, using plain assertions instead of cryptic methods. No need to memorize a dozen different assertion types or inherit from test base classes.

In this tutorial, we will start with a quick working example so you see testing in action immediately. Then we will explore what pytest is, install it, write various types of tests, and work through a complete real-world example. By the end, you will understand how to test your Python code effectively and confidently.

Quick Example

Let us jump straight into a working pytest test. This minimal example shows how simple testing can be:

# test_quick.py
def add(a, b):
    return a + b

def test_add_positive_numbers():
    assert add(2, 3) == 5

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

def test_add_mixed():
    assert add(5, -2) == 3

Output:

$ pytest test_quick.py
============================= test session starts ==============================
collected 3 items

test_quick.py::test_add_positive_numbers PASSED                        [ 33%]
test_quick.py::test_add_negative_numbers PASSED                        [ 66%]
test_quick.py::test_add_mixed PASSED                                   [100%]

============================== 3 passed in 0.02s ===============================

That is it! Three tests passing. Notice there is no special TestCase class to inherit from, no setUp methods, and no assertEquals calls. Just plain functions and simple assertions. This simplicity is what makes pytest so powerful.

Python pytest tests passing with green checkmarks
assert expected == reality. For once, it passed.

What is pytest and Why Use It?

pytest is a testing framework that makes writing and running tests in Python delightfully simple. It is now the de facto standard for Python testing, used by companies like Mozilla, Stripe, and countless open-source projects. pytest shines because it reduces boilerplate, makes test discovery automatic, and provides powerful features like fixtures and parametrization built in.

Python comes with a built-in testing module called unittest, which is powerful but verbose. It requires you to create classes, inherit from TestCase, and use assertion methods like assertEqual. By contrast, pytest uses simple functions and the plain assert statement. Here is a comparison:

Featureunittestpytest
Test discoveryRequires naming conventionAutomatic (test_* or Test*)
AssertionsassertEqual, assertTrue, etc.Plain assert statement
Class requirementMust inherit from TestCaseSimple functions
Setup/teardownsetUp/tearDown methodsFixtures (more flexible)
ParametrizationUse subTest or external tools@pytest.mark.parametrize
Learning curveModerateGentle

For beginners, pytest removes friction. You write less boilerplate, learn fewer concepts, and get productive faster. For experienced developers, pytest provides industrial-strength capabilities through fixtures, parametrization, and its plugin ecosystem.

Installing pytest

Before we write any tests, we need to install pytest. Open your terminal and run:

# install_pytest.sh
pip install pytest

Output:

Collecting pytest
  Downloading pytest-7.4.0-py3-none-any.whl (298 kB)
Successfully installed pytest-7.4.0

Verify the installation:

# verify.sh
pytest --version

Output:

pytest 7.4.0

You are ready to start writing tests. If you are using a virtual environment (recommended), activate it before running pip install.

Installing pytest tools for Python testing
pip install pytest — the only setup you will ever need.

Writing Basic Tests

Test files in pytest must be named test_*.py or *_test.py so pytest can discover them automatically. A basic test is simply a function starting with test_ that uses assert statements:

# test_calculator.py
def multiply(a, b):
    return a * b

def test_multiply_basic():
    result = multiply(3, 4)
    assert result == 12

def test_multiply_by_zero():
    result = multiply(5, 0)
    assert result == 0

def test_multiply_negatives():
    result = multiply(-2, -3)
    assert result == 6

Output:

$ pytest test_calculator.py -v
======================== test session starts ==========================
collected 3 items

test_calculator.py::test_multiply_basic PASSED                  [ 33%]
test_calculator.py::test_multiply_by_zero PASSED                [ 66%]
test_calculator.py::test_multiply_negatives PASSED              [100%]

======================== 3 passed in 0.01s ===========================

The -v flag shows verbose output with each test listed individually. Each test is independent — they run in any order and share no state.

Mastering Assertions

The assert statement is the heart of testing. Here are the most common patterns:

# test_assertions.py
def test_equality():
    assert 5 == 5
    assert "hello" == "hello"
    assert [1, 2, 3] == [1, 2, 3]

def test_truthiness():
    assert True
    assert not False
    assert [1, 2, 3]  # non-empty list is truthy
    assert not []  # empty list is falsy

def test_membership():
    assert 2 in [1, 2, 3]
    assert "key" in {"key": "value"}

def test_type_checking():
    assert isinstance(5, int)
    assert isinstance("hello", str)

Output:

$ pytest test_assertions.py -v
======================== test session starts ==========================
test_assertions.py::test_equality PASSED                        [ 25%]
test_assertions.py::test_truthiness PASSED                      [ 50%]
test_assertions.py::test_membership PASSED                      [ 75%]
test_assertions.py::test_type_checking PASSED                   [100%]

======================== 4 passed in 0.01s ===========================

When an assertion fails, pytest provides detailed error messages showing exactly what went wrong, including the values on both sides of the comparison.

Comparing expected and actual values in pytest assertions
assert actual == expected. The debugger’s mantra.

Using Fixtures

Fixtures are reusable pieces of test setup. Instead of repeating setup code in every test, define it once and inject it where needed. Think of fixtures as the pytest way of doing setup and teardown:

# test_database.py
import pytest

class Database:
    def __init__(self):
        self.connected = False
        self.data = {}

    def connect(self):
        self.connected = True

    def disconnect(self):
        self.connected = False

    def store(self, key, value):
        if not self.connected:
            raise RuntimeError("Not connected")
        self.data[key] = value

    def retrieve(self, key):
        if not self.connected:
            raise RuntimeError("Not connected")
        return self.data.get(key)

@pytest.fixture
def db():
    database = Database()
    database.connect()
    yield database  # code after yield runs as teardown
    database.disconnect()

def test_store_and_retrieve(db):
    db.store("name", "Alice")
    assert db.retrieve("name") == "Alice"

def test_store_overwrites(db):
    db.store("age", 25)
    db.store("age", 26)
    assert db.retrieve("age") == 26

def test_retrieve_nonexistent(db):
    assert db.retrieve("missing") is None

Output:

$ pytest test_database.py -v
======================== test session starts ==========================
test_database.py::test_store_and_retrieve PASSED               [ 33%]
test_database.py::test_store_overwrites PASSED                 [ 66%]
test_database.py::test_retrieve_nonexistent PASSED             [100%]

======================== 3 passed in 0.01s ===========================

The fixture uses yield instead of return. Code before yield runs before the test (setup), code after yield runs after (teardown). Each test gets a fresh database connection, so tests never interfere with each other.

Parametrized Tests

Parametrization runs the same test with different input values — DRY in action:

# test_parametrize.py
import pytest

def is_even(num):
    return num % 2 == 0

@pytest.mark.parametrize("number,expected", [
    (2, True),
    (4, True),
    (1, False),
    (3, False),
    (0, True),
    (-2, True),
])
def test_is_even(number, expected):
    assert is_even(number) == expected

Output:

$ pytest test_parametrize.py -v
======================== test session starts ==========================
test_parametrize.py::test_is_even[2-True] PASSED              [ 16%]
test_parametrize.py::test_is_even[4-True] PASSED              [ 33%]
test_parametrize.py::test_is_even[1-False] PASSED             [ 50%]
test_parametrize.py::test_is_even[3-False] PASSED             [ 66%]
test_parametrize.py::test_is_even[0-True] PASSED              [ 83%]
test_parametrize.py::test_is_even[-2-True] PASSED             [100%]

======================== 6 passed in 0.01s ===========================

pytest creates one test per parameter set and labels each one, making it easy to identify which specific input caused a failure.

Parametrized testing with multiple test cases in pytest
Six test cases, one function. @pytest.mark.parametrize does the heavy lifting.

Testing Exceptions

Sometimes correct behavior means raising an exception. Use pytest.raises to verify exceptions:

# test_exceptions.py
import pytest

def validate_age(age):
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age must be realistic")
    return True

def test_valid_age():
    assert validate_age(25) is True

def test_negative_age():
    with pytest.raises(ValueError, match="cannot be negative"):
        validate_age(-5)

def test_invalid_type():
    with pytest.raises(TypeError, match="must be an integer"):
        validate_age("twenty-five")

Output:

$ pytest test_exceptions.py -v
======================== test session starts ==========================
test_exceptions.py::test_valid_age PASSED                      [ 33%]
test_exceptions.py::test_negative_age PASSED                   [ 66%]
test_exceptions.py::test_invalid_type PASSED                   [100%]

======================== 3 passed in 0.01s ===========================

The match parameter verifies the exception message using regex, ensuring not just the right type but the right message is raised.

Mocking Basics

Mocking replaces real dependencies with controlled fakes so you can test in isolation:

# test_mocking.py
from unittest.mock import Mock, patch

def fetch_user(user_id):
    import requests
    response = requests.get(f"https://jsonplaceholder.typicode.com/users/{user_id}")
    return response.json()

def test_fetch_user_with_mock():
    with patch("requests.get") as mock_get:
        mock_response = Mock()
        mock_response.json.return_value = {"id": 1, "name": "Alice"}
        mock_get.return_value = mock_response

        result = fetch_user(1)

        assert result["name"] == "Alice"
        mock_get.assert_called_once_with(
            "https://jsonplaceholder.typicode.com/users/1"
        )

Output:

$ pytest test_mocking.py -v
======================== test session starts ==========================
test_mocking.py::test_fetch_user_with_mock PASSED             [100%]

======================== 1 passed in 0.01s ===========================

The patch context manager replaces the real requests.get with a mock. The mock tracks how it was called and what it returns, letting you test API-dependent code without network requests.

Mocking dependencies in Python unit tests
The API is down. The tests still pass. Thank unittest.mock.

Real-Life Example: Testing a Shopping Cart

Here is a complete shopping cart with comprehensive tests demonstrating fixtures, parametrization, and exception testing together:

# shopping_cart.py
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, product, quantity=1):
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        self.items.append({"product": product, "quantity": quantity})

    def remove_item(self, product_name):
        self.items = [i for i in self.items if i["product"].name != product_name]

    def get_total(self):
        return sum(i["product"].price * i["quantity"] for i in self.items)

    def apply_discount(self, percent):
        if percent < 0 or percent > 100:
            raise ValueError("Discount must be between 0 and 100")
        return self.get_total() * (1 - percent / 100)

    def is_empty(self):
        return len(self.items) == 0
# test_shopping_cart.py
import pytest
from shopping_cart import Product, ShoppingCart

@pytest.fixture
def cart():
    return ShoppingCart()

@pytest.fixture
def laptop():
    return Product("Laptop", 999.99)

@pytest.fixture
def mouse():
    return Product("Mouse", 29.99)

def test_cart_starts_empty(cart):
    assert cart.is_empty()
    assert cart.get_total() == 0

def test_add_single_item(cart, laptop):
    cart.add_item(laptop)
    assert not cart.is_empty()
    assert cart.get_total() == 999.99

def test_add_multiple_items(cart, laptop, mouse):
    cart.add_item(laptop)
    cart.add_item(mouse)
    assert cart.get_total() == 1029.98

@pytest.mark.parametrize("quantity", [0, -1, -10])
def test_invalid_quantity(cart, laptop, quantity):
    with pytest.raises(ValueError, match="must be positive"):
        cart.add_item(laptop, quantity=quantity)

def test_remove_item(cart, laptop, mouse):
    cart.add_item(laptop)
    cart.add_item(mouse)
    cart.remove_item("Mouse")
    assert cart.get_total() == 999.99

@pytest.mark.parametrize("discount,expected", [
    (10, 900), (50, 500), (100, 0),
])
def test_apply_discount(cart, discount, expected):
    cart.add_item(Product("Item", 1000))
    assert cart.apply_discount(discount) == pytest.approx(expected)

Output:

$ pytest test_shopping_cart.py -v
======================== test session starts ==========================
test_shopping_cart.py::test_cart_starts_empty PASSED           [  8%]
test_shopping_cart.py::test_add_single_item PASSED             [ 16%]
test_shopping_cart.py::test_add_multiple_items PASSED          [ 25%]
test_shopping_cart.py::test_invalid_quantity[0] PASSED         [ 33%]
test_shopping_cart.py::test_invalid_quantity[-1] PASSED        [ 41%]
test_shopping_cart.py::test_invalid_quantity[-10] PASSED       [ 50%]
test_shopping_cart.py::test_remove_item PASSED                 [ 58%]
test_shopping_cart.py::test_apply_discount[10-900] PASSED     [ 66%]
test_shopping_cart.py::test_apply_discount[50-500] PASSED     [ 75%]
test_shopping_cart.py::test_apply_discount[100-0] PASSED      [ 83%]

======================== 10 passed in 0.02s ===========================

This example ties together everything: fixtures for reusable setup, parametrization for multiple cases, and exception testing for error handling. Notice we test both happy paths and error paths.

Frequently Asked Questions

How do I run a single test file?

Use pytest followed by the filename: pytest test_calculator.py. To run a specific function: pytest test_calculator.py::test_multiply_basic.

How do I run tests matching a pattern?

Use the -k flag: pytest -k "multiply". This runs all tests with “multiply” in their name.

What does the -v flag do?

The -v (verbose) flag shows each test individually. Use -vv for even more detail including assertion introspection.

Can I stop on the first failure?

Yes, use pytest -x. This stops as soon as one test fails, useful for quick feedback during development.

How do I see print output from my tests?

By default pytest captures print statements. Use pytest -s to show all output during test execution.

What is the difference between a fixture and a helper function?

Fixtures are managed by pytest and support setup/teardown via yield. Helper functions are regular Python functions. Use fixtures for shared setup, helpers for reusable test logic.

How do I test async functions?

Install pytest-asyncio (pip install pytest-asyncio), then mark tests with @pytest.mark.asyncio and use async def.

Conclusion

You now have a solid foundation in pytest. You understand how to write tests with assertions, use fixtures for setup and teardown, parametrize tests for multiple cases, verify exceptions are raised correctly, and mock external dependencies. More importantly, you understand that testing does not have to be complicated.

Start by writing tests for new code, then gradually add tests to existing code. For more advanced topics, visit the official pytest documentation at https://docs.pytest.org/.