pytestHTML Reporter
Home Docs Running at scale
Guides

Running at scale

A report folder has exactly one writer per build. Everything on this page follows from that one rule — why a parallel run needs no configuration, why a matrix needs a merge step, and why the merge is its own command rather than a fifth pytest run.

One run, one build

A build is not only an HTML file. Writing one means rotating the previous output.json into archive/, writing a new output.json, appending a point to the trend chart, applying archive retention, recomputing Suite Highlights, and re-reading every retained build to rebuild the Analytics histories.

So four matrix legs each writing a report into one folder do not add up to a build. They overwrite each other's output.json and manufacture four builds out of one run: four trend points, four archive files, four Analytics entries, and Suite Highlights counting every suite four times — with nothing left on disk afterwards to say which four belonged together.

There are two ways a run can be spread out, and they get two different answers.

Diagram: assets/img/shots/sharded-run-one-build.png A diagram, not a screenshot, at 1440px wide. Three boxes labelled "shard 1/3", "2/3", "3/3", each writing a file icon labelled shards/<id>/records.json + pytest_screenshots/; three arrows into a box labelled "CI artifact store"; one arrow out of it into a box labelled "pytest-html-reporter merge"; and out of that exactly one of each: pytest_html_report.html, output.json, archive/output_<ts>.json, junit.xml, one trend point. Caption underneath: "one run, one build".

Why the merge works on records and nothing else

Two simpler designs were rejected in both directions, and the reasons are what decide the file formats.

So the merge happens at the level of records — the dicts the plugin builds as each test finishes — and nowhere else.

TipIf your pipeline is GitHub Actions and you want the report published to the job summary, a pull request comment, an artifact and GitHub Pages, the dedicated GitHub Action does all of that in one step. This page is the underlying machinery, and the two compose.

What the report works out about the run

New in 0.4.2The Environment panel fills itself in. The CI system, a Pipeline link back to the build, the branch and the commit under test, the operating system, the interpreter and the worker count all arrive without a flag.

A report is a build artifact: it is read a week later, by somebody who cannot re-run it and cannot ask the machine anything. The panel could already name the host, the Python and the pytest — the easy half — while the half that explains a red build was left to whoever remembered to write a --build-info flag before the run.

The rows, and where each comes from

RowWhat it says
CIThe CI system, named from its own variables, with the build number where one is published.
PipelineA link straight back to the build that produced the report.
Branch, CommitThe revision under test, from the CI system where it publishes them and from git otherwise.
HostThe machine that ran the tests.
PlatformThe operating system as its own users name it — Ubuntu 22.04.4 LTS · Linux 5.15.0 (x86_64), macOS 15.6 (arm64) — rather than the kernel string.
PythonVersion, implementation and word size: 3.11.7 (CPython, 64-bit).
InterpreterThe python that actually ran — the row that ends an argument about which virtualenv was active.
pytest, PluginsThe framework and every plugin version active for the run.
WorkersHow many xdist workers reported results. Only on a parallel run.
PackagesEvery installed distribution and its version, when --report-packages asked for it.
Arguments, RootThe command line the run was started with, and where it ran.

The systems detected by name

GitHub Actions, GitLab CI, CircleCI, Buildkite, Azure Pipelines, Travis CI, AppVeyor, Drone, Bitbucket Pipelines, Semaphore, AWS CodeBuild, TeamCity and Jenkins are each named individually, from the variable that actually identifies them — GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, TF_BUILD, JENKINS_URL and so on. Jenkins is tested last on purpose: BUILD_NUMBER and BUILD_URL are set by several of the others, and JENKINS_URL is what actually says Jenkins. Anything else that sets CI or CONTINUOUS_INTEGRATION is still recorded as a build agent rather than passed off as somebody's laptop.

NoteOn a sharded run every one of these is collected by the leg, on the machine that ran the tests. A merging job that asked its own environment which CI run this was would answer with the merge job, and one running on a laptop would answer with nothing at all. The merged panel says it once when the legs agree and says both when they do not — two legs naming two pipelines link neither, since a link to the first would be read as the whole build's. Bundles written before 0.4.2 merge unchanged: every new key reads back as "this leg never said".

The CI shape of a run, written down

New in 0.4.3Everything on this page that reads as a long command line can be a named profile instead — the workflow file then says --report-profile=ci, and what ci means is committed beside the tests rather than kept in YAML.

A workflow file is a bad home for a report's settings. It is edited by whoever is fixing the pipeline, it is not what anybody runs locally, and the day the two drift apart is the day somebody opens a CI report and finds it missing the logs they went there for. A profile puts both shapes in one file, side by side, where the difference between them is the thing you can see:

toml Committed beside the tests
[tool.pytest-html-reporter]
title = "PAYMENTS"

[tool.pytest-html-reporter.profiles.local]
open = "auto"
logs = "all"
archive_count = 10

[tool.pytest-html-reporter.profiles.ci]
open = "none"
logs = "failed"
attachments = "failed"
screenshots = "failed"
junit = "report/junit.xml"
archive_days = 30

The pieces a workflow legitimately owns — the branch, the commit, the run id — stay on the command line, because the shell has to expand them and because they are facts about this build rather than about the shape of it. They add to what the profile says rather than replacing it, and a label named twice is one row carrying the higher layer's answer.

A matrix leg that needs one thing different does not need a profile of its own: PYTEST_HTML_REPORTER_LOGS=all in that job's environment overrides the profile for that job alone. And when a leg's report comes out wrong, pytest --report-profile=ci --report-show-config prints what it resolved and which layer decided each setting, into the pytest header — which is the part of a CI log people paste into issues. See pytest-html-reporter config for the same answer without running the suite.

NoteA sharded matrix takes profiles the way any other run does. Every leg is a pytest run, so a leg names the profile like anything else — and because a profile is written onto the same options the flags parse into, the shard settings a leg resolves are the ones its bundle is filed under. The merge is not a pytest run and reads no profile: its flags are its own.

Parallel runs with pytest-xdist

Runs distributed with pytest-xdist are gathered into a single report with no configuration at all — one build in Archives, one set of totals, one row per test, whichever way the tests were distributed.

shell Both distribution modes produce one report
$ pytest tests/ -n 2 --html-report=./report
$ pytest tests/ -n auto --dist loadfile --html-report=./report

How the workers hand their results back

  1. Every worker collects the whole suite, and the plugin records each node id's collection index. A test therefore sits at the same position in every process.
  2. Each worker builds ordinary record dicts as its tests finish. Records are made only of JSON-safe built-ins on purpose — that is what lets them cross a process boundary.
  3. At the end of the session a worker writes its record list into config.workeroutput and renders nothing.
  4. On the controller, each finished worker's output is read back and stored as it goes down.
  5. The terminal-summary hook returns early on a worker. Only the controller reaches the render.
  6. The report is built from records sorted by collection index and worker, so a parallel report reads exactly like a serial one whatever order the workers finished in.

