This article is currently available in English only. Translation coming soon.
Odoo QWeb Reports: PDF Generation, Layout, and wkhtmltopdf Headers
QWeb reports are how every Odoo invoice, sales order, picking list, and customer-facing PDF gets generated. The basic mechanism is simple — write QWeb (an HTML template language), Odoo converts it to PDF via wkhtmltopdf — but the details of page layout, multi-page header/footer rendering, multilingual reports, and the web.external_layout inheritance pattern catch developers off guard. We've built dozens of custom Odoo reports for clients across industries; this article distills the patterns you actually need.
Key Takeaways
- QWeb is QWeb — same template language used in views, but rendered server-side for PDF
web.external_layoutis the canonical letterhead wrapper — extend it, don't replace- wkhtmltopdf renders the header/footer separately, with its own context — gotcha #1
- Multi-language reports need
t-options="{'lang': record.partner_id.lang}"on<t t-call>- For complex layouts (precise positioning), Chromium-based rendering beats wkhtmltopdf
- Page breaks:
style="page-break-before: always"on a div forces a break- Debugging: render as HTML first (
?report_type=qweb-html) before PDF
Anatomy of a QWeb report
A report in Odoo has three pieces:
- The
ir.actions.reportrecord that ties the report to a model and action button - The QWeb template that defines the layout
- (Optional) Python helpers in a custom report controller
<!-- ir.actions.report definition -->
<record id="action_report_my_invoice" model="ir.actions.report">
<field name="name">Custom Invoice</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">my_module.report_my_invoice</field>
<field name="report_file">my_module.report_my_invoice</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="base.paperformat_us"/>
</record>
<!-- The QWeb template -->
<template id="report_my_invoice">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<h2>Invoice <span t-field="o.name"/></h2>
<div class="row">
<div class="col-6">
<strong>Customer:</strong>
<div t-field="o.partner_id"
t-options='{"widget": "contact"}'/>
</div>
<div class="col-6">
<strong>Date:</strong> <span t-field="o.invoice_date"/>
</div>
</div>
<table class="table table-sm">
<thead>
<tr>
<th>Description</th>
<th class="text-end">Qty</th>
<th class="text-end">Price</th>
<th class="text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="o.invoice_line_ids" t-as="line">
<td t-field="line.name"/>
<td class="text-end" t-field="line.quantity"/>
<td class="text-end" t-field="line.price_unit"/>
<td class="text-end" t-field="line.price_subtotal"/>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
Key observations:
web.html_containeris the outer HTML scaffold (DOCTYPE, head, body)web.external_layoutwraps with the company letterhead (header, footer, page borders)<div class="page">defines the content area within the letterhead
The web.external_layout pattern
Odoo ships several "external layout" variants — the company's branded letterhead. The default is web.external_layout which dispatches to one of:
web.external_layout_standard— minimal header/footerweb.external_layout_background— full background imageweb.external_layout_boxed— bordered designweb.external_layout_clean— modern minimalweb.external_layout_striped— accent striping
Configurable per company: Settings > Companies > External Layout.
For most custom reports, calling web.external_layout is the right move — it inherits the company-level letterhead choice automatically. Only override if your report needs a fundamentally different layout (e.g., a half-page receipt or a specialized form).
Customizing the letterhead
<template id="external_layout_custom" inherit_id="web.external_layout_standard">
<xpath expr="//div[hasclass('header')]" position="replace">
<div class="header">
<img t-att-src="image_data_uri(company.logo)"
style="max-height: 80px;"/>
<div class="text-end">
<strong t-field="company.name"/><br/>
<span t-field="company.partner_id"
t-options='{"widget": "contact", "no_marker": True}'/>
</div>
</div>
</xpath>
</template>
wkhtmltopdf vs Chromium rendering
Odoo historically uses wkhtmltopdf (a wrapper around WebKit) for PDF generation. It's fast, lightweight, but renders an older HTML/CSS spec — flexbox is partial, CSS Grid is missing, modern fonts can be tricky.
Odoo 17+ added optional Chromium-based rendering via chromium system parameter. Trade-offs:
| Aspect | wkhtmltopdf | Chromium |
|---|---|---|
| HTML/CSS support | WebKit ~2014 era | Full modern |
| Speed | Fast (~500ms typical) | Slower (~2s typical) |
| Memory | Low | High (~300MB per process) |
| Header/footer | Separate render context (gotcha) | Same context |
| Font rendering | Variable | Consistent |
| Page-break control | CSS only | Better support |
For simple reports, wkhtmltopdf is fine. For complex layouts (precise positioning, modern CSS, multi-column), Chromium wins. Configure via report.url system parameter to use Chromium.
The wkhtmltopdf header/footer gotcha
wkhtmltopdf renders the header and footer in separate processes from the body. They don't share variables. If your header references o.name, you need to pass it explicitly — otherwise the header renders with empty values.
The pattern:
<template id="external_layout">
<t t-call="web.html_container">
<!-- This part rendered as the body -->
<div class="article">
<t t-out="0"/> <!-- inserts the calling template's content -->
</div>
<!-- This part rendered as the header (separate process) -->
<div class="header">
<span t-out="company.name"/>
</div>
</t>
</template>
When you pass company/document context, ensure it's accessible in both renders. Use data-oe-model and data-oe-id attributes on the body, then your header template can re-fetch from the URL parameters.
For complex headers/footers, switch to Chromium rendering — single context, no gotchas.
Multi-language reports
A report sent to a French customer should render in French. Odoo handles this via t-options="{'lang': lang}":
<t t-call="web.external_layout"
t-options='{"lang": o.partner_id.lang or "en_US"}'>
<div class="page">
...
</div>
</t>
The lang option flows through to all t-field and translatable strings inside. For reports invoked via the report engine, you can also set the language at the action level via context.
For multi-language strings in the template itself:
<span><t t-translation="off">Invoice #</t><span t-field="o.name"/></span>
<span t-esc="_t('Total')" />: <span t-field="o.amount_total"/>
_t() is the translation marker; t-translation="off" excludes a span from translation.
Page breaks
Force a page break before an element:
<div style="page-break-before: always">
<h2>New Page Section</h2>
...
</div>
Avoid breaking inside an element:
<div style="page-break-inside: avoid">
<h3>Keep this whole section together</h3>
<p>...</p>
</div>
For more control (Chromium only), use the modern break-before, break-inside, break-after properties.
Tables across pages
Tables that span multiple pages need explicit <thead> (rendered on every page) and <tbody>. wkhtmltopdf is sometimes inconsistent here — test with at least 50 rows of data to verify page-break behavior.
For complex tables (totals row, subtotals per group), Chromium rendering is more reliable.
Debugging QWeb reports
Three techniques that save hours:
1. Render as HTML first
Append ?report_type=qweb-html to the report URL. Odoo renders the QWeb to HTML in your browser. You can view source, inspect with devtools, and iterate fast — no PDF generation latency.
2. Inline raw values
Inside a template, dump variables to see what's available:
<pre t-esc="docs[0].read()"/>
<pre t-esc="env.context"/>
Then view as HTML.
3. Debug logger
In the Python report controller (if you have one):
import logging
_logger = logging.getLogger(__name__)
def _render_qweb_pdf(self, ...):
_logger.debug("Report context: %s", self.env.context)
return super()._render_qweb_pdf(...)
Common report tasks
Show company logo
<img t-att-src="image_data_uri(company.logo)"
style="max-height: 60px;"/>
Format currency
<span t-field="o.amount_total"
t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
Format date in user's locale
<span t-field="o.invoice_date"
t-options='{"widget": "date"}'/>
Show address block
<div t-field="o.partner_id"
t-options='{"widget": "contact", "fields": ["address", "name", "phone"]}'/>
Conditional rendering
<t t-if="o.amount_due > 0">
<p style="color: red;">Payment overdue!</p>
</t>
Show subtotal and tax breakdown
<table>
<tr>
<td>Subtotal:</td>
<td t-field="o.amount_untaxed"/>
</tr>
<tr t-foreach="o.amount_by_group" t-as="tax_group">
<td><span t-esc="tax_group[0]"/></td>
<td><span t-esc="tax_group[1]"/></td>
</tr>
<tr>
<td><strong>Total:</strong></td>
<td><strong t-field="o.amount_total"/></strong></td>
</tr>
</table>
Print 1-of-3 pagination
<div class="text-end">
Page <span class="page"/> of <span class="topage"/>
</div>
The page and topage elements are filled in by wkhtmltopdf at render time. They only work inside the header or footer template, not the body.
Frequently Asked Questions
How do I make a report download with a specific filename?
Set print_report_name on the ir.actions.report to a Python expression:
<field name="print_report_name">'Invoice - %s' % object.name</field>
object is the document; the result is used as the PDF filename.
Can I generate Excel/CSV reports instead of PDF?
Yes. For Excel, use the xlsxwriter library in a Python report controller. For CSV, set report_type to qweb-text and emit comma-separated values. There are also OCA modules (report_xlsx, report_xls) that provide Excel-specific helpers.
How do I add a watermark like "DRAFT" on unconfirmed records?
Add a positioned div to the layout template, conditional on state:
<t t-if="o.state == 'draft'">
<div style="position: fixed; top: 50%; left: 50%;
transform: rotate(-30deg); font-size: 100px;
color: rgba(255,0,0,0.2); z-index: -1;">
DRAFT
</div>
</t>
Why does my report look different in the browser preview vs the downloaded PDF?
Browser preview uses your browser's HTML rendering; PDF uses wkhtmltopdf (older WebKit) or Chromium. The most common discrepancy is CSS — modern flex/grid renders differently. Test the actual PDF, not just the HTML preview.
How do I print multiple records into one PDF?
Pass multiple records via docs. The template's t-foreach="docs" t-as="o" loops over them, each in its own page. Combine with <div style="page-break-before: always"> between records to ensure clean page breaks.
How do I render a barcode or QR code in a QWeb report?
Use Odoo's barcode controller as the image source:
<img t-att-src="'/report/barcode/?type=QR&value=%s&width=200&height=200' % o.name"/>
The barcode controller supports QR, Code128, EAN13, and several others. For high-density QR with custom error correction, use a Python library (qrcode) and embed as base64 data URI.
Can I add a digital signature to a generated PDF?
Yes, with a post-processing step. Generate the PDF normally, then run it through a Python library (pyHanko, reportlab's signature module) before delivering. Some clients use the signature placeholder pattern: include a styled signature block in the QWeb (with line, name, date) and have the recipient sign separately via DocuSign-or-equivalent.
QWeb reports are how Odoo's customer-facing documents take shape. ECOSIRE's Odoo customization service builds custom reports for invoices, contracts, statements, and industry-specific documents. See our Odoo developer hire service for senior Python+QWeb developers, or browse our Odoo modules catalog for examples of well-structured report modules.
تحریر
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Odoo ERP کے ساتھ اپنے کاروبار کو تبدیل کریں
آپ کے کاموں کو ہموار کرنے کے لیے ماہر Odoo کا نفاذ، حسب ضرورت، اور معاونت۔
متعلقہ مضامین
How to Add a Custom Button to an Odoo Form View (2026)
Add custom action buttons to Odoo 19 form views: Python action method, view inheritance, conditional visibility, confirmation dialogs. Production-tested.
How to Add a Custom Field in Odoo Without Studio (2026)
Add custom fields via custom module in Odoo 19: model inheritance, view extension, computed fields, store/non-store decisions. Code-first, version-controlled.
How to Add a Custom Report in Odoo Using External Layout
Build a branded PDF report in Odoo 19 using web.external_layout: QWeb template, paperformat, action binding. With print logo + footer overrides.