BlogMarch 11, 2026 · Updated March 13, 2026

Migrating from t-esc to t-out in OWL 3.0:
A Senior Architect's Field Guide for Odoo 19

THE PAIN POINT

Why a One-Line Deprecation Can Break Your Entire Module Library

If you maintain custom Odoo modules, you've likely seen this console warning since Odoo 18:

[OWL] t-esc is deprecated. Use t-out instead.

In Odoo 18, it was a warning. In Odoo 19, it's a hard error. OWL 3.0 has fully removed support for t-esc, and every template that still uses it will refuse to compile at runtime.

For a company with 5–10 custom modules, that might mean 20 templates to update. For an organization with 80+ modules built over three Odoo versions? You're looking at hundreds of broken views on Day 1 of your upgrade—unless you plan ahead.

This isn't just a find-and-replace task. The shift from t-esc to t-out changes the default escaping behavior, which has direct security implications. Get it wrong, and you either break your UI or open your application to Cross-Site Scripting (XSS) attacks.

THE TECHNICAL SHIFT

How OWL 3.0 Changed Template Output in Odoo 19

In OWL 2.x (Odoo 17–18), the templating engine offered two directives for dynamic output:

  • t-esc — Outputs an expression with HTML escaping (safe by default)
  • t-raw — Outputs raw HTML without escaping (deprecated since Odoo 16)

OWL 3.0 collapses both into a single, unified directive: t-out.

The key behavioral difference:

  • t-out with a string value → automatically HTML-escaped (same as old t-esc)
  • t-out with a Markup object → rendered as raw HTML (replaces old t-raw)

This means the escaping decision moves from the template layer to the data layer. Instead of choosing t-esc vs. t-raw in your XML, you now control safety by wrapping trusted HTML in OWL's markup() utility at the JavaScript level.

Architecture Insight

This is a deliberate design choice borrowed from Jinja2 and Django's template engines. By making the data producer responsible for declaring "this is safe HTML," you eliminate an entire class of bugs where a developer accidentally uses t-raw on user-supplied input.

XSS & SECURITY

Why t-out Is More Secure Than t-esc Ever Was

The old dual-directive model (t-esc + t-raw) had a fundamental flaw: the template author decided whether to escape. In large codebases with multiple developers, this led to patterns like:

<!-- Dangerous: developer chose t-raw for "formatting" -->
<span t-raw="record.user_description"/>

<!-- The user_description field contains user-supplied input -->
<!-- Result: XSS vulnerability -->

With t-out, this pattern becomes structurally impossible unless the developer explicitly wraps the value with markup():

<!-- Safe: t-out auto-escapes strings -->
<span t-out="record.user_description"/>

<!-- If you genuinely need raw HTML (e.g., a sanitized rich-text field): -->
// In your JS component:
import { markup } from "@odoo/owl";
get safeDescription() {
    return markup(this.record.user_description);
}

The markup() call acts as an explicit security gate. It shows up in code review, it's greppable, and it forces the developer to think: "Am I sure this content is sanitized?"

Security Best Practice

Never call markup() on user-supplied input unless it has been sanitized server-side. Odoo's fields.Html with sanitize=True (the default) is safe. Raw fields.Text or API responses are not.

OLD vs. NEW

t-esc vs. t-out: A Complete Comparison for Odoo Module Migration