Screenshots are written by whichever process ran the test — the workers share the controller's filesystem — into pytest_screenshots/ under the report base. An image is named <milliseconds>[-<worker id>]-<counter>: milliseconds alone collided because two tests can finish inside the same one, the counter settles that, and the worker id settles the cross-process case.

NoteResults are handed over when a worker finishes, so tests from a worker that crashes outright — rather than failing — are not in the report. pytest reports the crash itself.

Two more consequences worth knowing. The controller never runs a test, so run-wide totals cannot be accumulated as tests go by; they are counted off the merged record list at the end. And an -n 8 run that is also one leg of a matrix writes its bundle once, from the controller, after it has merged all eight workers' records — never eight times with an eighth of the tests each.

shell A shard that is itself parallel — one bundle, written by the controller
$ pytest tests/ -n 8 --html-report=./report --report-shard=1/4

Reruns: how retries are represented

pytest-rerunfailures runs the whole setup/call/teardown protocol again for every retry, so a retried test arrives at the reporter once per attempt. The plugin keeps one record per test and uses the rerun field to say how many attempts it stands for. There is no rerun-specific flag: the plugin reacts to pytest-rerunfailures being installed rather than configuring it.

Counting attempts is the only reliable signal available: --reruns, the ini key and @pytest.mark.flaky(reruns=n) can each set a different budget, and --only-rerun can stop the retries early, so no single number says how many attempts a given test will take.

The count then travels everywhere — into output.json as a per-test string and a per-suite total, into the JUnit <properties> as reruns and into each testcase's <system-out> as a reruns: N line, and into Analytics, where a non-zero rerun count is by itself enough to mark a test flaky. A retry inside a single build is the least ambiguous flake evidence there is: same code, same build, two different answers.

The attempt trail

New in 0.4.2The Rerun count is a button, and the panel behind it lists every attempt the test made — status, message, duration and, under -n, the worker that ran it — ending on the attempt the row itself is showing, marked kept.

One row carrying the outcome that stuck is the honest shape, but on its own it throws the interesting half away: the row shows the message of that attempt, and an attempt that stuck by passing has no message. A test that failed twice and then passed read PASS  2, and nothing anywhere in the report said what it had failed with — while two failures for two different reasons is a different bug report from the same failure twice.

Each attempt keeps four fields and not the record it came from: status, message, duration and worker. A shard bundle is the record list exactly as it stands in memory, so keeping whole attempts would multiply every bundle a matrix uploads by the number of times its flakiest tests were retried, and put a copy of each discarded attempt's logs and screenshots in it.

Under -n a worker sends back records it has already folded itself, so the controller keeps both sides' attempts and puts the record being replaced between them — it ran after its own attempts and before the one replacing it. The trail is parked outside the metrics table, so it is absent from the table's search index and from its CSV, Excel and print exports, and the Rerun column still exports the single number it always did.

A test that ran once has no trail and keeps the plain 0 the column has always shown, with the button disabled rather than only styled flat so it is not a tab stop either. So does a build archived before 0.4.2: the count was stored, the attempts behind it were not, and an empty panel would be worse than none.

CarefulOn a merge this folding is switched off entirely. A cross-shard duplicate must not be folded by rules that depend on what happens to be installed on the merging machine, and the fold pins the survivor to a per-process index that means nothing once several machines are involved. The merge has its own duplicate policy instead — which, under merge, builds a trail of its own, so a test retried twice on one shard that then ran again on another machine reports four attempts and shows four.

Sharding a run across machines

A shard writes no report: no pytest_html_report.html, no output.json, no archive rotation. It writes one file — its record list exactly as it stands in memory, plus everything only that machine knows about itself — and the screenshots those records name.

The four shard flags

FlagDefaultWhat it does
--report-shard "" Name this process as one leg of a sharded run. Writes its records to <report base>/shards/<id>/records.json and renders nothing. ini key: report_shard.
--report-shard-merge false After writing this leg's bundle, merge every bundle beside it under <report base>/shards and render one report. Needs --report-shard, or the run fails with a usage error. ini key: report_shard_merge.
--report-shard-run the CI system's own run id The token saying which CI run this leg belongs to. A merging leg merges only bundles carrying the same token, and names the ones it put aside. ini key: report_shard_run.
--report-shard-reset false Delete <report base>/shards entirely before this leg writes into it. For the first leg of a sequential run with no run token. Never implied by anything else, because it deletes the other legs' work. ini key: report_shard_reset.
shell Four legs of a matrix, each on its own machine
$ pytest tests/ --html-report=./report --report-shard=1/4
$ pytest tests/ --html-report=./report --report-shard=2/4
$ pytest tests/ --html-report=./report --report-shard=3/4
$ pytest tests/ --html-report=./report --report-shard=4/4

How a shard id becomes a directory

The shard is a directory under the report base, never a bare file beside the report. That is what makes merging in place safe: the merge clears <report base>/pytest_screenshots, while every source image sits under <report base>/shards/<id>/pytest_screenshots and is untouched.

Ids are sanitised into directory names. Everything outside A-Z a-z 0-9 . _ - becomes a dash, runs of dashes collapse, dots and dashes are stripped from both ends, and the result is capped at 64 characters.

You passThe directory is
1/41-4
python3.11 (ubuntu)python3.11-ubuntu
ubuntu 22.04ubuntu-22.04

The raw value is kept as the shard's label1/4 reads better than 1-4 in the report — while the sanitised value is the id, which names the directory and the screenshot folder.

CarefulA value made entirely of unsafe characters is a usage error rather than a silent fallback, because a shard with an empty id would write over the report base itself. Dots are trimmed from the ends for the same reason, so --report-shard=.. cannot resolve to the report base; a dot inside the id is left alone.

Before a leg writes, it removes its own directory outright — never the whole report folder, which would take a sibling leg's screenshots with it, and never only pytest_screenshots, because a records.json left by a previous run of the same leg would survive and, if that run collected more tests, would be what the merge reads. If two differently-named legs sanitise to the same directory, the second write prints a warning naming both labels; it is never fatal, because this runs after every test in the leg has finished.

What a leg leaves on disk

output After four legs have run — no output.json and no .html anywhere
report/
└── shards/
    ├── 1-4/
    │   ├── records.json
    │   └── pytest_screenshots/
    │       ├── 1788426639123-1.png
    │       └── 1788426639771-gw0-2.png
    ├── 2-4/
    │   ├── records.json
    │   └── pytest_screenshots/
    ├── 3-4/
    │   └── records.json
    └── 4-4/
        └── records.json
Don'tUpload only records.json. Upload the whole shards/<id>/ directory, bundle and pytest_screenshots/ together, or the merge will report every image as one the bundle named but did not carry.

The bundle: records.json

One file per leg, written atomically — to a temporary file in the same directory, flushed, fsynced, then replaced over the top. A bundle is written from the terminal-summary hook, which is the last thing a CI leg does before the job may be cancelled or the runner reclaimed, so what is on disk is either the previous file or the whole new one, never half of one.

