You're writing tests for a function. The logic is the same, but you need to check multiple inputs. Without parametrization, you end up with repetitive code:

def test_add_positive_numbers():
    assert add(2, 3) == 5
 
def test_add_negative_numbers():
    assert add(-1, -1) == -2
 
def test_add_mixed_numbers():
    assert add(-1, 5) == 4

Three functions that do essentially the same thing. There's a better way.

Basic Parametrization

The @pytest.mark.parametrize decorator runs your test multiple times with different arguments:

import pytest
 
@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, -1, -2),
    (-1, 5, 4),
    (0, 0, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

One test function, four test cases. The output shows each case separately:

test_math.py::test_add[2-3-5] PASSED
test_math.py::test_add[-1--1--2] PASSED
test_math.py::test_add[-1-5-4] PASSED
test_math.py::test_add[0-0-0] PASSED

Naming Test Cases

Those auto-generated names aren't great. Use pytest.param with id for clarity:

@pytest.mark.parametrize("input,expected", [
    pytest.param("hello", "HELLO", id="lowercase"),
    pytest.param("WORLD", "WORLD", id="already_upper"),
    pytest.param("MiXeD", "MIXED", id="mixed_case"),
    pytest.param("", "", id="empty_string"),
])
def test_uppercase(input, expected):
    assert input.upper() == expected

Now the output reads naturally:

test_strings.py::test_uppercase[lowercase] PASSED
test_strings.py::test_uppercase[already_upper] PASSED
test_strings.py::test_uppercase[mixed_case] PASSED
test_strings.py::test_uppercase[empty_string] PASSED

Multiple Parametrize Decorators

Stack decorators to test all combinations:

@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    result = x * y
    assert result == x * y

This generates four tests: (1,10), (1,20), (2,10), (2,20). Useful for testing interactions between independent parameters.

Testing Edge Cases and Errors

Parametrization shines for edge case testing:

@pytest.mark.parametrize("value,expected", [
    pytest.param(None, False, id="none"),
    pytest.param("", False, id="empty_string"),
    pytest.param("  ", False, id="whitespace"),
    pytest.param("hello", True, id="valid_string"),
    pytest.param(0, False, id="zero"),
    pytest.param(1, True, id="positive_int"),
    pytest.param([], False, id="empty_list"),
    pytest.param([1], True, id="non_empty_list"),
])
def test_is_truthy(value, expected):
    assert bool(value) == expected

For testing exceptions:

@pytest.mark.parametrize("value,error", [
    pytest.param("not_a_number", ValueError, id="invalid_string"),
    pytest.param(None, TypeError, id="none_value"),
    pytest.param(float("inf"), OverflowError, id="infinity"),
])
def test_parse_int_errors(value, error):
    with pytest.raises(error):
        parse_strict_int(value)

Parameterizing Fixtures

Combine parametrization with fixtures for powerful patterns:

@pytest.fixture
def database(request):
    db_type = request.param
    if db_type == "sqlite":
        return SqliteDatabase(":memory:")
    elif db_type == "postgres":
        return PostgresDatabase("test_db")
 
@pytest.mark.parametrize("database", ["sqlite", "postgres"], indirect=True)
def test_insert(database):
    database.insert({"key": "value"})
    assert database.get("key") == "value"

The indirect=True tells pytest to pass the parameter to the fixture, not directly to the test.

Real-World Example: API Testing

Testing API endpoints with various inputs:

@pytest.mark.parametrize("payload,status_code,error_field", [
    pytest.param(
        {"email": "valid@example.com", "password": "secure123"},
        200,
        None,
        id="valid_registration"
    ),
    pytest.param(
        {"email": "invalid-email", "password": "secure123"},
        400,
        "email",
        id="invalid_email_format"
    ),
    pytest.param(
        {"email": "valid@example.com", "password": "123"},
        400,
        "password",
        id="password_too_short"
    ),
    pytest.param(
        {"email": "", "password": "secure123"},
        400,
        "email",
        id="missing_email"
    ),
])
def test_register_user(client, payload, status_code, error_field):
    response = client.post("/api/register", json=payload)
    assert response.status_code == status_code
    
    if error_field:
        assert error_field in response.json["errors"]

Combining with Markers

Mark specific parameter combinations:

@pytest.mark.parametrize("n,expected", [
    (1, 1),
    (10, 55),
    pytest.param(50, 12586269025, marks=pytest.mark.slow),
    pytest.param(100, 354224848179261915075, marks=pytest.mark.slow),
])
def test_fibonacci(n, expected):
    assert fibonacci(n) == expected

Skip slow tests in quick runs with pytest -m "not slow".

Loading Test Data from Files

For extensive test cases, load from external files:

import json
from pathlib import Path
 
def load_test_cases():
    data = Path("tests/data/validation_cases.json").read_text()
    cases = json.loads(data)
    return [
        pytest.param(case["input"], case["expected"], id=case["name"])
        for case in cases
    ]
 
@pytest.mark.parametrize("input,expected", load_test_cases())
def test_validate(input, expected):
    assert validate(input) == expected

Best Practices

  1. Use descriptive IDs: id="empty_list" beats id="case_7"
  2. Group related cases: Keep parameters for one logical feature together
  3. Don't over-parametrize: If cases need different assertions, write separate tests
  4. Consider readability: A simple list of tuples works for obvious cases; use pytest.param for complex ones

Parametrization eliminates copy-paste testing. You define the pattern once and let pytest run it against all your cases. When a bug appears, adding a regression test is just one more tuple in the list.

React to this post: