Skip to content

Odoo read_group for Reporting: Aggregation and Grouping Guide

DeployMonkey Team · March 23, 2026 11 min read

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:

SyntaxSQLExample
field:sumSUM(field)amount_total:sum
field:avgAVG(field)amount_total:avg
field:minMIN(field)date_order:min
field:maxMAX(field)date_order:max
field:countCOUNT(field)id:count
field:count_distinctCOUNT(DISTINCT field)partner_id:count_distinct
field:array_aggARRAY_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 False as 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.