The file is identified by its schema string, not by its name: anything whose schema is not exactly pytest-html-reporter/records is walked past with a note, because a folder of CI artifacts is full of other JSON. A bundle whose version is higher than the reader understands stops the merge by name — silently dropping a quarter of a matrix produces a report that is wrong in a way nobody looking at it can see. Unknown keys survive a round trip untouched, at both the payload and the record level.

json records.json — abridged
{
  "schema": "pytest-html-reporter/records",
  "version": 1,
  "generator": "pytest-html-reporter 0.4.3",
  "shard": { "id": "1-4", "label": "1/4", "assets": "pytest_screenshots" },
  "run": {
    "session_start": 1788426000.11,
    "session_end": 1788426724.53,
    "exitstatus": 1,
    "token": "github:1234567-2",
    "collected": 203,
    "hostname": "runner-1",
    "platform": "Linux 6.5.0-1024-azure",
    "python": "3.11.9",
    "pytest": "8.2.0",
    "plugins": ["cov-5.0.0", "html-reporter-0.4.3", "xdist-3.5.0"],
    "arguments": "tests/ -n 4 --html-report=./report --report-shard=1/4",
    "rootdir": "/home/runner/work/proj/proj",
    "environment": "staging",
    "build_info": [["Commit", "a1b2c3d"], ["Branch", "main"]],
    "xdist_workers": ["gw0", "gw1", "gw2", "gw3"]
  },
  "coverage": { "percent": 84.12, "statements": 5120, "covered": 4307 },
  "counts": { "records": 203, "collect": 1 },
  "records": [ { "nodeid": "tests/test_login.py::test_valid_user" } ]
}

The run block is the machine's own account of itself — host, platform, interpreter, pytest version, plugins, command line, rootdir, environment, build info, what it kept of each test's output — captured here because the merge often runs on a machine that ran none of the tests. counts is there for inspect and is never trusted by the merge, which counts the records it actually read.

Sequential legs on one machine

Unit, then integration, then e2e, on one machine, is the case where a fourth command to merge the three is a fourth thing to remember. The last leg passes --report-shard-merge, and three commands do the work of four.

shell Three sequential legs, one build
$ pytest tests/unit        --html-report=./report --report-shard=unit --report-shard-reset
$ pytest tests/integration --html-report=./report --report-shard=integration
$ pytest tests/e2e         --html-report=./report --report-shard=e2e --report-shard-merge

<base>/shards is persistent, so it also holds whatever the last run left in it. A leg that was renamed or deleted between two CI runs leaves its bundle sitting there, and the next merging leg picks it up and reports tests that did not run — four tests run and the build says six, with nothing on the page saying where the other two came from. A clock cannot tell the two cases apart, because every bundle beside a merging leg was written before it, whether ten minutes ago by this run or yesterday by the last one. There are two answers, for two situations:

ini The same options as ini keys
[pytest]
html_report = ./report
report_shard_run = nightly-42

A merging leg that cannot merge — a bundle from a newer release, a duplicate under an error policy — says so and carries on rather than raising. This runs after every test has finished, and a traceback there would leave the run with no report and no verdict. Its own bundle is on disk either way, and the message names the folder and tells you to run the merge command once it is sorted out.

Merging the shards

The merge is its own process, not another pytest run. A fifth pytest started in the report folder to do the merging would clean the screenshots on the way in and delete the images it was sent to collect. It is installed as a console script by setup.py and is also reachable as a module, which matters in containers that pip-installed a wheel, in tox environments and in a checkout somebody is trying the feature out in.

shell
$ pytest-html-reporter merge ./artifacts --html-report ./report

It uses argparse and the standard library only. The package's install requirements are pytest and Pillow, and a merge command that needed a dependency of its own would be a command CI could not run.

Three subcommands

merge

The whole of it: the report, and the JUnit XML when asked. --html-report is the only required flag.

junit

Stops after the XML, for a pipeline that publishes test results without publishing a report. No report base is written, so no screenshots are copied and no archive is rotated. Takes -o.

inspect

Stops before anything is written at all, which is what you want when four artifacts were downloaded and only three of them are there.

All three take the same positional PATH arguments — default, the current directory — and the same three shaping flags: --on-duplicate, --order and --strip-path-prefix. Those are shared deliberately, because they decide the answer rather than the output: inspect reporting a different set of tests from the merge that follows it would make it useless as a pre-flight check.

Paths may be directories, which are walked, or records.json files named directly, for a step that unpacked one artifact and knows where it put it. The walk is sorted at every level and duplicates are removed by absolute path. A folder of CI artifacts holds JUnit files, logs and a coverage report beside the bundles, and every one of them is walked past with a note rather than treated as a failure.

shell The shapes a merge call takes
# one folder holding the downloaded artifacts
$ pytest-html-reporter merge ./artifacts --html-report ./report

# report and JUnit XML in one pass
$ pytest-html-reporter merge ./artifacts --html-report ./report --junit-xml ./report/junit.xml

# merging back into the folder the shards were written in - safe by design
$ pytest-html-reporter merge ./report --html-report ./report

# bundles named directly
$ pytest-html-reporter merge ./a/records.json ./b/records.json --html-report ./report

# XML only, no report
$ pytest-html-reporter junit ./artifacts -o ./junit.xml

# pre-flight: what is there, and what merging it would come to
$ pytest-html-reporter inspect ./artifacts
$ pytest-html-reporter inspect ./artifacts --json

The flags a pipeline actually reaches for

The complete list of every merge flag is in the CLI reference. These are the ones that change the shape of a CI job.

FlagDefaultWhat it does
--html-reportrequiredWhere the merged report is written: a folder, or the .html file itself.
--junit-xml""Also write the merged JUnit XML here.
--on-duplicatemergeWhat to do when one node id ran in more than one shard. Every fold is reported whichever is chosen.
--ordershardRow order: shard keeps each suite's rows in shard order, name sorts by suite and test name.
--strip-path-prefixnoneStrip this prefix from the front of every node id, so shards that ran under different checkout roots group into one suite. Repeatable.
--start-timeearliestearliest, now, or a unix timestamp. Names the file the next build archives this one as, stamps output.json, labels the trend point and orders the builds in Analytics.
--strictoffExit 1 when anything was quarantined, unreadable, folded, superseded, missing or carried a status this version has never heard of, or when the JUnit writer warned. The report is still written.
--exit-codeoffExit 1 when the merged build has any FAIL or ERROR.
--dry-runoffMerge and print the summary, writing nothing.
--report-opennoneDeliberately not the pytest run's auto: a merge run on somebody's laptop to look at four downloaded artifacts must not steal a browser tab.
Note--html-report is refused up front if it contains .html without ending in it. The report path decides between "a folder" and "a file" on whether .html appears in it, so ./my.html.d would be read as a file name and the report would land somewhere else — and by the time anybody looks for it the run has already happened. The archive flags are validated before anything is written, in the same words a pytest run uses.

