How QContext Works in Odoo 18
By Braincuber Team
Published on January 14, 2026
You need to display a personalized welcome banner on your website shop page showing the customer's loyalty points. Or maybe add a "Flash Sale Ending In" countdown timer that comes from business logic. Your QWeb template is static HTML—it doesn't know about your Python calculations. You need a way to pass dynamic data from your controller to your templates.
QContext (short for QWeb Context) is Odoo's mechanism for passing data from Python controllers to QWeb templates. When a controller renders a template, it provides a dictionary of variables that the template can access using QWeb expressions like t-esc and t-if. By modifying or extending the qcontext, you inject custom variables that make templates dynamic—showing different content based on business logic, user data, or real-time calculations. Understanding qcontext is essential for any Odoo website customization.
What is QContext? A Python dictionary that controllers pass to QWeb templates. It contains variables your template can reference. Default qcontext includes request data, website info, and model data. You can extend it with custom variables for dynamic content without modifying core templates.
How QContext Works
QCONTEXT: CONTROLLER TO TEMPLATE DATA FLOW
═══════════════════════════════════════════════════════════
THE PATTERN
───────────────────────────────────────────────────────────
1. User requests URL: /shop
↓
2. Controller receives request: shop() method
↓
3. Controller builds qcontext: {products, categories, ...}
↓
4. Controller renders template: website_sale.products
↓
5. Template uses qcontext: <t t-foreach="products">
↓
6. HTML returned to browser
WHAT'S IN DEFAULT QCONTEXT?
───────────────────────────────────────────────────────────
When you render a website template, Odoo provides these
standard variables in qcontext:
request → Current HTTP request object
website → Current website record
env → Odoo environment (access to models)
user_id → Current user
company → Current company
res_company → Company record
languages → Available languages
translatable → Whether page is translatable
editable → Whether user can edit page
website_sale → E-commerce helpers (if website_sale installed)
EXAMPLE: /shop PAGE QCONTEXT
───────────────────────────────────────────────────────────
When user opens /shop, the WebsiteSale controller
passes this qcontext to the template:
{
# Search/filter params
'search': '',
'order': 'website_sequence asc',
# Selected category
'category': product.public.category(5), # recordset
# Products to display
'products': product.template(9, 41, 38, 16, 22), # 5 products
# Pagination data
'pager': {
'page_count': 3,
'page': {'url': '/shop?', 'num': 1},
'page_next': {'url': '/shop/page/2?', 'num': 2},
},
# Products per page
'ppg': 20,
# Display options
'layout_mode': 'grid',
# Price range for filter
'min_price': 9.99,
'max_price': 499.00,
# Template to render
'response_template': 'website_sale.products',
# Plus all the default website variables...
}
The template uses these variables:
<t t-foreach="products" t-as="product">
<div class="product-card">
<h3 t-esc="product.name"/>
<span t-esc="product.list_price"/>
</div>
</t>
<t t-if="pager.page_next">
<a t-att-href="pager.page_next.url">Next Page</a>
</t>
WHY EXTEND QCONTEXT?
───────────────────────────────────────────────────────────
The default qcontext is read-only from templates.
To add YOUR data, you must extend qcontext in the controller.
Use cases:
• Show personalized messages
• Display user-specific data (loyalty points, cart info)
• Add conditional banners (flash sales, low stock warnings)
• Pass calculated values (discounts, shipping estimates)
• Inject configuration options
• Show role-based content (VIP vs regular customers)
Use Cases for QContext
Promotional Banners
Display flash sale alerts, discount announcements, or seasonal promotions dynamically from controller.
Personalized Content
Show user-specific data like loyalty points, purchase history, or recommended products.
Calculated Values
Pass computed data like estimated delivery dates, shipping costs, or inventory status.
Access Control
Show/hide content based on user roles, permissions, or subscription levels.
Configuration Options
Pass module settings to templates for conditional rendering.
Location-Based Data
Show region-specific pricing, availability, or content based on user location.
Practical Example: Shop Page Banner
Let's create a module that adds a promotional banner to the shop page by extending the qcontext.
shop_promo_banner/
├── __init__.py
├── __manifest__.py
├── controllers/
│ ├── __init__.py
│ └── main.py
└── views/
└── templates.xml
Create Module Manifest
Define module metadata and dependencies:
{
'name': 'Shop Promotional Banner',
'version': '18.0.1.0.0',
'category': 'Website',
'summary': 'Add dynamic promotional banners to shop page',
'description': """
Extends the shop page controller to inject custom
promotional messages via qcontext.
""",
'author': 'Your Company',
'website': 'https://www.yourcompany.com',
'license': 'LGPL-3',
'depends': [
'website_sale',
],
'data': [
'views/templates.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}
Create Init Files
Set up proper Python imports:
from . import controllers
from . import main
Extend Shop Controller
Override the shop method to inject custom qcontext variables:
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
class WebsiteSalePromo(WebsiteSale):
"""Extend WebsiteSale controller to inject promotional content."""
@http.route([
'/shop',
'/shop/page/<int:page>',
'/shop/category/<model("product.public.category"):category>',
'/shop/category/<model("product.public.category"):category>/page/<int:page>',
], type='http', auth="public", website=True, sitemap=WebsiteSale.sitemap_shop)
def shop(self, page=0, category=None, search='', min_price=0.0,
max_price=0.0, ppg=False, **post):
"""Override shop to add promotional banner via qcontext."""
# Call parent method to get default response
response = super(WebsiteSalePromo, self).shop(
page=page,
category=category,
search=search,
min_price=min_price,
max_price=max_price,
ppg=ppg,
**post
)
# Check if response has qcontext (template response)
if hasattr(response, 'qcontext'):
# Inject promotional message
response.qcontext['promo_banner'] = {
'show': True,
'type': 'info', # info, success, warning, danger
'title': 'Limited Time Offer',
'message': 'Get 25% OFF on orders above $100! Use code: SAVE25',
'link_text': 'Shop Now',
'link_url': '/shop?order=price+asc',
}
# Add user-specific data
user = request.env.user
if not user._is_public():
# Logged-in user - show loyalty points
partner = user.partner_id
response.qcontext['user_stats'] = {
'name': partner.name,
'is_logged_in': True,
'total_orders': request.env['sale.order'].sudo().search_count([
('partner_id', '=', partner.id),
('state', 'in', ['sale', 'done']),
]),
# Add loyalty points if pos_loyalty installed
'loyalty_points': getattr(partner, 'loyalty_points', 0),
}
else:
response.qcontext['user_stats'] = {
'is_logged_in': False,
}
# Add flash sale countdown (example)
from datetime import datetime, timedelta
sale_end = datetime.now() + timedelta(hours=5, minutes=30)
response.qcontext['flash_sale'] = {
'active': True,
'end_time': sale_end.strftime('%Y-%m-%dT%H:%M:%S'),
'discount_percent': 30,
}
return response
Key Points:
- Inherit the controller class: Extend
WebsiteSaleto override its methods - Call super(): Always call parent method first to get default response
- Check hasattr: Verify response has qcontext before modifying
- Add custom keys: Inject your data as new dictionary keys
- Return response: Don't forget to return the modified response
Create QWeb Template
Use the qcontext variables in your template:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Promotional Banner Template -->
<template id="shop_promo_banner" inherit_id="website_sale.products">
<xpath expr="//div[hasclass('o_wsale_products_main_row')]" position="before">
<!-- Promotional Alert Banner -->
<t t-if="promo_banner and promo_banner.get('show')">
<div t-attf-class="alert alert-#{promo_banner.get('type', 'info')}
text-center my-3 mx-3">
<div class="d-flex align-items-center justify-content-center">
<!-- Icon based on type -->
<t t-if="promo_banner.get('type') == 'info'">
<i class="fa fa-info-circle fa-lg me-2"/>
</t>
<t t-elif="promo_banner.get('type') == 'success'">
<i class="fa fa-check-circle fa-lg me-2"/>
</t>
<t t-elif="promo_banner.get('type') == 'warning'">
<i class="fa fa-exclamation-triangle fa-lg me-2"/>
</t>
<!-- Banner Content -->
<div>
<strong t-esc="promo_banner.get('title')"/>:
<span t-esc="promo_banner.get('message')"/>
<t t-if="promo_banner.get('link_url')">
<a t-att-href="promo_banner.get('link_url')"
class="alert-link ms-2">
<t t-esc="promo_banner.get('link_text', 'Learn More')"/>
<i class="fa fa-arrow-right ms-1"/>
</a>
</t>
</div>
</div>
</div>
</t>
<!-- User Stats Banner (for logged-in users) -->
<t t-if="user_stats and user_stats.get('is_logged_in')">
<div class="alert alert-success text-center my-3 mx-3">
<i class="fa fa-user me-2"/>
Welcome back, <strong t-esc="user_stats.get('name')"/>!
You have <strong t-esc="user_stats.get('total_orders')"/> orders.
<t t-if="user_stats.get('loyalty_points')">
| Loyalty Points:
<span class="badge bg-warning text-dark">
<t t-esc="user_stats.get('loyalty_points')"/>
</span>
</t>
</div>
</t>
<!-- Flash Sale Countdown -->
<t t-if="flash_sale and flash_sale.get('active')">
<div class="alert alert-danger text-center my-3 mx-3">
<i class="fa fa-bolt me-2"/>
<strong>FLASH SALE!</strong>
<t t-esc="flash_sale.get('discount_percent')"/>% OFF -
Ends in: <span id="countdown-timer"
t-att-data-end-time="flash_sale.get('end_time')">
Loading...
</span>
</div>
</t>
</xpath>
</template>
</odoo>
Advanced Example: Category-Specific Content
Here's a more complex example that shows different banners based on the selected category:
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
class WebsiteSaleCategoryContent(WebsiteSale):
"""Show different content based on product category."""
# Category-specific banners configuration
CATEGORY_BANNERS = {
'electronics': {
'icon': 'fa-laptop',
'color': 'primary',
'title': 'Tech Deals',
'message': 'Free shipping on all electronics orders!',
'features': ['2-year warranty', 'Free returns', 'Expert support'],
},
'clothing': {
'icon': 'fa-tshirt',
'color': 'success',
'title': 'Fashion Week',
'message': 'Buy 2, Get 1 Free on selected items',
'features': ['Size exchange', 'Style consultation', 'VIP early access'],
},
'furniture': {
'icon': 'fa-couch',
'color': 'warning',
'title': 'Home Collection',
'message': 'Assembly included on all furniture',
'features': ['White glove delivery', '30-day returns', 'Design service'],
},
}
@http.route()
def shop(self, page=0, category=None, search='', min_price=0.0,
max_price=0.0, ppg=False, **post):
"""Add category-specific content to shop page."""
response = super().shop(
page=page, category=category, search=search,
min_price=min_price, max_price=max_price,
ppg=ppg, **post
)
if hasattr(response, 'qcontext'):
# Determine category context
category_banner = None
if category:
# Get category slug/name for matching
category_key = category.name.lower() if category else None
# Look up banner configuration
for key, banner in self.CATEGORY_BANNERS.items():
if key in str(category_key):
category_banner = banner
break
# Default banner if no category match
if not category_banner:
category_banner = {
'icon': 'fa-shopping-cart',
'color': 'info',
'title': 'Welcome to Our Shop',
'message': 'Browse our full catalog of quality products',
'features': ['Fast shipping', 'Secure checkout', '24/7 support'],
}
response.qcontext['category_banner'] = category_banner
# Add stock availability summary
products = response.qcontext.get('products', [])
if products:
in_stock = sum(1 for p in products if p.qty_available > 0)
response.qcontext['stock_summary'] = {
'total': len(products),
'in_stock': in_stock,
'low_stock': sum(1 for p in products if 0 < p.qty_available <= 5),
'out_of_stock': len(products) - in_stock,
}
# Add price range info
if products:
prices = [p.list_price for p in products if p.list_price > 0]
if prices:
response.qcontext['price_info'] = {
'min': min(prices),
'max': max(prices),
'avg': sum(prices) / len(prices),
}
return response
QContext Best Practices
✅ QContext Best Practices:
- Always call super() first: Get the parent response before modifying qcontext
- Check hasattr before modifying: Not all responses have qcontext (redirects, JSON, etc.)
- Use descriptive key names: Make qcontext keys clear and namespaced (e.g.,
promo_bannernot justmessage) - Keep data structured: Pass dictionaries with meaningful keys rather than flat values
- Handle missing data gracefully: Use
.get()in templates to avoid errors - Don't overwrite existing keys: Check if key exists before setting, or use unique names
- Cache expensive computations: Don't run heavy queries on every page load
- Keep sensitive data private: Don't expose internal system data to templates
- Document custom variables: Comment what each qcontext variable contains
Common Mistakes to Avoid:
- Forgetting super(): Breaking parent functionality by not calling parent method
- Wrong route decorator: Using
@http.route()without parameters inherits parent routes - Overwriting built-in keys: Replacing
productsorcategoryinstead of adding new keys - Not checking response type: Modifying qcontext on redirect responses causes errors
- Expensive queries: Running complex database queries on every page load
QWeb Template Expressions
QWEB EXPRESSION REFERENCE FOR QCONTEXT VARIABLES
═══════════════════════════════════════════════════════════
OUTPUT EXPRESSIONS
───────────────────────────────────────────────────────────
t-esc: Output value (HTML escaped)
<span t-esc="promo_banner.get('message')"/>
Result: "Get 25% OFF!" (safe, no HTML execution)
t-out/t-raw: Output raw HTML (deprecated, use t-out)
<div t-out="rich_content"/>
Result: <strong>Bold text</strong> (renders HTML)
CONDITIONAL EXPRESSIONS
───────────────────────────────────────────────────────────
t-if: Render block if condition is truthy
<t t-if="promo_banner">
... banner content ...
</t>
t-elif / t-else: Additional conditions
<t t-if="user_stats.get('is_logged_in')">
Welcome back!
</t>
<t t-else="">
Please sign in.
</t>
LOOP EXPRESSIONS
───────────────────────────────────────────────────────────
t-foreach / t-as: Iterate over list
<t t-foreach="category_banner.get('features')" t-as="feature">
<li t-esc="feature"/>
</t>
Access loop variables:
feature_index → Current index (0, 1, 2...)
feature_first → True if first item
feature_last → True if last item
feature_size → Total number of items
ATTRIBUTE EXPRESSIONS
───────────────────────────────────────────────────────────
t-att: Set attribute dynamically
<div t-att-class="'alert-' + promo_banner.get('type')"/>
Result: class="alert-info"
t-attf: Format string with expressions
<div t-attf-class="alert alert-#{promo_banner.get('type')}">
Result: class="alert alert-info"
t-att-*: Dynamic attribute name
<a t-att-href="promo_banner.get('link_url')"/>
Result: href="/shop?discount=25"
COMBINING EXPRESSIONS
───────────────────────────────────────────────────────────
Example: Complete banner block
<t t-if="promo_banner and promo_banner.get('show')">
<div t-attf-class="alert alert-#{promo_banner.get('type', 'info')}">
<i t-attf-class="fa #{promo_banner.get('icon', 'fa-info')} me-2"/>
<strong t-esc="promo_banner.get('title')"/>:
<span t-esc="promo_banner.get('message')"/>
<t t-if="promo_banner.get('features')">
<ul class="mb-0 mt-2">
<t t-foreach="promo_banner.get('features')" t-as="feat">
<li t-esc="feat"/>
</t>
</ul>
</t>
</div>
</t>
SAFE VALUE ACCESS
───────────────────────────────────────────────────────────
Always use .get() with default values to prevent errors:
✗ Wrong (may cause error):
<span t-esc="promo_banner['message']"/>
✓ Correct (safe access):
<span t-esc="promo_banner.get('message', 'Default message')"/>
For nested access:
<span t-esc="(promo_banner or {}).get('nested', {}).get('value', '')"/>
Conclusion
QContext is the bridge between your Python business logic and QWeb templates in Odoo 18. By extending controllers and injecting custom variables into qcontext, you create dynamic website pages that respond to user data, display promotional content, show personalized information, and adapt based on any criteria you define. The pattern is simple: inherit the controller, call super(), check for qcontext, add your variables, return the response. Your templates then access these variables using QWeb expressions. Master qcontext and you unlock the full potential of Odoo website customization.
🎯 Key Takeaway: Inherit controller → Call super() → Check hasattr(response, 'qcontext') → Add custom keys to response.qcontext → In template: use t-if, t-esc, t-foreach with your variables. QContext is the glue between Python logic and dynamic templates.
