All posts
Developer10 min read

Custom PDF reports in Odoo: QWeb templates, report actions, and paper formats

Odoo's QWeb report engine lets you produce branded PDF documents - invoices, delivery orders, custom certificates - from any model. This guide covers report actions, QWeb template syntax, paper formats, and the common gotchas with wkhtmltopdf rendering.

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:

  1. Report action (ir.actions.report): links a button or menu to a QWeb template and defines which model(s) it applies to.
  2. QWeb template (report.xml): the HTML/XML template that renders each record. Odoo converts this to HTML, then passes it to wkhtmltopdf.
  3. 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:

xml
<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 format module_name.template_xml_id
  • binding_model_id: links the report to the Print menu on the model's form view
  • binding_type: use report to 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:

xml
<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:

python
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:

xml
<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:

xml
<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