What the merge does, in order

  1. Discover and read

    Every records.json under the given paths, in a stable order, without repeats. A bundle carrying no shard id is given its own directory's name as a fallback — hand-assembled bundles and artifacts unpacked by a step that renamed the folder both turn up without one.

  2. Order the bundles

    Sorted by the natural key of the shard id, never by load order: runs of digits read as numbers, so 1-4, 2-4, 10-16 sort in that order. Two jobs downloading the same artifacts in different orders have to produce the same report, or the diff between two builds is unreadable. If two files claim one shard id, the one that finished later is kept and both paths are named in a note.

  3. Normalise every record

    Every key is filled in from defaults and every type coerced, which is the whole defence against a bundle written by another release. Coercion is tolerant rather than strict: a duration that arrives as the string "1.2" or as null becomes 0.0 rather than losing the row. Then the node id is normalised, the suite name recomputed from it, and the record tagged with the leg it came from.

  4. Deduplicate collectors, then tests

    On completely different grounds. Every process collects the whole suite, so a broken import is expected in all four bundles and is not a duplicate at all: one record is kept per collector, an ERROR supersedes a SKIP, and for the same status the longer message wins. Tests follow the duplicate policy.

  5. Rebase every index

    Four shards each number their tests from zero. Collect rows are renumbered ahead of every test, test rows densely and uniquely, so the report's own sort can never fall back to a worker name that means nothing across machines. Under --order shard a suite split across shards stays contiguous instead of appearing twice.

  6. Build the run metadata and the coverage

    One set of run facts assembled from every bundle's own account, never one machine's answer generalised to all of them; and coverage through five branches and no sixth.

  7. Render, once

    Through the same render path a plain pytest run drives. A merged build assembled by different code is a build that drifts away from every other one on the report's own history.

Duplicate node ids

A node id that ran in more than one shard means the matrix overlaps, and somebody has to be told. There is no single right answer — a matrix that overlaps by accident wants merge, a matrix that retries a whole leg wants last, a gate that must not be fooled wants error — so it is asked rather than guessed. Every fold is reported on stderr and counted in the summary whichever policy is in force.

--on-duplicateWhat survives
merge defaultThe last shard's record, with rerun set to the members' reruns plus one per dropped attempt, and empty screenshots, attachments and steps back-filled from the latest loser that has them. Never a concatenation.
firstThe first shard's record in the merge's deterministic order; the rest are dropped.
lastThe last shard's record. For a matrix that retries a whole leg.
worstThe most severe outcome: ERROR, FAIL, xPASS, SKIP, xFAIL, PASS. A status this version has never heard of scores below every real one, so an unreadable value cannot outrank a genuine failure. Ties break on the earlier shard.
errorNothing. The merge stops, listing every clashing node id at once and the shards each ran in.

Node identity across machines

Two machines can run the same test under different roots — a Windows runner's backslashes, a container that checked the repo out at /src and a runner that used /home/runner/work/proj/proj. The same test under two spellings is two rows, two histories in Analytics and two entries in the JUnit file.

Node ids are normalised in a fixed order: backslashes become forward slashes, repeated slashes collapse, a leading ./ is stripped, then each --strip-path-prefix value is removed from the front. A prefix that would swallow the whole node id is not applied — the original is worth more than nothing. When the node id changes, the suite name is recomputed from it, so grouping follows identity instead of drifting away from it.

shell Folding three checkout roots into one identity
$ pytest-html-reporter merge ./artifacts \
    --html-report ./report \
    --strip-path-prefix /home/runner/work/proj/proj/ \
    --strip-path-prefix C:/actions-runner/_work/proj/proj/ \
    --strip-path-prefix /src/

A record whose node id is empty after all this is quarantined: dropped from the merge, counted in the summary and named in a note. It cannot be grouped, sorted against a duplicate, or linked to. Dropped rather than fatal, because the merge runs after every test in the matrix has finished, which is the worst moment there is to raise.

Screenshots across shards

The merge copies every PNG the merged records name into the report's own folder — by the names the records hold, never by globbing the shard's folder. A shard folder can still be holding images from a previous build of the same leg, and sweeping the folder would carry them into this report as tests that did not run in it. The merge owns <report base>/pytest_screenshots and removes it outright first: one build comes out of one merge.

Each shard's images land in a folder of their own, because the screenshot counter restarts at 1 in every process — two machines' first screenshots are both <ms>-1, and one would overwrite the other in a shared folder. An image a bundle named but did not carry is dropped rather than left pointing at nothing, counted as a missing screenshot, and named in a note that says which file the bundle promised.

output The merged report folder, after merging in place
report/
├── pytest_html_report.html
├── output.json
├── archive/
│   ├── output_1788425529.352927.json
│   └── output_1788426203.302967.json
├── pytest_screenshots/          <- owned by the merge, wiped and restaged
│   ├── 1-4/
│   │   └── 1788426639123-1.png
│   ├── 2-4/
│   │   └── 1788426639123-1.png   (same name, different shard, no collision)
│   └── 3-4/
└── shards/                      <- untouched when merging in place
    ├── 1-4/
    ├── 2-4/
    └── 3-4/
NoteA test that failed and was photographed on shard 1 and then passed on shard 2 ends up as a shard-2 row holding shard-1 images, so screenshots carry their own shard tag separately from the row. Looking them up under the row's shard would drop the picture of the only failure in the matrix while it sat on disk in the shard next door.

Provenance, notes and exit codes

Provenance is printed on every merge, with no threshold anywhere near it. There is no honest way to decide from inside a merge that a bundle is stale — every bundle beside a merging leg was written before it — so nothing is guessed and the times are shown. That is what turns yesterday's leg from a silent extra two tests in the totals into a line in the log of the run that merged it. Times are local, because this is read beside a CI log whose own timestamps are on the same clock.

Notes go to stderr and the summary goes to stdout, so a pipeline that captures one can discard the other, and inspect --json stays a clean document. -q suppresses the notes but never the summary and never an error.

Screenshot: assets/img/shots/terminal-merge-summary.png A terminal at about 100 columns, dark theme, showing a real pytest-html-reporter merge ./artifacts --html-report ./report --junit-xml ./report/junit.xml run: the stderr notes interleaved with the stdout summary, including the per-shard provenance lines, one folded duplicate, and the final report and junit paths.
output The merge summary, on stdout
merged 4 shards: 812 tests
  shard 1-4: 203 tests, finished 2026-09-03 11:42:07
  shard 2-4: 204 tests, finished 2026-09-03 11:44:31
  shard 3-4: 203 tests, finished 2026-09-03 11:41:55
  shard 4-4: 202 tests, finished 2026-09-03 11:45:02
  PASS 780, FAIL 21, SKIP 11
  3 collection records
  duplicates folded: 2
  missing screenshots: 1
  bundles superseded by a newer copy: 1
  ran on runner-1, runner-2, runner-3, runner-4 over 12 mins 04 secs
  report: /home/runner/work/proj/proj/report/pytest_html_report.html
  junit:  /home/runner/work/proj/proj/report/junit.xml

