Learn how to build professional PDF reports in Odoo 19 using QWeb. This guide covers everything from creating your first report to advanced techniques like reusable templates, custom paper formats, barcodes, multilingual reports, RTL support, and performance optimization.


Introduction

Every Odoo application relies on reports.

Whether you're printing:

  • Sales Quotations
  • Customer Invoices
  • Purchase Orders
  • Delivery Slips
  • Manufacturing Orders
  • Inventory Labels
  • Payroll Documents
  • Custom Certificates

you're using QWeb, Odoo's XML-based templating engine.

Although creating a basic report is straightforward, building maintainable, scalable, and professional reports requires understanding how the entire reporting pipeline works.

In this tutorial, we'll build a complete report from scratch while exploring best practices used by experienced Odoo developers.

By the end of this guide, you'll be able to:

  • Create custom PDF reports
  • Understand how Odoo generates PDFs
  • Design reusable templates
  • Display relational data
  • Build dynamic tables
  • Format currencies and dates correctly
  • Add company logos, images, barcodes, and QR codes
  • Create custom paper formats
  • Pass additional data from Python
  • Optimize report performance
  • Debug common QWeb issues

How Odoo Generates a PDF

Before writing any XML, it's important to understand the report generation process.

User clicks "Print"
        │
        ▼
ir.actions.report
        │
        ▼
Report Model (_get_report_values)
        │
        ▼
QWeb Template
        │
        ▼
Rendered HTML
        │
        ▼
wkhtmltopdf
        │
        ▼
PDF Download

Enter fullscreen mode Exit fullscreen mode

Each step has a specific responsibility:

Component Responsibility
ir.actions.report Registers the report
Report Model Prepares business data
QWeb Generates HTML
wkhtmltopdf Converts HTML into PDF

Understanding this workflow makes debugging significantly easier.


Project Structure

A clean module structure helps keep reports maintainable.

my_module/
│
├── models/
│   └── sale_order.py
│
├── report/
│   ├── sale_order_report.xml
│   ├── report_action.xml
│   └── paperformat.xml
│
├── security/
│
├── views/
│
└── __manifest__.py

Enter fullscreen mode Exit fullscreen mode

Keeping reports inside a dedicated report directory is considered best practice.


Step 1 — Create the Report Action

Every report starts with an ir.actions.report.

This record tells Odoo:

  • which model the report belongs to
  • which template should be rendered
  • whether it should generate HTML or PDF
  • where the report appears in the UI
<record id="action_sale_order_custom" model="ir.actions.report">

    <field name="name">Sale Order PDF</field>

    <field name="model">sale.order</field>

    <field name="report_type">qweb-pdf</field>

    <field name="report_name">
        my_module.sale_order_report
    </field>

    <field name="report_file">
        my_module.sale_order_report
    </field>

    <field name="binding_model_id"
           ref="sale.model_sale_order"/>

    <field name="binding_type">
        report
    </field>

</record>

Enter fullscreen mode Exit fullscreen mode

After installing the module, your report automatically appears inside the Print menu.

💡 Pro Tip: Always use descriptive report IDs. It makes debugging much easier.


Step 2 — Create the QWeb Template

The template defines the visual appearance of your report.

<template id="sale_order_report">

<t t-call="web.html_container">

<t t-foreach="docs" t-as="o">

<t t-call="web.external_layout">

<div class="page">

<h2>Sale Order</h2>

<p>

<strong>Customer:</strong>

<span t-field="o.partner_id"/>

</p>

<p>

<strong>Date:</strong>

<span t-field="o.date_order"/>

</p>

</div>

</t>

</t>

</t>

</template>

Enter fullscreen mode Exit fullscreen mode

Although this template is small, it demonstrates the three most important building blocks of every QWeb report.


Understanding Every XML Tag

web.html_container

Creates the HTML document wrapper required before converting HTML into PDF.

<t t-call="web.html_container">

Enter fullscreen mode Exit fullscreen mode


web.external_layout

Adds the standard company layout automatically.

It includes:

  • Company Logo
  • Company Address
  • Header
  • Footer
  • Page Numbers

Without this layout your report will not look like a standard Odoo document.


div.page

Every printable page should be wrapped inside:

<div class="page">

Enter fullscreen mode Exit fullscreen mode

This tells wkhtmltopdf where pages begin.


Accessing Data

When multiple records are printed, Odoo sends them to the template inside the variable:

docs

Enter fullscreen mode Exit fullscreen mode

This loop iterates over every selected record.

<t t-foreach="docs" t-as="o">

Enter fullscreen mode Exit fullscreen mode

Think of it like Python:

for o in docs:

Enter fullscreen mode Exit fullscreen mode


Displaying Fields

The preferred way to display model fields is:

<span t-field="o.partner_id"/>

Enter fullscreen mode Exit fullscreen mode

Why?

Because t-field automatically applies:

  • Translations
  • Currency formatting
  • Date formatting
  • Timezone conversion
  • Decimal precision

Whenever you're displaying an Odoo field, prefer t-field over t-out.


t-field vs t-out

t-field

<span t-field="o.amount_total"/>

Enter fullscreen mode Exit fullscreen mode

Automatically formats values.


t-out

<t t-out="o.amount_total"/>

Enter fullscreen mode Exit fullscreen mode

Outputs the raw evaluated expression.

Use it when displaying Python expressions instead of model fields.


Creating Tables

Most reports display one2many records.

Example:

