Ad – 728×90
🔧 Backend Development

Odoo QWeb PDF Reports – Creating Custom Reports

Odoo generates PDF reports by rendering QWeb XML templates into HTML and then passing that HTML to a headless browser (wkhtmltopdf in older versions, Chromium in Odoo 17+) to produce the final PDF. Understanding the pipeline — from defining an ir.actions.report record to writing a QWeb template with loops and field rendering — lets you build professional-grade printable documents entirely from within your module.

⏱️ 25 min 🎯 Intermediate 📅 Updated 2026

📋 What You Will Learn

  • How Odoo generates PDF reports end-to-end
  • Creating a report action with ir.actions.report
  • Writing a QWeb report template with loops and conditions
  • Rendering field values with t-field, t-out, and t-esc
  • Styling reports with Bootstrap and custom CSS
  • Configuring paper format (A4, margins, landscape)
  • Triggering a PDF download from Python code

How Odoo Generates PDF Reports

The report pipeline works in three stages:

  1. Action trigger: User clicks "Print" in a form/list view. Odoo finds the matching ir.actions.report record.
  2. Template rendering: Odoo fetches the recordset, passes it as docs to the QWeb template, and renders the template to HTML.
  3. PDF conversion: The rendered HTML is sent to a headless Chromium instance (Odoo 17+), which applies CSS and produces the final PDF binary.

All report templates live in your module's report/ directory (by convention) and are loaded via your module manifest's data list.

Report Action (ir.actions.report)

Every report needs an ir.actions.report record that links the action to the model and the QWeb template name:

XML
<odoo>
    <record id="action_report_library_loan_receipt" model="ir.actions.report">
        <field name="name">Loan Receipt</field>
        <field name="model">library.loan</field>
        <field name="report_type">qweb-pdf</field>

        <!-- Must match the template's t-name exactly -->
        <field name="report_name">library_management.report_loan_receipt</field>

        <!-- Optional: filename for the downloaded PDF -->
        <field name="report_file">library_management.report_loan_receipt</field>
        <field name="print_report_name">'Loan Receipt - ' + object.name</field>

        <!-- Bind to the model so it appears in the Print menu -->
        <field name="binding_model_id" ref="model_library_loan"/>
        <field name="binding_type">report</field>

        <!-- Optional: paper format (A4, Letter, etc.) -->
        <field name="paperformat_id" ref="base.paperformat_euro"/>
    </record>
</odoo>
FieldPurpose
modelThe model whose records are passed to the template
report_typeqweb-pdf for PDF, qweb-html for HTML preview
report_nameThe t-name of the QWeb template (module.template_id format)
print_report_namePython expression for the filename (has access to object)
binding_model_idThe model to bind to — shows the report in the Print button dropdown
Ad – 336×280

QWeb Report Template

A QWeb report template is an XML template with a specific structure. The outer template wraps a document template, which wraps individual page templates:

XML
<odoo>
    <template id="report_loan_receipt">
        <!-- t-call loads the standard report layout (header/footer/logo) -->
        <t t-call="web.html_container">
            <t t-foreach="docs" t-as="loan">
                <!-- One page per loan record -->
                <t t-call="web.external_layout">
                    <div class="page">
                        <h2>Library Loan Receipt</h2>
                        <div class="row mt-4">
                            <div class="col-6">
                                <strong>Member:</strong>
                                <!-- t-field renders the value with proper ORM formatting -->
                                <span t-field="loan.borrower_id.name"/>
                            </div>
                            <div class="col-6">
                                <strong>Date:</strong>
                                <span t-field="loan.borrow_date"/>
                            </div>
                        </div>

                        <!-- Conditional section -->
                        <t t-if="loan.days_overdue > 0">
                            <div class="alert alert-warning mt-3">
                                This loan is <strong t-out="loan.days_overdue"/> days overdue.
                            </div>
                        </t>

                        <!-- Book details table -->
                        <table class="table table-sm mt-4">
                            <thead>
                                <tr>
                                    <th>Book Title</th>
                                    <th>ISBN</th>
                                    <th>Due Date</th>
                                </tr>
                            </thead>
                            <tbody>
                                <tr>
                                    <td t-field="loan.book_id.title"/>
                                    <td t-field="loan.book_id.isbn"/>
                                    <td t-field="loan.due_date"/>
                                </tr>
                            </tbody>
                        </table>

                        <p class="mt-4 text-muted">
                            Please return the book in good condition by the due date.
                        </p>
                    </div>
                </t>
            </t>
        </t>
    </template>
</odoo>

Accessing Record Data in Templates

The template receives a docs variable which is the recordset passed to the report. You loop over it with t-foreach and render field values with t-field or t-out:

DirectiveUsageHTML escaping
t-field="record.field"Renders the field using Odoo's widget (dates formatted, currency symbol, etc.)Yes — safe
t-out="expression"Renders a Python expression — escaped HTMLYes — safe
t-esc="expression"Same as t-out (older syntax, still works)Yes — safe
t-raw="expression"Renders HTML markup as-is (use only for trusted content)No — unsafe
XML
<!-- Good: use t-field for ORM fields — gets proper date/currency formatting -->
<span t-field="loan.borrow_date"/>
<span t-field="loan.book_id.price" t-options='{"widget": "monetary", "display_currency": loan.currency_id}'/>