There are two independent failure flags, because they answer two different questions. --strict is about the merge being complete; --exit-code is about the tests. A pipeline that wants the build to go red on a failing test uses one; a pipeline that only wants to know all four artifacts arrived uses the other. The report is written either way — a merge that exits 1 has still produced the page that explains why.

Exit codeMeans
0The merge produced what it was asked for, and neither --strict nor --exit-code had anything to say.
1A verdict about the tests or about the completeness of the merge — and it always comes with a report. Also returned when --junit-xml could not be written, since the report is already on disk by then.
2Nothing was produced at all: a bad flag, no bundles under the given paths, a bundle from a newer release, a merge error, or junit -o failing to write the one thing that subcommand exists to produce.

inspect, as a pre-flight gate

inspect answers "did all the artifacts arrive, and do they overlap". It writes nothing and always exits 0 unless it could not start at all, so a pipeline that wants to fail loudly on a missing shard reads the count out of --json.

output inspect, plain output — one line per bundle
1-4                     203 records    1 collect  runner-1         ./artifacts/shard-1/records.json
2-4                     204 records    0 collect  runner-2         ./artifacts/shard-2/records.json
3-4                     203 records    1 collect  runner-3         ./artifacts/shard-3/records.json
4-4                     202 records    0 collect  runner-4         ./artifacts/shard-4/records.json
Screenshot: assets/img/shots/terminal-inspect-missing-shard.png A terminal at about 110 columns, dark theme, running pytest-html-reporter inspect ./artifacts where only three of four shards are present — three bundle lines and a summary showing three shards — so the reader recognises the failure this step is meant to catch.

The JSON document carries the same information: one object per bundle, and a summary holding shards, tests, collects, statuses, reruns, duplicates_folded, quarantined, unreadable, superseded, unrecognised, the matrix's start, end and wall span, the distinct hosts, the agreed environment, any coverage notice, and every note the merge made in order.

shell Failing the pipeline unless exactly four shards arrived
$ pytest-html-reporter inspect ./artifacts --json > merge.json
$ test "$(python -c 'import json;print(json.load(open("merge.json"))["summary"]["shards"])')" = 4

A three-shard matrix, end to end

The shape of a sharded pipeline is always the same three moves: each leg writes a bundle, the bundles are uploaded as artifacts, one job downloads them all and merges once. Here it is with three legs, coverage, and the merged report published as an artifact.

Step one: the legs

yaml .github/workflows/tests.yml — the matrix job
jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install pytest-html-reporter pytest-xdist pytest-cov
      - run: |
          pytest tests/ -n 4 \
            --cov=myapp --cov-report= \
            --html-report=./report \
            --report-shard=${{ matrix.shard }}/3
      # keep this leg's coverage data beside its bundle
      - if: always()
        run: cp .coverage report/shards/${{ matrix.shard }}-3/.coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: shard-${{ matrix.shard }}
          path: report/shards/
          include-hidden-files: true

Three things about that job are worth saying out loud.

Don'tPass --report-junit on a plain shard. It is ignored, with a notice on stderr, on purpose: the alternative a CI author expects is three shard XMLs plus the merged one, and a **/*.xml glob that found all four would count every test in the matrix twice. Ask the merge for the XML instead.

Step two: what each leg uploads

output ./artifacts after actions/download-artifact@v4 with no name
artifacts/
├── shard-1/
│   └── 1-3/
│       ├── records.json
│       ├── .coverage
│       └── pytest_screenshots/
│           └── 1788426639123-1.png
├── shard-2/
│   └── 2-3/
│       ├── records.json
│       └── .coverage
└── shard-3/
    └── 3-3/
        ├── records.json
        └── .coverage

The nesting does not matter to the merge: the paths it is given are walked, so ./artifacts finds all three bundles wherever the download step put them. It matters to --coverage-data, which reads a named directory one level deep — every entry in it whose name starts with .coverage — and does not walk subdirectories. So the data files are gathered into one flat folder first.

Step three: the merge job

yaml .github/workflows/tests.yml — the merge job
  merge:
    needs: test
    if: always()
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install pytest-html-reporter coverage
      - uses: actions/download-artifact@v4
        with:
          path: artifacts

      - name: every shard arrived
        run: |
          pytest-html-reporter inspect ./artifacts --json > merge.json
          test "$(python -c 'import json;print(json.load(open("merge.json"))["summary"]["shards"])')" = 3

      - name: gather the coverage data files
        run: |
          mkdir -p covdata
          i=0
          for f in $(find artifacts -name '.coverage'); do
            i=$((i + 1))
            cp "$f" "covdata/.coverage.$i"
          done

      - name: merge
        run: |
          pytest-html-reporter merge ./artifacts \
            --html-report ./report \
            --junit-xml ./report/junit.xml \
            --coverage-data ./covdata \
            --coverage-target 80 \
            --title "NIGHTLY" \
            --environment staging \
            --build-info "Commit=${{ github.sha }}" \
            --build-info "Branch=${{ github.ref_name }}" \
            --strict

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: report
          path: report/

What comes out is one build: one pytest_html_report.html, one output.json, one archived build, one trend point, one junit.xml with exactly one <testcase> per node id, and one coverage figure combined from the three legs' data.

NoteThe merge job starts from an empty ./report, so there is no archive for it to rotate and the build has no history behind it. History needs the report folder to persist between builds — restore the previous folder from GitHub Pages, from a cache, or from wherever you publish it, and the merge will archive it and rebuild Analytics from what it finds.
Screenshot: assets/img/shots/report-environment-merged-shards.png The Environment panel of a merged report at 1440px wide, light theme, showing the Environment badge, the build-info rows, "Merged from: 3 shards", and one "Shard 1/3", "Shard 2/3", "Shard 3/3" row underneath, each naming a different hostname, platform, Python version and command line.

Every row in that panel is something a shard reported about itself, never the merging machine's answer — the merging machine ran no tests. Where the legs disagree the panel says so rather than picking: an Environment the shards do not agree on is left blank with a note listing the values, and captured-output settings that differ produce one row per leg instead of one summarised row that would be a lie. The run header shows the span of the matrix rather than the sum of the legs, so three shards that each took ten minutes in parallel are reported as ten minutes.

JUnit XML

There is no JUnit input path anywhere in this plugin. Nothing reads a JUnit XML — not the plugin, not the merge command. The writer is a pure function over the record dicts, which is what makes a live pytest run and a merge produce the same document from the same input.

Why it is built from records rather than merged from XMLs

Every merging tool available gets one of four things wrong, and building from records answers all four at once.

It is one <testsuite>, not one per shard: Azure's run-level timing across several suites is ambiguous and every other consumer flattens them anyway, so shard identity lives in the properties and in each testcase's captured output instead. Writing is atomic — a temporary file beside the target, then a rename — because a CI collector very often watches the directory it will read from, and half a document is a parse error attributed to the tests rather than to the reporting.

Writing it, from a run or from a merge

