pytest.mark.parametrize runs one test function with different arguments. Every combination becomes an independent case, which improves failure diagnosis and removes duplicated setup and assertions.

Give important cases readable names

import pytest


@pytest.mark.parametrize(
    ("text", "expected"),
    [
        pytest.param("  Python ", "python", id="space-and-case"),
        pytest.param("", "", id="empty"),
        pytest.param("API", "api", id="acronym"),
    ],
)
def test_normalize(text: str, expected: str) -> None:
    assert text.strip().lower() == expected

Short IDs keep reports readable. Do not expose secrets or enormous objects in an ID. For exceptions, parametrize invalid inputs and use pytest.raises inside the test rather than forcing unrelated flows into one function merely to reduce lines.

Stacking two decorators creates a Cartesian product. That is useful for compatibility across independent dimensions, but it can multiply cases without adding confidence. List combinations explicitly when only a few matter.

Parametrization supplies data; fixtures manage setup, dependencies, and cleanup. The guide to pytest fixtures, mocks, and monkeypatch separates those responsibilities. Avoid mutable global state so one case cannot contaminate the next.

The official pytest parametrization documentation, accessed July 22, 2026, covers parameters for tests, fixtures, modules, and dynamic generation.

Start from a behavior, not a data dump

A good parameter set describes one rule through representative examples. Begin with a straightforward success, boundaries, and a failure that previously caused a bug. Hundreds of arbitrary values make the report longer without necessarily testing a meaningful property.

def shipping_cost(total: int) -> int:
    if total < 0:
        raise ValueError("total must be non-negative")
    return 0 if total >= 100 else 12


@pytest.mark.parametrize(
    ("total", "expected"),
    [
        pytest.param(0, 12, id="empty-order"),
        pytest.param(99, 12, id="below-threshold"),
        pytest.param(100, 0, id="at-threshold"),
        pytest.param(101, 0, id="above-threshold"),
    ],
)
def test_shipping_cost(total: int, expected: int) -> None:
    assert shipping_cost(total) == expected

IDs should explain why the case exists. Stable domain names such as at-threshold are more useful than case-3. pytest can generate IDs automatically, but explicit IDs are worthwhile when values are opaque, long, or likely to change.

Test exceptions without hiding the assertion

Keep valid and invalid flows in separate tests when their assertions differ substantially. This makes the contract visible and prevents conditional logic inside a test.

@pytest.mark.parametrize("total", [-1, -50], ids=["minus-one", "minus-fifty"])
def test_shipping_cost_rejects_negative_total(total: int) -> None:
    with pytest.raises(ValueError, match="non-negative"):
        shipping_cost(total)

Checking the exception type is the minimum. Match a stable, meaningful part of the message only when that message is part of the interface. Avoid asserting the entire traceback or punctuation that users do not depend on.

Combine fixtures and parameters deliberately

Parameters describe variations in input or expectation. Fixtures provide dependencies, setup, and cleanup. They can appear together in the function signature:

@pytest.mark.parametrize("role", ["reader", "editor"])
def test_profile_is_visible(client, user_factory, role: str) -> None:
    user = user_factory(role=role)
    response = client.get(f"/users/{user.id}")
    assert response.status_code == 200

If a parameter must be interpreted by a fixture, use indirect parametrization. This is useful for configuration variants or resources whose creation belongs in a fixture.

@pytest.fixture
def database(request):
    return connect_for_test(engine=request.param)


@pytest.mark.parametrize("database", ["sqlite", "postgres"], indirect=True)
def test_repository_round_trip(database) -> None:
    repository = Repository(database)
    repository.save({"id": 1, "name": "Ada"})
    assert repository.get(1)["name"] == "Ada"

Use indirect parameters sparingly because the value changes meaning across two locations. A direct fixture or fixture factory is clearer when only one test needs the setup.

Understand stacked decorators

Stacked decorators generate every combination. Two browsers and three locales produce six tests:

@pytest.mark.parametrize("locale", ["en", "es", "pt"])
@pytest.mark.parametrize("browser", ["chromium", "firefox"])
def test_homepage(browser: str, locale: str) -> None:
    ...

This is appropriate when the dimensions are independent and every combination is supported. If only production combinations matter, list tuples explicitly. A smaller purposeful matrix is faster and easier to interpret than a theoretical Cartesian product.

Mark individual cases

pytest.param can attach marks to one case. Use xfail for a documented known limitation and skip only when the test cannot run in that environment.

