Odoo ships with dozens of built-in reports — invoices, purchase orders, delivery slips, balance sheets — and for many businesses they work fine out of the box. But eventually someone in accounting asks for a custom aging report with their specific payment terms logic, or the warehouse manager needs a pick list with barcodes and bin locations that the default delivery slip doesn't include. That's when you need to build custom QWeb reports.

QWeb is Odoo's XML-based templating engine. It renders HTML that gets converted to PDF via wkhtmltopdf. The learning curve is surprisingly gentle if you already know HTML and CSS, but there are enough Odoo-specific patterns and gotchas to trip up developers who dive in without a guide. This article covers the full lifecycle: defining the report action, building the QWeb template, styling for print, adding computed fields, and the debugging workflow that saves hours.

How Odoo Reports Work Under the Hood

Every printable report in Odoo follows the same pipeline. A ir.actions.report record defines the report metadata — which model it applies to, the QWeb template to render, and the output format (PDF or HTML). When a user clicks "Print" in the UI, Odoo calls the report engine, which renders the QWeb template with the selected records as context, wraps it in an outer layout template (the one with the company header and footer), and pipes the resulting HTML through wkhtmltopdf to produce a PDF.

The key files in a custom report module are:

Step 1: Define the Report Action

Let's build a concrete example: a custom "Warehouse Pick List" report for the stock.picking model that shows bin locations, barcodes, and lot numbers — things the default delivery slip omits.

<!-- report/report_action.xml -->
<odoo>
  <record id="action_report_pick_list" model="ir.actions.report">
    <field name="name">Warehouse Pick List</field>
    <field name="model">stock.picking</field>
    <field name="report_type">qweb-pdf</field>
    <field name="report_name">my_module.report_pick_list</field>
    <field name="report_file">my_module.report_pick_list</field>
    <field name="binding_model_id" ref="stock.model_stock_picking"/>
    <field name="binding_type">report</field>
  </record>
</odoo>

The binding_model_id field is what makes the report appear in the Print menu on stock picking forms and list views. The report_name must match the QWeb template's t-name attribute exactly — this is the most common source of "Report not found" errors.

Step 2: Build the QWeb Template

The template structure follows a pattern you'll reuse for every report. The outer t-call pulls in Odoo's standard report layout (company logo, address, page numbers), and your template fills in the body.

<!-- report/report_template.xml -->
<odoo>
  <template id="report_pick_list">
    <t t-call="web.html_container">
      <t t-foreach="docs" t-as="picking">
        <t t-call="web.external_layout">

          <div class="page">
            <h2>Pick List: <t t-esc="picking.name"/></h2>
            <p>
              <strong>Scheduled Date:</strong>
              <t t-esc="picking.scheduled_date"
                 t-options='{"widget": "date"}'/>
            </p>
            <p>
              <strong>Source Location:</strong>
              <t t-esc="picking.location_id.complete_name"/>
            </p>

            <table class="table table-sm mt-3">
              <thead>
                <tr>
                  <th>Product</th>
                  <th>Barcode</th>
                  <th>Bin Location</th>
                  <th>Lot/Serial</th>
                  <th class="text-end">Qty</th>
                </tr>
              </thead>
              <tbody>
                <t t-foreach="picking.move_line_ids" t-as="ml">
                  <tr>
                    <td><t t-esc="ml.product_id.name"/></td>
                    <td>
                      <t t-if="ml.product_id.barcode">
                        <img t-att-src="'/report/barcode/?barcode_type=Code128&amp;value=%s&amp;width=200&amp;height=40' % ml.product_id.barcode"
                             style="max-height:30px;"/>
                        <br/>
                        <small t-esc="ml.product_id.barcode"/>
                      </t>
                    </td>
                    <td><t t-esc="ml.location_id.name"/></td>
                    <td><t t-esc="ml.lot_id.name or ''"/></td>
                    <td class="text-end">
                      <t t-esc="ml.quantity"
                         t-options='{"widget": "float", "precision": 2}'/>
                      <t t-esc="ml.product_uom_id.name"/>
                    </td>
                  </tr>
                </t>
              </tbody>
            </table>
          </div>

        </t>
      </t>
    </t>
  </template>
</odoo>

Key insight: The docs variable is automatically injected by the report engine — it contains the recordset of whatever records the user selected before clicking Print. You don't need to query for it. The doc_ids and doc_model variables are also available if you need them.

Step 3: Styling for Print

QWeb reports render through wkhtmltopdf, which means your CSS needs to work in a WebKit context with print media rules. The most common styling issues are page breaks, table headers that repeat across pages, and margins that conflict with the external layout.

Add a <style> block inside your template's <div class="page">:

<style>
  .page { font-size: 12px; }

  /* Force table header to repeat on every page */
  thead { display: table-header-group; }

  /* Prevent rows from splitting across pages */
  tr { page-break-inside: avoid; }

  /* Add space between multiple pickings */
  .page + .page { page-break-before: always; }

  /* Barcode column sizing */
  td:nth-child(2) { width: 180px; }

  /* Right-align quantity column */
  .text-end { text-align: right; }
</style>

