Odoo generates PDF documents - invoices, delivery notes, purchase orders - using a server-side HTML-to-PDF rendering pipeline built on QWeb templates. The same templates that power web views power reports. But the rendering context, the layout helpers, and the paper size constraints are different enough that report development deserves its own mental model.
This guide covers the full lifecycle of a custom PDF report: registering it, writing the QWeb template, testing it, and avoiding the five CSS pitfalls that make reports look wrong on paper.
How QWeb reports work#
- An action (
ir.actions.report) registers a report: it specifies the model, the template XML ID, the paper format, and the print method (PDF, HTML, QWeb-HTML). - When triggered (button click, print menu, scheduled action), Odoo calls
IrActionsReport._render_qweb_pdf(). - The renderer renders the QWeb template to HTML in a server-side Python context.
- wkhtmltopdf (a headless WebKit binary) converts the HTML to PDF.
- The PDF is streamed back to the browser or written to an attachment.
The critical implication: your CSS must work with the version of WebKit bundled in wkhtmltopdf, not a modern Chrome. wkhtmltopdf 0.12.x uses a ~2014 WebKit. Flexbox works. CSS Grid does not. position: fixed on headers/footers works differently than in the browser.
Registering a report action#
<record id="action_report_custom_invoice" model="ir.actions.report">
<field name="name">Custom Invoice</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.report_custom_invoice_document</field>
<field name="report_file">my_module.report_custom_invoice_document</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="base.paperformat_euro"/>
</record>Key fields:
- report_name - the XML ID of the main QWeb template (without the module prefix once registered, or with it during initial lookup). Must match the
t-namein your XML file exactly. - report_file - used by Odoo's asset bundling to locate the template file. Typically mirrors report_name.
- binding_model_id - adds this report to the Print menu on the model's list and form views.
- paperformat_id - points to an
ir.actions.report.paperformatrecord that defines page size, margins, and orientation. Create a custom paper format if your report needs non-standard margins.
The report template structure#
Every QWeb PDF template has a standard outer structure:
<odoo>
<template id="report_custom_invoice_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<!-- Your report content here -->
<t t-call="my_module.report_custom_invoice_lines"/>
</div>
</t>
</t>
</t>
</template>
<template id="report_custom_invoice_lines">
<table class="table table-sm">
<thead>
<tr>
<th>Description</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
<tr t-foreach="o.invoice_line_ids" t-as="line">
<td><t t-esc="line.name"/></td>
<td class="text-end"><t t-esc="line.quantity"/></td>
<td class="text-end">
<t t-esc="formatLang(line.price_unit, currency_obj=o.currency_id)"/>
</td>
<td class="text-end">
<t t-esc="formatLang(line.price_subtotal, currency_obj=o.currency_id)"/>
</td>
</tr>
</tbody>
</table>
</template>
</odoo>web.html_container#
Provides the outer shell and loads Odoo's report CSS. Always call this at the top level - without it, wkhtmltopdf receives an incomplete HTML document.
web.external_layout#
Injects the company header (logo, name, address) and footer (page numbers, company info) from the company's report layout configuration. Override it with web.external_layout_background or your own template inheriting from it if you need custom branding.
The docs variable#
In the rendering context, docs is the recordset of objects being printed - usually the records selected in the list view or the current form view record. docs is always a recordset, even when printing a single record. Always loop over it with t-foreach="docs".
QWeb directives in reports#
t-esc vs t-raw#
t-esc- HTML-escapes the output. Use this for text values.is safe.t-raw- injects raw HTML without escaping. Use only for content you control and trust. Never uset-rawon user-supplied text.
t-if, t-elif, t-else#
<t t-if="o.partner_shipping_id != o.partner_invoice_id">
<div class="row">
<div class="col-6">
<strong>Delivery address:</strong>
<div t-field="o.partner_shipping_id" t-options='{"widget": "contact"}'/>
</div>
</div>
</t>t-field with widgets#
t-field is a shorthand that applies Odoo field widgets to the output. Useful widgets in reports:
"widget": "monetary"- formats with currency symbol and decimal places"widget": "date"- formats as locale date"widget": "contact"- renders address with proper line breaks"widget": "html"- renders an HTML field (e.g., product description with formatting)
formatLang#
The formatLang function is available in the report rendering context for number and currency formatting:
<t t-esc="formatLang(line.price_subtotal, currency_obj=o.currency_id)"/>
<!-- Output: $1,250.00 or €1.250,00 depending on locale -->Multi-language output#
Odoo renders reports in the language of the partner on the document, not the language of the user printing it. For a French customer, o.partner_id.lang = 'fr_FR' - Odoo will use French translations for all field labels and static text in the report.
To ensure this works:
- Wrap static text with
_translation markers:Totalshould be- but in practice, QWeb in reports uses thelangcontext automatically fort-fieldvalues. For literal strings, define them in air.ui.viewwith translatablet-esccalls. - Install the translation for each language you need via Settings → Translations → Load a Translation.
- Test by creating a partner with the target language and printing the report.
CSS layout for paper#
Page breaks#
.page-break { page-break-after: always; }
.avoid-break { page-break-inside: avoid; }Use page-break-inside: avoid on table rows or div blocks that must not be split across pages. Use page-break-after: always to force a new page after each document when printing multiple records.
Headers and footers across pages#
Odoo's external_layout uses CSS @page rules and a fixed-position header/footer that wkhtmltopdf repeats on each page. Do not use CSS position fixed for your own content - it will overlap the page controls.
Images#
Images in reports must be embedded as data URIs or accessible by wkhtmltopdf's internal HTTP request. Company logos are served at /web/image/res.company/ - wkhtmltopdf fetches this URL during rendering. If rendering runs in a container without network access to Odoo's HTTP port, images will be blank. The Odoo report renderer pre-fetches images and embeds them as base64 to avoid this, but verify this for any images you add outside the standard layout.
Troubleshooting#
Blank PDF output: wkhtmltopdf crashed or returned an empty document. Enable debug mode and check the server logs for a wkhtmltopdf error. Common causes: missing web.html_container call, invalid XML in the template, or a Python error in the rendering context.
Template not found: verify the report_name exactly matches the t-name attribute in your XML, including the module prefix (my_module.report_...). XML IDs are case-sensitive.
CSS not applying: the report CSS is loaded by web.html_container. If you need custom CSS, create a ir.ui.view with type qweb and inherit from the HTML container, adding a block. Or link an external CSS asset defined as a web.assets_pdf bundle asset.
Page numbers wrong: page numbers in wkhtmltopdf are handled via JavaScript substitution in the footer HTML. Odoo's external_layout handles this. If you replace the footer, use and in the footer template.
For QWeb in web views (not reports), see the OWL and QWeb guide. For computed fields that power report values, see the computed fields guide.