<!-- Good: use t-out for computed values and Python expressions -->
<span t-out="loan.days_overdue"/>
<span t-out="len(loan.book_ids)"/>

<!-- String formatting in the template -->
<p t-out="'Receipt #' + str(loan.id)"/>

<!-- Conditional rendering -->
<t t-if="loan.borrower_id.country_id">
    <span t-field="loan.borrower_id.country_id.name"/>
</t>

Report Styling with Bootstrap

Odoo's report layout includes Bootstrap 4 CSS. You can use any Bootstrap utility class directly in your template. For module-specific styles, add a <style> block inside the template or reference a separate CSS asset:

XML
<template id="report_loan_receipt">
    <t t-call="web.html_container">
        <t t-foreach="docs" t-as="loan">
            <t t-call="web.external_layout">
                <div class="page">
                    <!-- Custom styles scoped to this report -->
                    <style>
                        .loan-header { border-bottom: 2px solid #714B67; margin-bottom: 20px; }
                        .overdue-badge { background-color: #dc3545; color: white;
                                         padding: 4px 8px; border-radius: 4px; }
                        .signature-line { border-top: 1px solid #ccc; margin-top: 60px;
                                          width: 200px; text-align: center; }
                    </style>

                    <div class="loan-header d-flex justify-content-between">
                        <h2>Loan Receipt</h2>
                        <t t-if="loan.days_overdue > 0">
                            <span class="overdue-badge">OVERDUE</span>
                        </t>
                    </div>
                    <!-- content... -->
                </div>
            </t>
        </t>
    </t>
</template>

Paper Format

Paper formats control the page dimensions, margins, orientation, and header/footer height. Built-in formats include base.paperformat_euro (A4) and base.paperformat_us_letter. You can define a custom one:

XML
<odoo>
    <record id="paperformat_library_receipt" model="report.paperformat">
        <field name="name">Library Receipt (A5)</field>
        <field name="default">False</field>
        <field name="format">A5</field>
        <field name="page_height">0</field>
        <field name="page_width">0</field>
        <field name="orientation">Portrait</field>
        <field name="margin_top">15</field>
        <field name="margin_bottom">10</field>
        <field name="margin_left">10</field>
        <field name="margin_right">10</field>
        <field name="header_line">False</field>
        <field name="header_spacing">35</field>
        <field name="dpi">90</field>
    </record>
</odoo>

Triggering Reports

You can trigger a PDF report download from Python code — useful for triggering from a button, wizard, or automated action:

Python
def action_print_receipt(self):
    """Return an action that triggers the PDF download."""
    self.ensure_one()
    return self.env.ref(
        'library_management.action_report_library_loan_receipt'
    ).report_action(self)

# Generate the PDF binary programmatically (e.g. to attach to an email)
def _generate_receipt_pdf(self):
    report = self.env.ref('library_management.action_report_library_loan_receipt')
    pdf_content, content_type = self.env['ir.actions.report']._render_qweb_pdf(
        report,
        res_ids=self.ids,
    )
    return pdf_content  # bytes

# Attach a generated PDF to a record as an ir.attachment
def _attach_receipt_pdf(self):
    self.ensure_one()
    pdf_content = self._generate_receipt_pdf()
    attachment = self.env['ir.attachment'].create({
        'name': f'Receipt-{self.name}.pdf',
        'type': 'binary',
        'datas': base64.b64encode(pdf_content),
        'res_model': self._name,
        'res_id': self.id,
        'mimetype': 'application/pdf',
    })
    return attachment

Summary

📋 Key Points

  • Reports need two things: an ir.actions.report record and a QWeb template
  • report_name in the action must exactly match the template's id in module.id format
  • The template receives docs — the recordset; loop with t-foreach="docs" t-as="doc"
  • Use t-field for ORM fields (proper date/currency formatting); t-out for Python expressions
  • Bootstrap 4 is available in all report templates — use its grid and utility classes freely
  • Define a custom report.paperformat record to control page size, margins, and orientation
  • Call report_action(self) from Python to trigger a download; use _render_qweb_pdf to get raw bytes

FAQ

Why does my PDF look different from the HTML preview? +

The HTML preview renders in your browser's engine, while the PDF uses Chromium headless with a subset of CSS. Some CSS properties (flexbox, grid, certain transitions) may not render identically. Always test your report by actually downloading the PDF, not just the HTML preview. Using Bootstrap's table and grid system — which Odoo has already tested for PDF compatibility — helps avoid rendering issues.

How do I add a page number and total page count to the footer? +

Odoo uses the web.external_layout template which includes a footer. You can override the footer template or add a custom one. For page numbers, you need CSS counter-based content since QWeb runs in a browser engine: page { @bottom-center { content: counter(page) ' / ' counter(pages); } } — this is CSS Paged Media, supported by Chromium's print engine.

Can I generate a report for multiple records at once? +

Yes. The docs variable in the template is a recordset — it may contain one or many records. The standard pattern is to loop with t-foreach="docs" t-as="doc" and wrap each iteration in t-call="web.external_layout" to create one page (or section) per record. If you select multiple records in a list and print, all are merged into a single PDF.

What is the difference between report_name and report_file? +

report_name is the technical name of the QWeb template (the t-name / XML id) — this is what Odoo uses to find and render the template. report_file is deprecated in newer Odoo versions and was historically used as a filename hint. Use print_report_name (a Python expression) to control the downloaded file's name dynamically.