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.
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-outwith a string value → automatically HTML-escaped (same as oldt-esc)t-outwith aMarkupobject → rendered as raw HTML (replaces oldt-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.
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.
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?"
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.
t-esc vs. t-out: A Complete Comparison for Odoo Module Migration
| Aspect | Old Way (t-esc / t-raw) | Odoo 19 Way (t-out) |
|---|---|---|
| Directive | t-esc for escaped, t-raw for raw | t-out for both |
| Escaping decision | Template layer (XML) | Data layer (JS/Python) |
| Raw HTML output | <span t-raw="html_content"/> | <span t-out="markup(html_content)"/> |
| XSS risk surface | Any template using t-raw | Only code calling markup() |
| Code review signal | Must scan all XML templates | Grep for markup( in JS files |
| Odoo 19 status | Removed — compilation error | Required — only supported directive |
| Fallback content | Not supported | <t t-out="val">default text</t> |
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:
Run in dry-run mode first: python migrate_tesc_to_tout.py ./custom_addons --dry-run
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.
Apply changes: python migrate_tesc_to_tout.py ./custom_addons
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_modules3 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:
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.
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 (<p>Hello</p>) 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.
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.
What This Means for Your Bottom Line
The t-esc → t-out migration is often dismissed as "just a template change." Here's why that's a costly misconception:
- Upgrade blocking: Unresolved
t-escdirectives 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-rawdirectives 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-outare compatible with OWL's roadmap through Odoo 20+. You invest once and avoid repeated migration debt.
Optimization Metadata
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.
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)"
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