shell
$ pytest tests/ --html-report=./report --report-junit=./report/junit.xml
$ pytest tests/ --html-report=./report --report-junit=./report/junit.xml --report-junit-xpass=fail
FlagWhereDefaultWhat it does
--report-junitpytest""Write a JUnit XML of this run. strftime placeholders are expanded, as with --html-report. Ignored on a shard that is not also the merging leg. ini key: report_junit.
--report-junit-xpasspytestpassHow an xPASS is written down. An invalid value fails the run rather than falling back, because the whole point of setting it is that the team disagrees with the default. ini key: report_junit_xpass.
--report-link-patternpytest, mergenoneNames a marker that carries an id rather than a label. On top of turning it into a link in the html report, it puts the id in the XML as a <property> on the testcase. Repeatable; ini key: report_link_pattern.
--junit-xmlmerge""Also write the merged XML here.
-o, --outputjunitrequiredWhere the XML is written. This subcommand exists to produce it, so failing to write it is exit 2.
--junit-suite-namemerge, junitpytestThe <testsuite> name; the root element is named after it.
--junit-hostnamemerge, junitthe shards' hostUnset means the shards' single host, or the literal merged when they ran on more than one — never the machine doing the merging, which Azure would read as the agent name.
--junit-xpassmerge, junitpasspass, fail or skip.
--junit-loggingmerge, junitnono, all or failed — which tests carry their captured output. The spelling and the default are pytest's own junit_logging ini key.
--junit-attachmentsmerge, junitonWrite [[ATTACHMENT|path]] lines for screenshots — the one attachment convention GitLab and Azure both understand. --no-junit-attachments leaves them out.
output A merged document, with --junit-logging all
<?xml version="1.0" encoding="utf-8"?>
<testsuites name="pytest tests" tests="3" failures="1" errors="1" skipped="0" time="724.000">
  <testsuite name="pytest" tests="3" failures="1" errors="1" skipped="0" time="724.000"
             timestamp="2026-09-03T13:00:00" hostname="merged">
    <properties>
      <property name="pytest-html-reporter" value="0.4.3" />
      <property name="shards" value="4" />
      <property name="shard.ids" value="1-4,2-4,3-4,4-4" />
      <property name="shard.hosts" value="runner-1,runner-2,runner-3,runner-4" />
      <property name="reruns" value="2" />
      <property name="duplicates-folded" value="0" />
    </properties>
    <testcase classname="tests.test_login" name="test_valid_user" time="1.204">
      <properties>
        <property name="owner" value="payments-team" />
        <property name="jira" value="PROJ-123" />
        <property name="jira" value="PROJ-987" />
        <property name="severity" value="blocker" />
      </properties>
      <system-out>[[ATTACHMENT|pytest_screenshots/1-4/1788426639123-1.png]]
shard: 1/4 (runner-1)
reruns: 2</system-out>
    </testcase>
    <testcase classname="tests.test_cart" name="test_add[qty=2]" time="0.400">
      <failure message="AssertionError: assert 1 == 2">AssertionError: assert 1 == 2</failure>
      <system-out>shard: 2/4 (runner-2)</system-out>
    </testcase>
    <testcase classname="tests.test_api" name="(collection error)" time="0.000">
      <error message="collection failure">ImportError: No module named 'requests'</error>
      <system-out>shard: 3/4 (runner-3)</system-out>
    </testcase>
  </testsuite>
</testsuites>

The order inside <system-out> is fixed: attachments first, because a collector scans for them; then the shard and the rerun count, the two facts that would otherwise only live in properties nobody reads; then the captured output, which is the longest and least machine-read part. There is at most one <system-out> and one <system-err> per testcase, because a document with two of them is one whose second some parsers drop and others concatenate.

Traceability ids on the testcase

New in 0.4.1A testcase carrying owner, severity or a marker named in report_link_pattern gets a <properties> block of its own, written ahead of the outcome, which is where pytest's own writer puts a record_property block and therefore where every consumer that reads one already looks.

This is the machine-readable half of traceability. Xray, Zephyr and TestRail all ingest a test's issue key from a testcase property, and none of them opens an html report, so an id that only ever reaches a badge is invisible to exactly the tools that were supposed to consume it.

owner and severity need no configuration, so they are written by the merge and junit subcommands as well. Pattern markers need to be named, and only merge takes --report-link-pattern — it reads them off its own argv, having no ini file to ask. See markers that link for the html side of the same configuration.

How each status becomes an element

The mapping is explicit and closed on purpose. A canonical CI file is the last place that should be guessing, so a status this version has never heard of is written as an <error> and warned about loudly rather than absorbed into a plausible total.

StatusElementCounted as
PASSA bare <testcase> with no child element.passed
FAIL<failure>, its message the first non-blank line, or test failed when there is none.failures
ERROR<error> naming the phase — a record that never got as far as a call never ran the test, so that is a setup error; anything else fell over on the way out.errors
SKIP<skipped type="pytest.skip"> with the reason and the location parsed back out of the stored message.skipped
xFAIL<skipped type="pytest.xfail"> with an empty body, as pytest's own writer leaves it. Never a <failure>: mapping an expected failure onto one turns every suite that documents its known bugs red across Jenkins, GitLab and Azure at once.skipped
xPASSA bare passing testcase by default; <failure> under --junit-xpass fail; <skipped type="pytest.xpass"> under skip.depends
collection error<error message="collection failure">, with the classname set to the dotted path of the module that would not load — pytest's own answer leaves it empty, and every consumer that groups by classname then files every broken module under one nameless heading.errors
NoteOn a plain pytest run, failing to write the XML is reported and never raised. A raise there would cost the run its HTML report, its output.json and its archived build as well as its XML, over a mistyped path, in the one hook that runs after every test has finished.

Test coverage

Coverage is read, never re-measured. Nothing in that path is allowed to take a test run down: every branch either returns data or returns nothing, and a failure to read is reported into the page rather than raised. By the time it runs the tests are over, and failing the run over a decoration would turn a green build red.

Where the percentage comes from

On a live run the sources are tried in this order, stopping at the first that yields data.

  1. --report-coverage none — nothing at all, and no tab.
  2. --report-coverage-file PATH — read that file. If nothing there can be read as coverage data, the tab shows a notice naming the path. It does not fall through to the other sources, because you said where to look.
  3. The live pytest-cov run. The coverage object is asked for its own JSON report through coverage.py's public API, so the tab and your terminal always agree. This is read rather than raced for: pytest-cov stops, saves and combines its data — including every xdist worker's share — long before the report is written. The tab says Measured by pytest-cov during this run.
  4. Discoverycoverage.json, then coverage.xml, looked for beside the report, at the rootdir and in the working directory, first hit wins. Only those two deliberate report artifacts are looked for: a stray .coverage data file is often left over from a run days ago, and quietly reporting yesterday's number is worse than reporting none.
  5. If pytest-cov ran but measured nothing, the tab explains why rather than showing a setup guide to somebody who has already installed the plugin and passed the flag.

