Why read_group Instead of search + mapped?
When building reports or dashboards, the naive approach is to search all records and compute totals in Python. This is extremely slow for large datasets. Odoo's read_group method translates directly to SQL GROUP BY queries, running aggregation on the database server where it belongs.
# Slow: loads all records into Python
orders = self.env['sale.order'].search([('state', '=', 'sale')])
total = sum(o.amount_total for o in orders)
# Fast: database-level aggregation
result = self.env['sale.order'].read_group(
domain=[('state', '=', 'sale')],
fields=['amount_total:sum'],
groupby=[]
)
total = result[0]['amount_total']For 100,000 orders, the read_group version is 100x faster because it never loads record objects into memory.
Basic read_group Syntax
Model.read_group(
domain, # filter records (same as search domain)
fields, # fields to aggregate
groupby, # fields to group by
offset=0, # pagination offset
limit=None, # max groups returned
orderby='', # sort order
lazy=True # lazy grouping (default True)
)Aggregate Functions
The fields parameter supports these aggregate functions:
| Syntax | SQL | Example |
|---|---|---|
| field:sum | SUM(field) | amount_total:sum |
| field:avg | AVG(field) | amount_total:avg |
| field:min | MIN(field) | date_order:min |
| field:max | MAX(field) | date_order:max |
| field:count | COUNT(field) | id:count |
| field:count_distinct | COUNT(DISTINCT field) | partner_id:count_distinct |
| field:array_agg | ARRAY_AGG(field) | id:array_agg |
Grouping by Fields
Simple Grouping
# Sales by salesperson
results = self.env['sale.order'].read_group(
domain=[('state', '=', 'sale')],
fields=['amount_total:sum', 'user_id'],
groupby=['user_id']
)
# Returns: [{'user_id': (3, 'John'), 'amount_total': 45000, '__count': 12}, ...]Date Grouping
Group by date fields with granularity:
# Monthly sales
results = self.env['sale.order'].read_group(
domain=[('state', '=', 'sale')],
fields=['amount_total:sum', 'date_order'],
groupby=['date_order:month']
)
# Supported granularities: day, week, month, quarter, year
# groupby=['date_order:quarter'] -> Q1 2026, Q2 2026, ...Multi-Level Grouping
# Sales by country, then by month
results = self.env['sale.order'].read_group(
domain=[('state', '=', 'sale')],
fields=['amount_total:sum', 'partner_id.country_id', 'date_order'],
groupby=['partner_id.country_id', 'date_order:month'],
lazy=False
)Lazy vs Non-Lazy Grouping
By default, read_group uses lazy grouping (lazy=True). With multiple groupby fields, lazy mode only groups by the first field and returns a __domain key you can use to drill down:
# Lazy (default): only groups by first field
results = self.env['sale.order'].read_group(
domain=[], fields=['amount_total:sum'],
groupby=['user_id', 'date_order:month']
)
# Each result has __domain for sub-grouping
for group in results:
sub = self.env['sale.order'].read_group(
domain=group['__domain'],
fields=['amount_total:sum'],
groupby=['date_order:month']
)
# Non-lazy: groups by ALL fields at once
results = self.env['sale.order'].read_group(
domain=[], fields=['amount_total:sum'],
groupby=['user_id', 'date_order:month'],
lazy=False
)Use lazy=False when you need the full cross-product of groups in a single query.
Dashboard Data Pattern
A common pattern for building dashboard endpoints:
def _get_dashboard_data(self):
Order = self.env['sale.order']
today = fields.Date.today()
month_start = today.replace(day=1)
return {
'total_revenue': Order.read_group(
[('state', '=', 'sale'), ('date_order', '>=', month_start)],
['amount_total:sum'], []
)[0].get('amount_total', 0),
'order_count': Order.search_count([
('state', '=', 'sale'),
('date_order', '>=', month_start)
]),
'by_salesperson': Order.read_group(
[('state', '=', 'sale'), ('date_order', '>=', month_start)],
['amount_total:sum', 'user_id'],
['user_id'],
orderby='amount_total desc',
limit=10
),
'monthly_trend': Order.read_group(
[('state', '=', 'sale')],
['amount_total:sum', 'date_order'],
['date_order:month'],
orderby='date_order:month asc'
),
}read_group with Stored Computed Fields
Only stored fields can be used in read_group. Non-stored computed fields are not available because the aggregation runs at the SQL level. If you need to aggregate a computed value, make it a stored computed field:
margin = fields.Float(compute='_compute_margin', store=True)
# Now you can:
results = self.env['sale.order'].read_group(
[], ['margin:avg'], ['user_id']
)Common Gotchas
- Empty groups: read_group only returns groups that have records. If a salesperson has zero sales, they will not appear in results
- __count key: Every result dict includes
__count— the number of records in that group - Many2one format: Grouped Many2one fields return as
(id, name)tuples - Access rights: read_group respects record rules. If the user cannot see certain records, they are excluded from aggregation
- NULL handling: NULL values in groupby fields create a separate group with
Falseas the key - Performance: For very large tables, ensure groupby fields are indexed
read_group vs SQL Query
When read_group is not flexible enough (joins across models, subqueries, window functions), use raw SQL:
self.env.cr.execute("""
SELECT u.login, SUM(so.amount_total)
FROM sale_order so
JOIN res_users u ON u.id = so.user_id
WHERE so.state = 'sale'
GROUP BY u.login
ORDER BY SUM(so.amount_total) DESC
LIMIT 10
""")
results = self.env.cr.dictfetchall()But prefer read_group when possible — it respects access rights and handles multi-company filtering automatically.