@pytest.mark.parametrize(
    ("value", "expected"),
    [
        ("10", 10),
        pytest.param(
            "١٠",
            10,
            marks=pytest.mark.xfail(reason="Arabic digits not supported yet"),
            id="arabic-digits",
        ),
    ],
)
def test_parse_number(value: str, expected: int) -> None:
    assert parse_number(value) == expected

Do not use xfail as a permanent way to ignore an unexplained failure. Link the limitation to tracked work, keep the reason specific, and consider strict mode so an unexpected pass is noticed.

Generate cases only when collection needs it

Module or class level pytestmark can parametrize a group, while the pytest_generate_tests hook can build cases during collection. Dynamic generation is justified when supported implementations are discovered from a registry or command line option. For a short fixed list, the decorator is easier to read.

Never fetch remote data during collection. Tests become slow and unreliable before execution starts. Store stable contract cases in the repository and reserve integration checks for an explicit suite.

Avoid mutable shared values

pytest passes parameter values as they are. If a test mutates a list or dictionary, a later case can observe that mutation. Prefer immutable values or create a fresh copy:

@pytest.mark.parametrize("payload", [{"items": []}, {"items": ["book"]}])
def test_add_item(payload: dict[str, list[str]]) -> None:
    local_payload = {"items": payload["items"].copy()}
    local_payload["items"].append("pen")
    assert "pen" in local_payload["items"]

The same isolation rule applies to database rows, environment variables, clocks, and caches. Parametrization creates separate test items, but it does not automatically undo external state.

Run and diagnose selected cases

Use pytest -vv to see complete case IDs and pytest -k threshold to select cases by their generated node names. pytest --collect-only shows the matrix without running it, which is valuable after stacking decorators or adding dynamic parameters.

When a case fails, its ID should point to the business boundary. The assertion should then show actual and expected values. Adding prints to every case is rarely necessary because pytest already provides rich assertion introspection.

Review checklist

Before merging a parametrized test, ask whether each row represents a distinct rule or risk, whether IDs are stable and safe, and whether failure of one row remains independent. Check that the matrix is not accidentally Cartesian, invalid cases assert the correct exception, and fixtures retain responsibility for cleanup.

Parametrization reduces repetition when the behavior and assertion stay the same. If every row requires branches, different mocks, and unrelated assertions, split the test. Clear duplication is sometimes cheaper than a compact table that conceals several behaviors.

Choose boundary cases systematically

For numeric ranges, include values immediately below, at, and above the boundary. For text, consider empty input, surrounding whitespace, Unicode, and the documented maximum length. For collections, distinguish an empty collection from one item and several items. These are prompts, not a requirement to add every possibility to every function.

Start from the public contract and known failure modes. If an input cannot reach the function because validation happens earlier, test it at the validation boundary. Repeating impossible values in deeper unit tests creates noise and can lock tests to implementation details.

Pairwise tools can reduce a large compatibility matrix, while property based tests explore broad input spaces and shrink failures. parametrize remains the better fit for named examples that reviewers should understand individually. The approaches complement each other.

Keep case data readable

If a row becomes a long tuple of booleans and expected fragments, introduce a small immutable case object:

from dataclasses import dataclass


@dataclass(frozen=True)
class PriceCase:
    name: str
    subtotal: int
    member: bool
    expected: int


CASES = [
    PriceCase("regular", 100, False, 100),
    PriceCase("member-discount", 100, True, 90),
]


@pytest.mark.parametrize("case", CASES, ids=lambda case: case.name)
def test_final_price(case: PriceCase) -> None:
    assert final_price(case.subtotal, case.member) == case.expected

This structure gives fields names and catches accidental ordering mistakes. Keep it near the tests unless several modules genuinely share the same contract data. A global catalog of cases can become another fixture system that is difficult to navigate.

Avoid putting secrets, production customer records, or licensed datasets into parameters. Replace them with minimal synthetic examples that preserve the relevant shape. Test reports and CI artifacts commonly expose parameter representations.

Preserve useful failure isolation

One advantage of parametrization is that pytest can continue after a case fails. That benefit disappears if cases write to the same file, consume a shared iterator, or depend on execution order. Give each case a temporary directory through tmp_path, fresh objects through fixtures, and independent database state.

Do not assume the decorator order is an execution order contract. Tests should pass with random ordering and parallel execution when the project supports it. If the scenario truly requires a sequence of steps, it is one workflow test, not several parameter cases.

Finally, keep the test name focused on the behavior. The parameter ID adds the scenario. A report such as test_shipping_cost[at-threshold] is concise, searchable, and tells a maintainer both what failed and under which condition.