The format is sniffed from the first byte, not the extension< is Cobertura XML, { is coverage.py JSON, anything else is tried as a .coverage SQLite data file. CI hands these files whatever name the pipeline felt like.

Source formatWhat it needsHow the percentage is arrived at
coverage.jsonnothing at read timecoverage.py's own totals.percent_covered, taken as given rather than recomputed. With branch coverage on it already folds branches in, and it is the same total pytest-cov printed to the terminal.
coverage.xmlnothing at allCobertura: covered lines plus covered branches over valid lines plus valid branches — the same formula coverage.py uses, so an XML-read report and a live one agree on the headline number. The one path that needs no coverage package installed, which makes it the useful one when the reporting job is not the job that ran the tests.
.coveragethe coverage packageThe SQLite data file, loaded and turned into the same shape. Without the package this returns nothing rather than raising.
shell Coverage on a live run
# the ordinary case: pytest-cov measures, the reporter reads
$ pytest tests/ --cov=myapp --cov-report=html --cov-fail-under=80 --html-report=./report

# read an artifact another step produced
$ pytest tests/ --html-report=./report --report-coverage-file=./coverage.xml

# no Coverage tab at all
$ pytest tests/ --html-report=./report --report-coverage=none
NoteAn empty report is not 100%. Coverage is only accepted when it measured some statements or some files, because coverage.py calls a file with no statements fully covered — a run that measured nothing would otherwise open a Coverage tab announcing a perfect score.

The per-file split

Files are listed worst first, by percentage then by missing lines then by name. That is both the useful default and the only defensible way to cut the list: a cap that kept the alphabetical head would hide exactly the files the tab is opened to find. The full count is always recorded, so the cap note can say how many were left out and how to see them all.

Each row carries the file's percentage, statements, covered, missing, excluded, branches, partial branches and the missing line numbers written the way coverage.py writes them — 12-15, 88, 91-140 — trimmed at a comma and never mid-range, because 91-14 reads as a line range that does not exist. Paths are relativised against the rootdir, since four directories of CI checkout in front of src/api.py identify nothing. A file with no branches shows an em dash rather than 0/0: having no branches to cover is not the same statement as covering none of them, and the Branches and Partial tiles appear only when branch coverage was actually switched on.

FlagDefaultWhat it does
--report-coverageautoauto builds the tab from whatever coverage is there; none switches it off, including the entry in output.json. ini key: report_coverage.
--report-coverage-file""Read coverage from this file instead of looking for one. ini key: report_coverage_file.
--report-coverage-limit500Files listed on the tab, least covered first; 0 lists every one. ini key: report_coverage_limit.
--coverage-targetunsetMerge only. The percentage this project set for itself, which colours the ring. It is the merge's answer to --cov-fail-under, and is never read as a coverage source.
--coverage-datanoneMerge only. .coverage data files, or directories holding them, combined with the coverage package. Repeatable.

Colour, targets and drift

The ring is green at 90% and above, amber at 75%, red below — unless the project has stated its own bar with --cov-fail-under (or --coverage-target on a merge), in which case that is the line the colour is drawn at and the tab says so. A report should not disagree with the build that just passed or failed beside it, so a hair of slack keeps 79.999999999 against a target of 80 from reading as a failure in the report while the build itself passed.

The percentage is written into output.json alongside the test counts, so it travels with the archived builds. That is what gives the tab its +0.8 since the last build chip and its trend line. The delta compares this build against the most recent earlier build that measured any coverage, skipping builds that ran without it rather than counting them as a drop to zero; a movement under 0.05 reads as no change. A build that ran without coverage leaves a gap in the line rather than a drop to zero, because the coverage key is simply absent from that build's output.json — and absent means "not measured", which is a different answer from zero and the true one.

Screenshot: assets/img/shots/coverage-tab-merged.png The Coverage tab at 1440px wide, light theme, on a build with a target set: the ring with its grade colour, the "+0.8 since the last build" chip, the stat tiles including Branches and Partial, the coverage trend chart, and the top of the least-covered-first file table with the cap note visible underneath it.

The annotated source

The one thing a summary cannot replace is the source, line by line, with the missed lines marked. Generate it with --cov-report=html and the tab links to it.

It is linked, never embedded. An iframe would break the property the whole reporter is built on — one file you can mail, publish as a CI artifact or open off a stick — and it would break silently, showing an empty frame wherever the folder did not travel. The link is offered only when the index file exists and was written after this run started: coverage.py names that folder whether or not the HTML report was asked for, so a stale htmlcov sits there looking exactly like a fresh one, and annotations that disagree with the summary beside them are worse than no link.

Coverage on a merged build

The merge has five branches, in this order, and no sixth.

  1. --report-coverage none — nothing. Reachable only through a --report-shard-merge leg, which reads the pytest flag; the merge command has no such flag, so coverage there is auto unless you name a source.
  2. --report-coverage-file — read it. If nothing there can be read, the build gets no coverage and a notice naming the path.
  3. --coverage-data — collect every named file, and every entry named .coverage* directly inside every named directory, and combine them with the coverage package. The source line reads N combined data files. A missing coverage package, no files matched, or a combine that raised are each reported as a notice rather than a failure.
  4. Exactly one bundle measured coverage — use it, with a notice saying it covers that shard's share of the run.
  5. More than one measured, or none did — no coverage, and a notice telling you what to do about it.
CarefulThe merge never averages the shards' percentages — four percentages over four different subsets of the code do not average to anything — and it never discovers a coverage file on the merging machine. Discovery searches the working directory and stops at the first hit, so a stray coverage.json would become the build's headline number, and output.json would archive it into the trend chart for as long as the archive is kept.

The data files are copied into a temporary directory before being combined, because coverage.py's own combine() deletes the files it reads — and these are somebody's CI artifacts, which are very often the only copy.

shell Two routes to one figure over a whole matrix
# let the merge combine the data files itself
$ pytest-html-reporter merge ./artifacts \
    --html-report ./report \
    --coverage-data ./covdata \
    --coverage-target 80

# or combine first with coverage.py and hand over one report
$ coverage combine ./covdata/.coverage.*
$ coverage json -o ./coverage.json
$ pytest-html-reporter merge ./artifacts \
    --html-report ./report \
    --report-coverage-file ./coverage.json \
    --coverage-target 80

Troubleshooting: the Coverage tab is empty

Work down this list. The first one that applies is the answer.

Did anything measure coverage at all?

pytest-cov has to be installed and --cov passed. With neither, the tab shows the setup guide — which is the correct answer, not a fault.

Is --cov pointing at code that actually gets imported?

This is the usual one. --cov takes the import name or the path of the code under test — your package, not the tests, and not a folder that is not there. --cov=src against a project with no src directory measures nothing, and pytest-cov prints Module src was never imported and No data was collected among the rest of the run. The tab repeats it, naming the flag you typed, rather than showing you a guide to what you have already done.

Is --report-coverage=none set somewhere you are not looking?

Check addopts and the report_coverage ini key in pytest.ini, pyproject.toml or tox.ini, not only the command you typed.

Is --report-coverage-file pointing at something that is not a coverage report?

Naming a file switches off every other source, so a file that cannot be read leaves the tab empty rather than falling through. The tab names the file it could not read.

Is this a merged build with more than one shard measuring?

Then there is no coverage on the build by design, and the notice on the tab names the fix: combine the data first and pass the result with --report-coverage-file, or pass the shards' data files with --coverage-data.

Whichever source the numbers do come from, the tab states it — Measured by pytest-cov during this run, or Read from coverage.xml, written 2026-08-31 20:23 — so you can always tell which of these you are in.

CI recipes

Five things are worth getting right whatever the platform.

GitHub Actions

The full three-shard workflow is the worked example above. For a single unsharded job there is a dedicated composite Action that installs Python, runs pytest with the plugin, and publishes the result to the workflow job summary, a self-editing pull request comment, a downloadable artifact and GitHub Pages — with step outputs a later step can gate on.

yaml The whole of an unsharded job, with the Action
- uses: actions/checkout@v4
- uses: prashanth-sams/pytest-html-reporter-action@v1

GitLab CI

GitLab's parallel keyword hands each job a CI_NODE_INDEX and a CI_NODE_TOTAL, which is exactly a shard id. The run token is derived automatically from CI_PIPELINE_ID, and the merged XML goes into reports:junit: so the pipeline's own test tab reads it — one entry per test, because the document has exactly one <testcase> per node id.

yaml .gitlab-ci.yml
test:
  parallel: 4
  script:
    - pip install pytest-html-reporter
    - pytest tests/ --html-report=./report --report-shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths: [report/shards/]

merge:
  needs: [test]
  script:
    - pip install pytest-html-reporter
    - pytest-html-reporter merge ./report
        --html-report ./report
        --junit-xml ./junit.xml
        --exit-code
  artifacts:
    when: always
    paths: [report/]
    reports:
      junit: junit.xml

The merge reads ./report and writes ./report, which is safe by design: the shards live in a subtree the merge never clears, and only report/pytest_screenshots is wiped and restaged.

Jenkins

Jenkins is the sequential case — several stages on one agent, in one workspace — so it is the one that --report-shard-merge was written for. The run token is derived from BUILD_TAG, which is jenkins-${JOB_NAME}-${BUILD_NUMBER} and so is unique across jobs, unlike the bare build number, which only counts within one. The first stage passes --report-shard-reset so a stage that was renamed or removed since the last build cannot leave a bundle behind for this build to merge.

shell The three commands, one per stage, in one workspace
# stage: unit
$ pytest tests/unit --html-report=./report --report-shard=unit --report-shard-reset

# stage: integration
$ pytest tests/integration --html-report=./report --report-shard=integration

# stage: e2e - the last leg merges the three and writes the one report and the one xml
$ pytest tests/e2e --html-report=./report \
    --report-shard=e2e --report-shard-merge \
    --report-junit=./report/junit.xml \
    --archive-count=60

Publish the results from the pipeline's post block with Jenkins' own steps: junit 'report/junit.xml' for the test result page, and archiveArtifacts artifacts: 'report/**' to keep the HTML report against the build. Because the workspace persists between builds, report/output.json and report/archive/ survive, which is what gives this job a real trend chart and a populated Analytics tab rather than a one-build view.

NoteThe merging leg's --report-junit writes the merged document — the shard properties, the matrix's timestamp and span, the reruns and the folds — not just its own stage's tests. Only the xPASS mapping comes from the run's own flag, because --report-junit-xpass is the one shaping flag a pytest run has.

Keeping the report file down

Set no retention at all and every build is kept for ever, which is what eventually makes a report slow to open: a retained build costs roughly 5KB of the page, so an hourly job reaches a multi-megabyte report inside a couple of months.

Retention: three limits that intersect

An archived build has to satisfy every limit you set in order to survive. Retention runs after the trend has been drawn and before the Archives section and Analytics are built, so the history Analytics reads is exactly the history the report ships with.

FlagDefaultWhat it does
--archive-countunlimitedBuilds to keep. Empty keeps every build. 0 deletes archive/ outright and removes the Archives section. N keeps N - 1 files on disk, because the build being reported now is shown alongside the archived ones and counts against the limit.
--archive-daysunsetKeep only builds newer than now minus D days. Fractions are allowed — 0.5 is half a day. For a job on a schedule, where "the last 30 days" does not have to be retuned every time the schedule changes.
--archive-sinceunsetDelete every archived build older than this moment. YYYY-MM-DD means midnight local time that day; 'YYYY-MM-DD HH:MM' and with seconds are also accepted.

When both a days limit and a since date are given, the stricter of the two wins, so neither can widen the other. All three are validated by name: a non-numeric or negative count, a non-numeric or negative days value, or an unparseable date each fail the run rather than being ignored — and on the merge command they are checked before anything is written, so a typo cannot abort a build that had already rotated the archive.

shell The same limits on a run and on a merge, with the same words on error
# a nightly job: keep a month of history, never more than 60 builds
$ pytest tests/ --html-report=./report --archive-days=30 --archive-count=60

# the same limits applied by a merge
$ pytest-html-reporter merge ./artifacts --html-report ./report \
    --archive-days 30 --archive-count 60

# keep nothing but this build
$ pytest tests/ --html-report=./report --archive-count=0

Retention is a property of the job rather than of one run, so the ini file is usually the better place for it: set it once and every invocation, however it is started, keeps the same window.

ini pytest.ini
[pytest]
html_report = ./report
archive_count = 60
archive_days = 30

The per-test evidence caps

The other half of the size question is how much each test carries. Four settings decide it, and all four have an all / failed / none narrowing plus a numeric cap where a cap makes sense.

SettingDefaultWhat it costs you
--report-logsallCaptured stdout, stderr and logging, per test. failed is the usual choice on a large suite.
--report-log-limit10000Characters kept per test. What survives is the end of the output — the lines next to the failure — cut back to a whole line, with a note saying how much was dropped. 0 keeps everything.
--report-attachmentsallText, JSON and API payloads. Held outside the metrics table, so they are never swept into its search box or its exports.
--report-attachment-limit20000Characters per payload. What survives is the start — the opposite of the log limit, because a response puts its status, its error field and its first records at the top.
--report-stepsallStep trees. none still keeps the phases and their timings, which cost nothing.
--report-step-limit500Steps per test, so a step inside a loop over ten thousand rows cannot run away with the page.
--report-screenshotsfailedWhen a live Selenium driver or Playwright page is photographed without the suite asking. Images handed to attach() are always kept.
--report-coverage-limit500Rows in the coverage file table, least covered first.
shell A long-running pipeline that keeps evidence only where it helps
$ pytest tests/ --html-report=./report \
    --report-logs=failed --report-log-limit=5000 \
    --report-attachments=failed \
    --report-steps=failed --report-step-limit=100 \
    --archive-days=30 --archive-count=60
TipRetention is the setting to think about first, because it is the one that bounds Analytics as well as the file size. How far back the flaky-test histories, the pass-rate drift and the movement cards can see is exactly what these three limits have kept.