AspectOld Way (t-esc / t-raw)Odoo 19 Way (t-out)
Directivet-esc for escaped, t-raw for rawt-out for both
Escaping decisionTemplate layer (XML)Data layer (JS/Python)
Raw HTML output<span t-raw="html_content"/><span t-out="markup(html_content)"/>
XSS risk surfaceAny template using t-rawOnly code calling markup()
Code review signalMust scan all XML templatesGrep for markup( in JS files
Odoo 19 statusRemoved — compilation errorRequired — only supported directive
Fallback contentNot supported<t t-out="val">default text</t>
MIGRATION SCRIPT

Automating the t-esc to t-out Migration Across Your Module Library

Below is a Python script we use internally at Octura Solutions to automate the replacement across an entire custom module library. It handles XML templates, QWeb files, and generates a detailed audit log.

#!/usr/bin/env python3
"""
migrate_tesc_to_tout.py
Replaces t-esc with t-out across all XML/QWeb templates in an Odoo module tree.
Generates a CSV audit log for code review.

Usage:
    python migrate_tesc_to_tout.py /path/to/custom_addons [--dry-run]
"""
import argparse
import csv
import os
import re
import sys
from datetime import datetime
from pathlib import Path

# Matches t-esc="..." (handles single and double quotes)
T_ESC_PATTERN = re.compile(
    r'\bt-esc\s*=\s*(["\'])(.*?)\1',
    re.DOTALL,
)

# Also catch any lingering t-raw (deprecated since Odoo 16)
T_RAW_PATTERN = re.compile(
    r'\bt-raw\s*=\s*(["\'])(.*?)\1',
    re.DOTALL,
)

EXTENSIONS = {'.xml', '.qweb'}


def find_template_files(root: Path):
    """Yield all XML/QWeb files under the given root."""
    for dirpath, _, filenames in os.walk(root):
        for fname in filenames:
            fpath = Path(dirpath) / fname
            if fpath.suffix.lower() in EXTENSIONS:
                yield fpath


def migrate_file(fpath: Path, dry_run: bool = False):
    """Replace t-esc and t-raw in a single file. Returns list of changes."""
    content = fpath.read_text(encoding='utf-8')
    changes = []

    def replace_tesc(match):
        quote = match.group(1)
        expr = match.group(2)
        changes.append({
            'file': str(fpath),
            'old': match.group(0),
            'new': f't-out={quote}{expr}{quote}',
            'type': 't-esc → t-out',
            'line': content[:match.start()].count('\n') + 1,
        })
        return f't-out={quote}{expr}{quote}'

    def replace_traw(match):
        quote = match.group(1)
        expr = match.group(2)
        changes.append({
            'file': str(fpath),
            'old': match.group(0),
            'new': f't-out={quote}{expr}{quote}',
            'type': 't-raw → t-out (⚠ REVIEW: needs markup())',
            'line': content[:match.start()].count('\n') + 1,
        })
        return f't-out={quote}{expr}{quote}'

    new_content = T_ESC_PATTERN.sub(replace_tesc, content)
    new_content = T_RAW_PATTERN.sub(replace_traw, new_content)

    if changes and not dry_run:
        fpath.write_text(new_content, encoding='utf-8')

    return changes


def main():
    parser = argparse.ArgumentParser(
        description='Migrate t-esc/t-raw → t-out for Odoo 19 / OWL 3.0'
    )
    parser.add_argument('root', help='Root directory of custom addons')
    parser.add_argument(
        '--dry-run',
        action='store_true',
        help='Preview changes without writing files',
    )
    args = parser.parse_args()

    root = Path(args.root).resolve()
    if not root.is_dir():
        print(f"Error: {root} is not a directory", file=sys.stderr)
        sys.exit(1)

    all_changes = []
    file_count = 0

    for fpath in find_template_files(root):
        changes = migrate_file(fpath, dry_run=args.dry_run)
        if changes:
            file_count += 1
            all_changes.extend(changes)

    # Generate CSV audit log
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    log_file = root / f'tesc_migration_log_{timestamp}.csv'
    with open(log_file, 'w', newline='', encoding='utf-8') as f:
        writer = csv.DictWriter(
            f, fieldnames=['file', 'line', 'type', 'old', 'new']
        )
        writer.writeheader()
        writer.writerows(all_changes)

    mode = "DRY RUN" if args.dry_run else "APPLIED"
    print(f"\n[{mode}] Migration complete.")
    print(f"  Files affected : {file_count}")
    print(f"  Total changes  : {len(all_changes)}")
    t_raw_hits = sum(
        1 for c in all_changes if 't-raw' in c['type']
    )
    if t_raw_hits:
        print(
            f"  ⚠ t-raw → t-out : {t_raw_hits} "
            f"(MANUAL REVIEW: add markup() in JS)"
        )
    print(f"  Audit log      : {log_file}")


if __name__ == '__main__':
    main()

How to use it:

Step 1

Run in dry-run mode first: python migrate_tesc_to_tout.py ./custom_addons --dry-run

Step 2

Review the generated CSV audit log. Pay special attention to any t-raw → t-out entries—these require adding markup() in the corresponding JS/Python code.

Step 3

Apply changes: python migrate_tesc_to_tout.py ./custom_addons

Step 4

Run your test suite and manually verify every t-raw replacement in a staging environment.

For JavaScript-heavy modules, here's a companion Node.js one-liner to find all OWL component templates using t-esc in .js or .xml files:

# Bash: find remaining t-esc in JS template literals and XML
grep -rn --include="*.js" --include="*.xml" \
  't-esc=' ./custom_addons/ | grep -v node_modules
EXPERT INSIGHTS

3 Gotchas That Break Odoo 19 Migrations (and How We Handle Them)

After migrating 40+ custom module libraries to Odoo 19, these are the three issues that catch teams off guard:

Gotcha #1

Inherited templates that override t-esc in standard Odoo views. Your module may use xpath to inject content into a standard view that Odoo already migrated to t-out. The regex script won't catch these because the t-esc lives inside an xpath position="replace" block targeting a node that no longer exists. Our fix: We run a secondary validation pass that loads every template in a test Odoo instance and catches compilation errors at the QWeb level, not just regex.

Gotcha #2

Fields.Html content rendered without markup() after migration. If you blindly replace t-raw with t-out, any fields.Html content will suddenly render as escaped HTML entities (&lt;p&gt;Hello&lt;/p&gt;) instead of formatted text. Our fix: The migration script flags every t-raw replacement separately. We then trace each expression back to its field definition and add markup() wrapping only for sanitized Html fields.

Gotcha #3

Third-party OCA modules with unpinned OWL dependencies. Many OCA modules still reference t-esc in their views. If you upgrade Odoo but keep an older OCA branch, the module loads fine—until the template compiles and crashes. Our fix: We maintain a compatibility matrix per project that maps every installed OCA module to its Odoo 19-compatible branch (or our patched fork). We never upgrade Odoo core without upgrading the full dependency tree simultaneously.

BUSINESS ROI

What This Means for Your Bottom Line

The t-esct-out migration is often dismissed as "just a template change." Here's why that's a costly misconception:

  • Upgrade blocking: Unresolved t-esc directives prevent your Odoo 19 upgrade entirely. Every week of delay is a week without new features, security patches, and performance improvements.
  • Security liability: Improperly migrated t-raw directives create XSS attack vectors. A single vulnerability in a customer-facing portal can cost €50K+ in incident response and reputational damage.
  • Developer time: Manual migration across 50+ modules typically takes 2–4 weeks of senior developer time. Our automated approach reduces this to 2–3 days including review and testing.
  • Future-proofing: Modules migrated correctly to t-out are compatible with OWL's roadmap through Odoo 20+. You invest once and avoid repeated migration debt.
SEO NOTES

Optimization Metadata

Meta Description

Odoo 19 removed t-esc in OWL 3.0. Learn how to migrate to t-out, avoid XSS pitfalls, and automate the change across your custom modules with our Python script.

H2 Keywords

1. "How OWL 3.0 Changed Template Output in Odoo 19"
2. "Automating the t-esc to t-out Migration Across Your Module Library"
3. "3 Gotchas That Break Odoo 19 Migrations (and How We Handle Them)"

Target Keywords

Odoo 19 t-esc deprecated, OWL 3.0 migration guide, t-out directive Odoo, Odoo 19 template migration, Odoo QWeb t-esc to t-out

Migrating to Odoo 19? Let's Do It Right.

Template deprecations are just one piece of the Odoo 19 puzzle. OWL 3.0 brings changes to component lifecycle, reactivity, and the asset bundling pipeline—each with its own set of migration requirements.

At Octura Solutions, we've migrated enterprise module libraries with 100+ custom modules to Odoo 19 without a single day of downtime. Whether you need a full migration audit, a targeted t-esc remediation sprint, or long-term upgrade support—we've done it before, and we'll get it right.

Book a Free Migration Audit