# Report Generation in Odoo, Part 1: From Print Click to PDF on Disk

> The six hops between clicking Print on a quotation and a PDF landing in your downloads folder, traced through Odoo 18 and 19 source, with the differences back to Odoo 12.

**Date:** 2026-09-16
**Source:** <https://trobz.com/insights/odoo-report-internals/>

---



Odoo's own documentation never explains what happens between clicking Print and a PDF landing in your downloads folder, even though the answer runs through at least six separate files on the client and the server.

This is the first post in a planned series on Odoo reports. We trace the full path a request takes, using the generation of a sale order quotation as the running example: clicking Print on a quotation, and watching `sale.report_saleorder` turn into a saved PDF. The trace below is checked against Odoo 18.0 source directly, and confirmed unchanged in substance on 19.0. Readers on an older version get a dedicated section near the end instead of a version caveat on every paragraph.

## From Click to Server: The RPC Call and the Report Action

{{< img src="/images/insights/odoo-report-internals/print-menu-pdf-quote.jpg" alt="The Print menu open on an Odoo quotation, with PDF Quote highlighted" >}}

Clicking Print on a quotation sends a request built as:

```
POST /web/dataset/call_button/${params.resModel}/${params.name}
```

That URL is not a static route: the client builds it at runtime from the model and method being called (`sale.order` and `print_quotation` in our example), then sends it through the generic `rpc()` helper, which always issues a POST regardless of which endpoint it targets [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1486)[ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/rpc.js#L128). The controller resolves the model method (`sale.order.print_quotation()` in our example), runs it, and if the method returns a dict describing an action, cleans it and hands it back to the browser [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/dataset.py#L38-L45).

That action comes from `ir.actions.report.report_action()`, the method behind the report definition (`sale.action_report_saleorder`). It builds the dict the client will act on: an action type of `ir.actions.report`, the report's technical name, its output type (`qweb-pdf` for our quotation), and a context carrying the `active_ids`, the record IDs to print [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L1137-L1169).

This hop has stayed remarkably stable. The route itself has not moved since Odoo 12.0. Its signature changed once, in Odoo 13.0, when `call_button` switched from two separate `domain_id` and `context_id` arguments to a single caller-supplied `kwargs` dict [ref](https://github.com/odoo/odoo/commit/1ced3bfca457ac50845637bcd42a8e53bd7f088d), the shape still in use today.

## The Client Picks Up the Action

Once the browser has the `ir.actions.report` dict, the current web client's action service takes over. Its `_executeReportAction()` function is the direct successor to an older widget named `action_manager_report.js`, a name that still survives today only as a comment inside the server's own download controller [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1302-L1352).

For a PDF report, this function first calls `/report/check_wkhtmltopdf` to confirm the server can actually render PDFs, then calls `downloadReport()`, which posts a small JSON payload, the report's internal URL and its type (something like `["/report/pdf/sale.report_saleorder/1", "qweb-pdf"]`), to `/report/download` [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/reports/utils.js#L65-L86). This is the exact point where the pre-OWL client and today's client diverge; the section on older versions below covers when and why.

## The Server Renders, the Browser Saves

`report_download()` reads that payload, extracts the report name and record IDs from the URL, and calls `report_routes()`, which in turn calls `_render_qweb_pdf()` to produce the actual PDF bytes [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L92-L139)[ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L23-L50). Before sending the response, it computes a filename (a report can define a custom naming expression evaluated against the record being printed) and sets the header that makes this a download rather than a page load: `Content-Disposition: attachment`.

On the browser side, the client's download handler receives the response as a blob, reads the filename back out of that same `Content-Disposition` header, and triggers the actual file save through a hidden link element with a `download` attribute [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/download.js#L484-L579). Everything from here on is standard browser behavior: the PDF lands wherever the browser is configured to put downloads.

## Inside wkhtmltopdf: How the PDF Actually Gets Made

Everything so far is routing. The part that actually turns a QWeb template into a PDF happens inside `_render_qweb_pdf()`, and it is worth walking through because nothing in the flow above documents it.

The QWeb template renders to HTML first, exactly as it would for an on-screen view. That HTML is then split into a header, a footer, and one or more bodies (one per record, generally) [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L368-L455). Each piece is written to its own temporary file: a header HTML file, a footer HTML file, one body HTML file per record, and a cookie jar file that lets the rendering process authenticate as the requesting user. Odoo then spawns `wkhtmltopdf` as a subprocess, pointed at those files, and reads the resulting PDF back off disk once the subprocess exits. Every temporary file is deleted immediately afterward [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L502-L636).

One detail worth knowing on its own: bodies larger than 4 mebibytes get their tables split into smaller chunks before rendering, specifically to work around a performance problem in wkhtmltopdf itself. The Odoo team's own fix for this cites a report with 250,000 rows that used to take about an hour to render before the split was added [ref](https://github.com/odoo/odoo/commit/ad06a7b2ad2230e8ae27b694019daf5fd73a7e77). If a report with a very large table seems to hang, this is the first place to look.

## If You Are on Odoo 12.0 Through 17.0

The trace above holds for Odoo 18.0 and 19.0. Earlier versions differ at a handful of specific points, each tied to a real commit:

**Odoo 12.0**: the baseline the rest of this section compares against. `call_button` still takes separate `domain_id` and `context_id` arguments instead of a single `kwargs` dict, `report_download` requires a `token` argument and sets a `fileToken` cookie on its response, client-side dispatch runs through the pre-OWL `action_manager_report.js` widget, and `_run_wkhtmltopdf()` authenticates with a single inline `--cookie session_id <sid>` argument rather than the temp-file cookie jar described earlier [ref](https://github.com/odoo/odoo/blob/ba1ba88874e2ae9716e59ec199eb739d58c3f771/addons/web/controllers/main.py#L962-L966).

**Odoo 13.0**: `call_button`'s signature changes from separate `domain_id` and `context_id` arguments to the single `kwargs` dict described above [ref](https://github.com/odoo/odoo/commit/1ced3bfca457ac50845637bcd42a8e53bd7f088d).

**Odoo 15.0**: two unrelated changes land in the same release. The pre-OWL `action_manager_report.js` widget is removed, replaced by `_executeReportAction()` inside the new OWL-based action service [ref](https://github.com/odoo/odoo/commit/0573acae2306bf5da2005852da9323ddc59e5431). Separately, a `fileToken` cookie that the old client used to poll for (a way of detecting a same-origin download had finished, before browsers offered a reliable blob-download API) is removed as dead code, since the new client no longer needs it [ref](https://github.com/odoo/odoo/commit/926af3734384e765db070212ef12271c0b41aded).

**Odoo 16.0**: the web client's controllers, previously piled into one large `main.py`, are split by concern. `call_button` moves into `dataset.py`; `report_download` and `check_wkhtmltopdf` move into `report.py` [ref](https://github.com/odoo/odoo/commit/bcf665a291b86203af9f604ec5b95c8c857b20c3).

**Odoo 17.0**: the report-download logic is pulled out of the action service into its own `reports/utils.js` module, originally so that Point of Sale could reuse the download code without loading the entire action service [ref](https://github.com/odoo/odoo/commit/db4b11141d1581ebf1cd5b401801e66e77748040). The same release adds the 4-mebibyte table-splitting guard described above [ref](https://github.com/odoo/odoo/commit/ad06a7b2ad2230e8ae27b694019daf5fd73a7e77).

**Odoo 19.0**: the flow above is unchanged in substance. The one visible difference is that `call_button` and `check_wkhtmltopdf` switch their route type from `json` to `jsonrpc`, a framework-wide rename that touches many routes, not something specific to reports [ref](https://github.com/odoo/odoo/blob/6c8e7dcb96621dbbf634b0632f64a1be3cab7d94/addons/web/controllers/dataset.py#L34).

One correction, because it is a useful trap to know about: an earlier pass of this research read the temporary cookie-jar file in the wkhtmltopdf command as introduced in Odoo 15.0, apparently alongside the OWL rewrite above. That is wrong. Versions 12.0 through 14.0, and originally 15.0 too, used a much simpler mechanism with no temp file at all: a single `--cookie` argument. The temp-file version, with domain and path restrictions, comes from a 2024 security fix that restricts which domain the session cookie gets sent to [ref](https://github.com/odoo/odoo/commit/ae8658468dcb89bef6cba14b93db036705914630). That fix was backported to every branch still supported at the time (15.0 through 18.0) and skipped 12.0 through 14.0 only because those were already end of life by then. Comparing two branches' current code shows what a version looks like today, not when a feature was introduced: a distinction that matters for anyone repeating this kind of research on old Odoo branches.

## The Full Chain, End to End

Six hops, in order: the button click reaches the server through `call_button`, the server builds an `ir.actions.report` dict, the client's action service dispatches it and posts to `/report/download`, the server renders the QWeb template and sets the download header, wkhtmltopdf turns rendered HTML into an actual PDF through a set of temporary files, and the browser saves the response using the filename from that header.

```mermaid
sequenceDiagram
    participant U as User
    participant B as Browser
    participant S as Odoo server

    U->>B: Clicks Print
    B->>S: POST /web/dataset/call_button
    Note over S: call_button() [1] — dataset.py — runs sale.order.print_quotation()
    S->>S: report_action() [2] — ir_actions_report.py
    Note over S: builds the ir.actions.report dict
    S-->>B: ir.actions.report dict
    Note over B: Action service [3] — _executeReportAction()
    B->>S: POST /report/download
    Note over S: report_download() [5][6] — report.py — calls _render_qweb_pdf()
    S->>S: wkhtmltopdf subprocess [8][9]
    Note over S: header/footer/body temp files
    S-->>B: PDF bytes + Content-Disposition header
    B->>U: Saves the file [7] — download.js
```

Each symptom points back to one specific hop. A report that does nothing at all, silently, points to the first two steps: check what the action dict actually contains. A PDF that downloads but renders with no styling points to the header, footer, and body extraction inside `_render_qweb_pdf()`. A report that appears to hang on a large document points to the table-splitting guard, and whether the table in question is actually being split.

Part 2 of this series is not scoped yet. If there is a specific failure mode worth a deeper trace, tell us and we will consider it for the next one.

---

## References

Odoo 18.0, "call_button() — RPC handler, addons/web/controllers/dataset.py:38-45": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/dataset.py#L38-L45
Odoo 18.0, "report_action() — odoo/addons/base/models/ir_actions_report.py:1137-1169": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L1137-L1169
Odoo 18.0, "_executeReportAction() — addons/web/static/src/webclient/actions/action_service.js:1302-1352": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1302-L1352
Odoo 18.0, "downloadReport() — addons/web/static/src/webclient/actions/reports/utils.js:65-86": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/reports/utils.js#L65-L86
Odoo 18.0, "report_download() — addons/web/controllers/report.py:92-139": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L92-L139
Odoo 18.0, "report_routes() — addons/web/controllers/report.py:23-50": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L23-L50
Odoo 18.0, "client-side blob download and filename parsing — addons/web/static/src/core/network/download.js:484-579": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/download.js#L484-L579
Odoo 18.0, "header, footer, and body extraction — odoo/addons/base/models/ir_actions_report.py:368-455": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L368-L455
Odoo 18.0, "_run_wkhtmltopdf() — odoo/addons/base/models/ir_actions_report.py:502-636": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L502-L636
Odoo, "commit 1ced3bfca4 — pass context in kwargs in rpc calls (2019-02-15, Odoo 13.0)": https://github.com/odoo/odoo/commit/1ced3bfca457ac50845637bcd42a8e53bd7f088d
Odoo, "commit 0573acae23 — rewrite the webclient in OWL, phase 1 (2021-05-31, Odoo 15.0)": https://github.com/odoo/odoo/commit/0573acae2306bf5da2005852da9323ddc59e5431
Odoo, "commit 926af37343, PR #72079 — remove unused fileToken cookies (2021-06-11, Odoo 15.0)": https://github.com/odoo/odoo/commit/926af3734384e765db070212ef12271c0b41aded
Odoo, "commit bcf665a291, PR #87571 — split controllers.main in several files (2022-03-29, Odoo 16.0)": https://github.com/odoo/odoo/commit/bcf665a291b86203af9f604ec5b95c8c857b20c3
Odoo, "commit db4b11141d, PR #120070 — factor report downloads out of action service (2023-05-04, Odoo 17.0)": https://github.com/odoo/odoo/commit/db4b11141d1581ebf1cd5b401801e66e77748040
Odoo, "commit ad06a7b2ad, PR #131933 — improve PDF generation speed for large tables (2023-08-18, Odoo 17.0)": https://github.com/odoo/odoo/commit/ad06a7b2ad2230e8ae27b694019daf5fd73a7e77
Odoo, "commit ae8658468d, PR #78857 — restrict the cookie domain in wkhtmltopdf (2024-05-27, backported 15.0-18.0)": https://github.com/odoo/odoo/commit/ae8658468dcb89bef6cba14b93db036705914630
Odoo 19.0 branch, "route type renamed from json to jsonrpc — addons/web/controllers/dataset.py:34 (also report.py:153)": https://github.com/odoo/odoo/blob/6c8e7dcb96621dbbf634b0632f64a1be3cab7d94/addons/web/controllers/dataset.py#L34
Odoo 18.0, "call_button URL built as a template literal — addons/web/static/src/webclient/actions/action_service.js:1486": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1486
Odoo 18.0, "generic RPC POST helper — addons/web/static/src/core/network/rpc.js:128": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/rpc.js#L128
Odoo 12.0 branch, "pre-13.0 baseline for call_button, fileToken cookie, and inline --cookie wkhtmltopdf arg — addons/web/controllers/main.py:962-966,1658-1712 (also ir_actions_report.py:389-460)": https://github.com/odoo/odoo/blob/ba1ba88874e2ae9716e59ec199eb739d58c3f771/addons/web/controllers/main.py#L962-L966
