While developing an Odoo module, ensuring that the functionality works correctly is only the first step. A customization may work without any issues with a small amount of data but become slow as the database grows. For example, a custom method that processes 20 records may take less than a second, while the same method might take several minutes when processing 20,000 records. This is why performance testing is important in Odoo development. Performance testing helps developers identify slow operations, unnecessary database queries, inefficient ORM usage, and code that does not scale well with larger datasets.
In Odoo 19, we can use the built-in profiler and testing utilities to investigate and prevent these problems. In this blog, we are going to explain different methods of performance testing and its importance.
What Is Performance Testing?
Performance testing is the process of measuring how efficiently an Odoo operation performs under a specific workload.
Some of the common things we measure are:
- Execution time
- Number of SQL queries
- Database query performance
- Python execution time
- ORM operations
- Batch processing efficiency
Consider the following example:
for partner in partners:
orders = self.env['sale.order'].search([
('partner_id', '=', partner.id)
])
While analysing this code, we can see that it is functionally correct, but it performs a search for every partner.
If there are 10 partners, the problem may not be noticeable. But if there are 10,000 partners, the same implementation can result in a large number of database queries.
Performance testing helps us identify such problems before they become production issues.
Why Performance Testing Is Important in Odoo
Odoo is an ERP system where many operations are interconnected.
A single user action can trigger:
User Action > ORM > Business Logic > Computed Fields > SQL Queries > PostgreSQL
A small performance problem in custom code can therefore affect the overall user experience.
This is particularly important for all the modules like Sales, Purchase, Inventory, Accounting, Manufacturing, etc. Because most of the Odoo modules are interconnected.
Performance also becomes more important as the number of records increases.
A solution that works well for a small database should ideally continue to perform reasonably when the database becomes much larger.
Odoo 19 Profiler
Odoo 19 provides a built-in profiler that can be used to investigate application performance.
The profiler can collect information about:
- SQL queries
- Python execution
- QWeb rendering
- Function calls
The collected information can then be analyzed through Odoo's profiling interface.
For example, if confirming a Sales Order takes several seconds, instead of immediately changing the code, we can first profile the operation and determine where the time is being spent.
The profiler is particularly useful when the cause of the performance problem is not obvious.
SQL Collector
The SQL Collector records SQL queries executed during an operation.
This is useful for finding problems such as:
- Too many SQL queries
- Repeated searches
- N+1 query problems
- Unnecessary database access
For example:
with Profiler(collectors=['sql']):
self._process_orders()
Periodic Collector
The Periodic Collector periodically records the execution stack. It helps us to identify the Python code that consumes a significant amount of execution time. For example:
with Profiler(collectors=['traces_async'], db=None) as res:
self._process_orders()
The profiler can then help identify which methods are consuming most of the execution time. Unlike the SQL Collector, which focuses on database queries, the Periodic Collector is useful when the bottleneck is primarily in Python code.
Query Count Testing
Performance does not always mean measuring execution time. Another useful metric is the number of SQL queries generated by an operation.
Odoo provides assertQueryCount() for this purpose.
For example:
def test_process_orders(self):
with self.assertQueryCount(20):
self.orders.action_process_orders()
This allows us to find out the expected query count for an operation. If a future code change increases the number of queries significantly, the test can fail and highlight the regression. This is particularly useful for custom modules which may contain additional searches inside loops.
Example: The N+1 Query Problem
One of the most common performance problems in Odoo development is the N+1 query problem.
Consider:
for partner in partners:
orders = self.env['sale.order'].search([
('partner_id', '=', partner.id)
])
partner.order_count = len(orders)
If there are 1,000 partners, the code can perform approximately 1,000 searches. The problem is not that search() is inherently slow. The problem is that the same database operation is being repeated unnecessarily. Instead, we can retrieve the required orders in a batch:
orders = self.env['sale.order'].search([
('partner_id', 'in', partners.ids)
])
We can then group the results:
order_count = {}
for order in orders:
partner_id = order.partner_id.id
order_count[partner_id] = order_count.get(partner_id, 0) + 1Finally:
for partner in partners:
partner.order_count = order_count.get(partner.id, 0)
The exact query count depends on the surrounding code and ORM behaviour, but the important improvement is that we no longer perform a separate search for every partner.
Batch Processing
Another important way of improvement for performance in Odoo is batch processing.
For example:
for record in records:
record.write({
'state': 'done',
})
If we need to write the same value to every record, it is better to work with the complete recordset:
records.write({
'state': 'done',
})Recordsets are an important part of Odoo's ORM and are designed to allow operations to be performed on multiple records.
The same approach can be applied to scheduled actions.
Instead of loading and processing every pending record:
records = self.search([
('state', '=', 'pending')
])
for record in records:
record.process()
We can process a limited batch:
records = self.search([
('state', '=', 'pending')
], limit=500)
for record in records:
record.process()
This prevents a scheduled action from trying to process an extremely large number of records in a single operation.
Avoid Searches Inside Loops
Consider another common example:
for line in order.order_line:
product = self.env['product.product'].search([
('default_code', '=', line.product_code)
], limit=1)
If an order contains 500 lines, this code may perform 500 searches. A better solution is to collect the product codes first:
codes = order.order_line.mapped('product_code')
products = self.env['product.product'].search([
('default_code', 'in', codes)
])Then, create a dictionary:
product_by_code = {
product.default_code: product
for product in products
}The processing loop becomes:
for line in order.order_line:
product = product_by_code.get(line.product_code)
This approach reduces repeated database access and becomes much more scalable when the number of order lines increases.
Performance Testing with Large Datasets
A performance test should use a realistic amount of data. Testing with a smaller number of records does not tell us much about how the code behaves with a large number of records.
For example:
partners = self.env['res.partner'].create([
{
'name': f'Test Customer {index}',
}
for index in range(1000)
])
We can then execute the operation against those records:
with self.profile():
self._process_partners(partners)
This gives us a better understanding of how the implementation behaves at scale.
It is also useful to test different dataset sizes:
10 records
100 records
1,000 records
10,000 records
The goal is to understand how execution time and query count change as the dataset grows.
Performance Testing Computed Fields
Computed fields are also considered during performance testing.
For example:
@api.depends('order_ids')
def _compute_order_count(self):
for partner in self:
partner.order_count = self.env['sale.order'].search_count([
('partner_id', '=', partner.id)
])This may work correctly, but the computation performs a search for each partner. If a large number of partners are recomputed, the method can become expensive.
When writing computed fields, we need to consider the following points to improve the performance:
- How frequently is the field recomputed?
- How many records are processed?
- Does the computation perform database queries?
- Can the calculation be performed in batches?
Computed fields are sometimes responsible for performance issues that are not immediately visible because they may be triggered indirectly by other operations.
Profiling a Test
Performance profiling can also be integrated into automated tests.
For example:
def test_process_orders_performance(self):
with self.profile():
with self.assertQueryCount(50):
self.orders.action_process_orders()
The profiler helps us to identify where time is being spent, while assertQueryCount() helps ensure that the number of database queries remains within an expected range. This is useful for performance regression testing. If a later code change introduces unnecessary database calls, the test can expose the problem.
Performance testing is not only about speed,
Suppose we have two implementations:
Implementation A
10,000 records
2 seconds
5,000 SQL queries
and:
Implementation B
10,000 records
2.5 seconds
30 SQL queries
At first glance, implementation A appears faster. But while evaluating, we can find that implementation B may scale much better when the database becomes larger. This is why performance testing should consider more than execution time.
Useful metrics include:
- Execution time
- SQL query count
- Database query duration
- CPU usage
- Memory consumption
- Number of records processed
The right metric depends on the type of operation being tested.
Common Performance Mistakes
Some performance problems appear repeatedly in custom Odoo development.
for record in records:
self.env['x.model'].search(...
- Creating records individually
for values in values_list:
self.env['x.model'].create(values)
Even though batch creation creates records individually, a common mistake is found during development.
- Unnecessary writes
Repeated write() operations can trigger recomputations, tracking, automation, and other ORM operations.
- Loading huge recordsets
A scheduled action that loads hundreds of thousands of records into one transaction can consume significant resources.
- Expensive computed fields
Computed fields that repeatedly execute searches can become costly.
- Calling external APIs inside loops
For example:
for partner in partners:
requests.get(api_url)
If an external API supports batch operations, those should be considered instead.
A Simple Performance Testing Workflow
When an Odoo feature is slow, the following approach is useful:
Reproduce the Problem > Profile the Operation > Check SQL Queries > Identify the Bottleneck > Optimize the Code > Run Functional Tests > Run Performance Tests Again
The important point is to measure before optimizing. Changing code based only on assumptions can sometimes make the implementation more complicated without actually improving performance.
The best practices we need to follow while developing custom modules in Odoo 19:
- Use recordsets effectively.
- Avoid searches inside loops.
- Batch database operations whenever possible.
- Use the profiler to identify bottlenecks.
- Monitor SQL query counts.
- Test with realistic datasets.
- Pay attention to computed fields.
- Process scheduled actions in batches.
- Avoid unnecessary writes.
- Use raw SQL only when there is a clear performance or technical reason.
- Add performance tests for critical operations.
Performance testing is an important part of Odoo development, especially for custom modules that will work with large amounts of business data. A method that works correctly with a small dataset is not necessarily a scalable implementation. Odoo 19 provides developers with useful tools such as the profiler, SQL Collector, Periodic Collector, and assertQueryCount() to investigate and monitor application performance. The most common performance improvements usually come from simple changes: avoiding repeated searches, processing records in batches, reducing unnecessary database operations, and making better use of the ORM. The objective of performance testing is not to make every operation execute in the minimum possible time. It is to ensure that the application continues to perform reliably as the amount of data and number of users increase. Development performance should therefore be considered alongside functionality and correctness – not after them.
To read more about How to Perform Load Testing in Odoo 19, refer to our blog, How to Perform Load Testing in Odoo 19.