Two rules that save you from the most common wkhtmltopdf headaches: thead { display: table-header-group; } makes table headers repeat on every page (they don't by default), and tr { page-break-inside: avoid; } prevents rows from being split mid-row at page boundaries.

Step 4: Adding Computed Fields with an Abstract Model

Sometimes you need data that doesn't exist as a field on the model — aggregated totals, conditional formatting logic, or data pulled from related records that would be ugly to compute inline in QWeb. For these cases, create an abstract report model.

# models/report_pick_list.py
from odoo import api, models


class ReportPickList(models.AbstractModel):
    _name = 'report.my_module.report_pick_list'
    _description = 'Pick List Report'

    @api.model
    def _get_report_values(self, docids, data=None):
        pickings = self.env['stock.picking'].browse(docids)

        # Pre-compute bin location summaries
        bin_summary = {}
        for picking in pickings:
            bins = {}
            for ml in picking.move_line_ids:
                loc = ml.location_id.name
                bins.setdefault(loc, []).append(ml)
            bin_summary[picking.id] = bins

        return {
            'doc_ids': docids,
            'doc_model': 'stock.picking',
            'docs': pickings,
            'bin_summary': bin_summary,
        }

The _name of the abstract model must be report.<report_name> — that's how Odoo links it to your report action. When this model exists, the report engine calls _get_report_values() instead of using the default context, and everything you return becomes available in the QWeb template.

In the template, you can now access the computed data:

<t t-set="bins" t-value="bin_summary.get(picking.id, {})"/>
<t t-foreach="bins.items()" t-as="bin_item">
  <h4 class="mt-3">
    Bin: <t t-esc="bin_item[0]"/>
    (<t t-esc="len(bin_item[1])"/> items)
  </h4>
  <!-- render items grouped by bin -->
</t>

Debugging QWeb Reports

Report debugging in Odoo is notoriously frustrating because errors often produce a generic "Report not found" or a blank PDF with no useful traceback. Here's the workflow that saves the most time:

  1. Render as HTML first. Change the report URL from /report/pdf/ to /report/html/ in your browser. This skips wkhtmltopdf entirely and gives you the raw HTML output with browser dev tools available for CSS debugging.
  2. Check the template name. The report_name in your ir.actions.report must exactly match the t-name (or id) of your QWeb template, including the module prefix. A mismatch produces a "Report not found" error with no further clues.
  3. Watch for None values. QWeb's t-esc will happily render None as the string "None". Use t-esc="field or ''" for optional fields, or wrap in a t-if to hide the entire block when the value is falsy.
  4. Enable wkhtmltopdf logging. Set --log-level=debug in your Odoo config and look for wkhtmltopdf stderr output. Common issues: external CSS/JS that wkhtmltopdf can't fetch (it runs headless without session cookies), and images referenced by relative URLs that resolve incorrectly.

For rapid iteration, the fastest feedback loop is:

# Restart Odoo with module update
./odoo-bin -u my_module --stop-after-init

# Then start normally and test the HTML version
./odoo-bin

# In browser, navigate to:
# http://localhost:8069/report/html/my_module.report_pick_list/<picking_id>

Inheriting and Extending Existing Reports

You don't always need a new report from scratch. Often you want to modify an existing one — adding a field to the invoice PDF, changing the layout of the delivery slip, or inserting a legal disclaimer at the bottom of a quotation. QWeb supports template inheritance with t-call and XPath-style overrides.

<!-- Extend the default invoice report to add payment QR code -->
<template id="report_invoice_payment_qr"
          inherit_id="account.report_invoice_document">
  <xpath expr="//div[@id='informations']" position="after">
    <div class="mt-2" t-if="o.partner_id.country_id.code == 'IN'">
      <p><strong>Scan to Pay:</strong></p>
      <img t-att-src="'/report/barcode/?barcode_type=QR&amp;value=%s&amp;width=150&amp;height=150' % o.get_upi_payment_string()"
           style="width:120px; height:120px;"/>
    </div>
  </xpath>
</template>

The inherit_id attribute points to the original template, and the XPath expression targets the exact DOM node you want to modify. The position attribute can be after, before, inside, replace, or attributes.

This is the cleanest way to customize reports because your changes survive Odoo version upgrades — the base template changes, and your XPath extension re-applies on top. If you copy-paste the entire base template into your module instead, you'll have to manually merge upstream changes every time you upgrade.

Paper Format and Page Setup

If your report needs a non-standard page size (shipping labels, receipt printers, landscape orientation), define a custom paper format:

<record id="paper_pick_list" model="report.paperformat">
  <field name="name">Pick List A4 Landscape</field>
  <field name="format">A4</field>
  <field name="orientation">Landscape</field>
  <field name="margin_top">25</field>
  <field name="margin_bottom">20</field>
  <field name="margin_left">10</field>
  <field name="margin_right">10</field>
  <field name="header_line">False</field>
  <field name="header_spacing">20</field>
</record>

<!-- Link it to the report action -->
<record id="action_report_pick_list" model="ir.actions.report">
  <field name="paperformat_id" ref="paper_pick_list"/>
</record>

The margin values are in millimeters. Getting them right usually takes a few print-test cycles — start generous and tighten. If your content overflows into the header/footer zone, increase header_spacing before reducing content font size.

Wrapping Up

Custom QWeb reports are one of the highest-value customizations you can make in an Odoo deployment. A well-designed pick list saves warehouse staff minutes per order. A branded invoice with payment QR codes reduces days-sales-outstanding. A compliance report that pulls the right fields automatically saves your finance team hours of manual assembly every month.

The pattern is always the same: define the report action, build the QWeb template, optionally add an abstract model for computed data, and iterate using HTML rendering before switching to PDF. Once you've built one, every subsequent report follows the same structure with different data and layout.