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.

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

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 1819. 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 1.

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 2.

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 10, 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 3.

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 4. 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 56. 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 7. 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) 8. 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 9.

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 15. 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 20.

Odoo 13.0: call_button’s signature changes from separate domain_id and context_id arguments to the single kwargs dict described above 10.

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 11. 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 12.

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 13.

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 14. The same release adds the 4-mebibyte table-splitting guard described above 15.

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 17.

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 16. 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.

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.


  1. call_button() — RPC handler, addons/web/controllers/dataset.py:38-45 — Odoo 18.0 ↩︎

  2. report_action() — odoo/addons/base/models/ir_actions_report.py:1137-1169 — Odoo 18.0 ↩︎

  3. _executeReportAction() — addons/web/static/src/webclient/actions/action_service.js:1302-1352 — Odoo 18.0 ↩︎

  4. downloadReport() — addons/web/static/src/webclient/actions/reports/utils.js:65-86 — Odoo 18.0 ↩︎

  5. report_download() — addons/web/controllers/report.py:92-139 — Odoo 18.0 ↩︎

  6. report_routes() — addons/web/controllers/report.py:23-50 — Odoo 18.0 ↩︎

  7. client-side blob download and filename parsing — addons/web/static/src/core/network/download.js:484-579 — Odoo 18.0 ↩︎

  8. header, footer, and body extraction — odoo/addons/base/models/ir_actions_report.py:368-455 — Odoo 18.0 ↩︎

  9. _run_wkhtmltopdf() — odoo/addons/base/models/ir_actions_report.py:502-636 — Odoo 18.0 ↩︎

  10. commit 1ced3bfca4 — pass context in kwargs in rpc calls (2019-02-15, Odoo 13.0) — Odoo ↩︎

  11. commit 0573acae23 — rewrite the webclient in OWL, phase 1 (2021-05-31, Odoo 15.0) — Odoo ↩︎

  12. commit 926af37343, PR #72079 — remove unused fileToken cookies (2021-06-11, Odoo 15.0) — Odoo ↩︎

  13. commit bcf665a291, PR #87571 — split controllers.main in several files (2022-03-29, Odoo 16.0) — Odoo ↩︎

  14. commit db4b11141d, PR #120070 — factor report downloads out of action service (2023-05-04, Odoo 17.0) — Odoo ↩︎

  15. commit ad06a7b2ad, PR #131933 — improve PDF generation speed for large tables (2023-08-18, Odoo 17.0) — Odoo ↩︎

  16. commit ae8658468d, PR #78857 — restrict the cookie domain in wkhtmltopdf (2024-05-27, backported 15.0-18.0) — Odoo ↩︎

  17. route type renamed from json to jsonrpc — addons/web/controllers/dataset.py:34 (also report.py:153) — Odoo 19.0 branch ↩︎

  18. call_button URL built as a template literal — addons/web/static/src/webclient/actions/action_service.js:1486 — Odoo 18.0 ↩︎

  19. generic RPC POST helper — addons/web/static/src/core/network/rpc.js:128 — Odoo 18.0 ↩︎

  20. 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) — Odoo 12.0 branch ↩︎