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! 🚀
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.