Every Odoo project eventually needs a custom PDF: a branded invoice with fields the standard template does not show, a picking list with extra product information, a certificate of conformity, or a custom shipping label. Odoo generates all PDFs through its QWeb report engine, and understanding how the engine works is the difference between a fast, clean customization and hours of trial-and-error.
How Odoo generates PDFs#
The rendering pipeline has three components:
- Report action (
ir.actions.report): links a button or menu to a QWeb template and defines which model(s) it applies to. - QWeb template (
report.xml): the HTML/XML template that renders each record. Odoo converts this to HTML, then passes it to wkhtmltopdf. - Paper format (
report.paperformat): page size, margins, orientation, and header/footer settings.
wkhtmltopdf is a headless Chromium-based renderer. Odoo passes the rendered HTML to it and gets back a PDF. This means your report template is standard HTML + CSS - anything a browser can render, wkhtmltopdf can convert, with some exceptions around JavaScript and fonts.
The report action#
Create a report action with an XML record in your module's report/ directory:
<record id="action_report_picking_certificate" model="ir.actions.report">
<field name="name">Picking Certificate</field>
<field name="model">stock.picking</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.report_picking_certificate</field>
<field name="report_file">my_module.report_picking_certificate</field>
<field name="binding_model_id" ref="stock.model_stock_picking"/>
<field name="binding_type">report</field>
</record>Key fields:
model: the model this report applies to (e.g.,stock.picking,account.move)report_name: the full template name, in the formatmodule_name.template_xml_idbinding_model_id: links the report to the Print menu on the model's form viewbinding_type: usereportto add it to the Print menu
Once bound, the report appears under the Print button on any stock.picking form and in the action menu on the picking list view.
The QWeb template#
The template lives in a report/ directory XML file:
<odoo>
<template id="report_picking_certificate">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<h2>Picking Certificate</h2>
<p>Reference: <span t-field="o.name"/></p>
<p>Date: <span t-field="o.scheduled_date" t-options='{"widget": "date"}'/></p>
<table class="table table-sm">
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Lot/Serial</th>
</tr>
</thead>
<tbody>
<t t-foreach="o.move_line_ids" t-as="line">
<tr>
<td><span t-field="line.product_id.name"/></td>
<td><span t-field="line.qty_done"/></td>
<td><span t-field="line.lot_id.name"/></td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>Key template conventions#
t-call="web.html_container": always wrap the outermost template in this. It sets the and structure that wkhtmltopdf expects.
t-call="web.external_layout": adds the company header (logo, name, address) and footer automatically. Use web.internal_layout for internal reports that do not need the company branding. You can override either layout in your module to change the global header/footer.
t-foreach="docs": the docs variable is always the recordset of the model instances being printed. If the user prints three pickings at once, docs contains all three and the template loops over each one.
t-field: renders the field value through Odoo's field widget system. This handles formatting (dates, monetary amounts, many2one display names) automatically. Use t-field over t-esc when you want locale-aware formatting.
t-esc: renders a raw value with HTML escaping. Use it for computed strings or when you need exact control over the output.
Passing additional data to the report#
Sometimes you need data that is not directly on the model - a configuration value, a computed total across records, or a lookup from another model. Use the get_report_values method on a custom report model:
from odoo import models
class PickingCertificateReport(models.AbstractModel):
_name = 'report.my_module.report_picking_certificate'
_description = 'Picking Certificate Report'
def _get_report_values(self, docids, data=None):
docs = self.env['stock.picking'].browse(docids)
return {
'docs': docs,
'doc_model': 'stock.picking',
'company': self.env.company,
'total_weight': sum(
docs.mapped('move_line_ids.product_id.weight')
),
}The model name must follow the pattern report.. Any keys returned by _get_report_values are available as top-level variables in the QWeb template.
Paper formats#
A paper format controls page dimensions, margins, and header/footer height. The default is A4 portrait. To define a custom format:
<record id="paperformat_certificate" model="report.paperformat">
<field name="name">Certificate A4 Landscape</field>
<field name="default" eval="False"/>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">20</field>
<field name="margin_bottom">20</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">10</field>
</record>Link it to a report action:
<field name="paperformat_id" ref="paperformat_certificate"/>If no paper format is set on the report action, Odoo uses the company's default paper format (Settings → Technical → Reporting → Paper Formats → Company Default).
Styling and CSS#
wkhtmltopdf renders with a subset of CSS. Odoo includes Bootstrap in the report output, so Bootstrap utility classes (table, row, col-, text-right, etc.) work as expected. Complex layouts using CSS Grid or Flexbox work in modern Odoo versions (which use a recent wkhtmltopdf build) but can fail on older versions.
To add custom CSS to your report, include a block inside the wrapper or override the web.html_container template to inject a stylesheet link:
<template id="report_picking_certificate" inherit_id="web.html_container">
<xpath expr="//head" position="inside">
<link rel="stylesheet" href="/my_module/static/src/css/report.css"/>
</xpath>
</template>Keep report CSS in a dedicated file rather than inline styles, especially if multiple reports share the same branding.
Translating reports#
QWeb reports respect Odoo's translation system automatically for static strings marked with t-translation. For field values, Odoo's field widget handles locale-appropriate rendering (date formats, decimal separators, currency symbols) based on the lang of the partner receiving the document.
To translate a static string in a QWeb template, Odoo reads the strings during module installation and adds them to the translation engine. Translated reports render in the partner's language when you print from the invoice or order - no additional code needed.
Debugging report rendering#
Browser preview: the fastest way to debug is to open the report URL directly in a browser. Navigate to /report/html/ (e.g., /report/html/my_module.report_picking_certificate/42). This renders the HTML before the wkhtmltopdf conversion, so you can inspect element styles and fix layout issues without waiting for a PDF download.
wkhtmltopdf logging: if the PDF renders incorrectly but the HTML preview looks right, the problem is usually a font or resource that wkhtmltopdf cannot load. Check the Odoo server log for wkhtmltopdf warnings - they appear at the WARNING level and usually name the failing resource.
Missing records: if the report prints but some fields are empty, verify the records are accessible in the current user's security context. The report rendering uses sudo() for the document fetch by default, but custom _get_report_values methods run with the current user's rights unless you explicitly sudo().
Inheriting and overriding existing reports#
To add a column to the standard invoice PDF without replacing it entirely, use template inheritance:
<template id="report_invoice_line_extra" inherit_id="account.report_invoice_document">
<xpath expr="//th[hasclass('product')]" position="after">
<th class="text-center">Origin</th>
</xpath>
<xpath expr="//td[hasclass('product')]" position="after">
<td class="text-center">
<span t-field="line.sale_line_ids.order_id.name"/>
</td>
</xpath>
</template>XPath on QWeb templates follows the same rules as view inheritance XPath. The hasclass() function is a QWeb extension that selects elements by CSS class without needing an exact class string match.
Common mistakes#
Not using t-call="web.html_container": rendering will produce malformed output that wkhtmltopdf sometimes accepts and sometimes rejects depending on version.
Using JavaScript in reports: wkhtmltopdf does not reliably execute JavaScript. Any dynamic behavior (totals, conditional display) must be in the QWeb template or the Python _get_report_values method.
Hardcoding company data in templates: always use self.env.company or the company variable from _get_report_values. The report may be printed from a multi-company context.
Large image attachments: embedding high-resolution logos or product images inflates PDF size and slows rendering. Resize images before embedding, or reference a URL that serves a compressed version.
For QWeb view rendering in the web client, see the Odoo OWL component guide. For email templates using QWeb, see the Odoo email template guide.

