Mastering QWeb PDF Reports in Odoo 19: From Beginner to Advanced
> 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
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
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>
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>
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">
## `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">
This tells wkhtmltopdf where pages begin.
# Accessing Data
When multiple records are printed, Odoo sends them to the template inside the variable:
docs
This loop iterates over every selected record.
<t t-foreach="docs" t-as="o">
Think of it like Python:
for o in docs:
# Displaying Fields
The preferred way to display model fields is:
<span t-field="o.partner_id"/>
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"/>
Automatically formats values.
## t-out
<t t-out="o.amount_total"/>
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>
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>
Else:
<t t-if="o.amount_total > 1000">
VIP Customer
</t>
<t t-else="">
Regular Customer
</t>
# Images
Company Logo
<img
t-if="o.company_id.logo"
t-att-src="image_data_uri(o.company_id.logo)"
style="height:80px;"/>
Product Image
<img
t-if="line.product_id.image_128"
t-att-src="image_data_uri(line.product_id.image_128)"
style="width:60px;"/>
# Barcodes
Odoo can generate barcodes without additional libraries.
<img
t-att-src="'/report/barcode/Code128/%s' % o.name"/>
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>
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")
),
}
Inside QWeb:
<t t-out="grand_total"/>
💡 **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)
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"/>
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;
}
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! 🚀