diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py
index cfcc74b2..f7e72e47 100644
--- a/src/pytest_html/basereport.py
+++ b/src/pytest_html/basereport.py
@@ -209,6 +209,13 @@ def pytest_collection_finish(self, session):
@pytest.hookimpl(trylast=True)
def pytest_runtest_logreport(self, report):
+ # pytest 9+ subtests emit a separate report for each subtest. Those
+ # reports share the parent test's nodeid but have their own outcome,
+ # so storing them would create duplicate rows. The parent test's own
+ # "call" report already contains the subtest details, so skip them.
+ if getattr(report, "context", None) is not None:
+ return
+
if hasattr(report, "duration_formatter"):
warnings.warn(
"'duration_formatter' has been removed and no longer has any effect!"
diff --git a/testing/test_unit.py b/testing/test_unit.py
index 1ea945ea..0558e137 100644
--- a/testing/test_unit.py
+++ b/testing/test_unit.py
@@ -1,4 +1,7 @@
+import html
import importlib.resources
+import json
+import re
import sys
from pathlib import Path
@@ -146,3 +149,28 @@ def test_custom_css_selfcontained(pytester, css_file_path, expandvar):
with open(pytester.path / "report.html") as f:
html = f.read()
assert_that(html).contains("* " + str(css_file_path)).contains("* two.css")
+
+
+def test_subtests_single_row(pytester):
+ """A test with mixed subtest outcomes must produce one report row."""
+ pytest.importorskip("pytest", minversion="9.0")
+ pytester.makepyfile(
+ test_mixed="""
+ from pytest import Subtests
+
+ def test_mixed(subtests: Subtests):
+ for i in range(6):
+ with subtests.test(i):
+ assert i % 2 == 0
+ """
+ )
+ result = run(pytester)
+ assert result.ret == 1
+
+ report = pytester.path.joinpath("report.html").read_text()
+ data = json.loads(
+ html.unescape(re.search(r'data-jsonblob="(.*?)"', report, re.S).group(1))
+ )
+ rows = data["tests"]["test_mixed.py::test_mixed"]
+ assert_that(rows).is_length(1)
+ assert_that(rows[0]["result"]).is_equal_to("Failed")