<table class="table">

<thead>

<tr>

<th>Product</th>

<th>Qty</th>

<th>Unit Price</th>

<th>Total</th>

</tr>

</thead>

<tbody>

<tr t-foreach="o.order_line" t-as="line">

<td>

<span t-field="line.product_id"/>

</td>

<td>

<span t-field="line.product_uom_qty"/>

</td>

<td>

<span t-field="line.price_unit"/>

</td>

<td>

<span t-field="line.price_total"/>

</td>

</tr>

</tbody>

</table>

Enter fullscreen mode Exit fullscreen mode

This works exactly like iterating over a Python list.


Conditional Rendering

Display content only when necessary.

<t t-if="o.state == 'sale'">

<div class="alert alert-success">

Confirmed Order

</div>

</t>

Enter fullscreen mode Exit fullscreen mode

Else:

<t t-if="o.amount_total > 1000">

VIP Customer

</t>

<t t-else="">

Regular Customer

</t>

Enter fullscreen mode Exit fullscreen mode


Images

Company Logo

<img
t-if="o.company_id.logo"
t-att-src="image_data_uri(o.company_id.logo)"
style="height:80px;"/>

Enter fullscreen mode Exit fullscreen mode

Product Image

<img
t-if="line.product_id.image_128"
t-att-src="image_data_uri(line.product_id.image_128)"
style="width:60px;"/>

Enter fullscreen mode Exit fullscreen mode


Barcodes

Odoo can generate barcodes without additional libraries.

<img
t-att-src="'/report/barcode/Code128/%s' % o.name"/>

Enter fullscreen mode Exit fullscreen mode

Useful for:

  • Inventory
  • Manufacturing
  • Shipping
  • Warehouses

QR Codes

Generating QR codes follows the same principle.

Perfect for:

  • Payment Links
  • Product URLs
  • Customer Verification
  • Digital Certificates

Custom Paper Formats

Need receipts?

Shipping labels?

A5?

Letter?

Create a custom paper format.

<record id="paperformat_custom"
        model="report.paperformat">

    <field name="name">A5 Landscape</field>

    <field name="format">A5</field>

    <field name="orientation">Landscape</field>

</record>

Enter fullscreen mode Exit fullscreen mode

Then assign it to your report action.


Passing Extra Data from Python

Complex calculations belong in Python—not XML.

class SaleOrderReport(models.AbstractModel):

    _name = "report.my_module.sale_order_report"

    def _get_report_values(self, docids, data=None):

        docs = self.env["sale.order"].browse(docids)

        return {

            "docs": docs,

            "grand_total": sum(
                docs.mapped("amount_total")
            ),

        }

Enter fullscreen mode Exit fullscreen mode

Inside QWeb:

<t t-out="grand_total"/>

Enter fullscreen mode Exit fullscreen mode

💡 Best Practice: Keep business logic inside Python. Use QWeb only for presentation.


Calling Reports from Python

Generate reports programmatically.

return self.env.ref(
    "my_module.action_sale_order_custom"
).report_action(self)

Enter fullscreen mode Exit fullscreen mode

Perfect for:

  • Buttons
  • Wizards
  • Scheduled Actions
  • Automated Workflows

Reusable Templates

Instead of duplicating headers or tables, extract them into reusable templates.

<t t-call="my_module.report_header"/>

Enter fullscreen mode Exit fullscreen mode

This dramatically reduces maintenance.


Multilingual Reports

Odoo automatically translates field labels according to the customer's language.

Always use translated strings whenever possible.


RTL Support

Need Arabic reports?

Add RTL CSS.

body{

direction:rtl;

text-align:right;

}

Enter fullscreen mode Exit fullscreen mode

Use fonts that support Arabic glyphs for consistent rendering.


Performance Tips

Large reports can become slow.

Follow these recommendations:

  • Move calculations into Python.
  • Avoid nested loops.
  • Minimize database queries.
  • Reuse templates.
  • Cache expensive computations.
  • Use mapped() instead of repeated traversals.

Common Mistakes

❌ Putting business logic inside XML.

❌ Using t-out instead of t-field.

❌ Forgetting web.external_layout.

❌ Missing <div class="page">.

❌ Large nested loops.

❌ Hardcoded text instead of translations.


Debugging Checklist

If the PDF is blank:

  • Check XML syntax.
  • Verify the template ID.
  • Confirm the report action.
  • Test HTML rendering.
  • Review server logs.

If CSS is broken:

Remember that wkhtmltopdf doesn't support every modern CSS feature.

Keep layouts simple.


Best Practices

✅ Use descriptive template names.

✅ Keep XML focused on presentation.

✅ Put calculations in Python.

✅ Reuse templates.

✅ Test with realistic data.

✅ Support multiple languages.

✅ Follow Odoo coding standards.


Conclusion

QWeb is much more than an XML templating engine—it's the foundation of every printable business document in Odoo.

By understanding the entire reporting pipeline, structuring templates correctly, separating business logic from presentation, and following best practices, you can build reports that are scalable, maintainable, and production-ready.

Whether you're creating invoices, quotations, delivery slips, manufacturing work orders, inventory labels, or custom business documents, mastering QWeb is an essential skill for every Odoo developer.

If you're just starting with Odoo reporting, begin with a simple template, experiment with dynamic fields and loops, then progressively explore advanced topics like reusable templates, multilingual reports, RTL support, custom paper formats, and report optimization.

Happy coding! 🚀