Most Python developers learn about the mutable default argument trap early in their careers. You define a function with a default list parameter, some caller mutates it, and suddenly every subsequent call sees the mutation. It’s Python 101, covered in every introductory course and linting rule set.
But the same problem exists at the class level, and it’s far less discussed. When a Python class defines a mutable object — a list, a dictionary — as a class attribute, that object is shared across every instance of the class. If any instance modifies it, the change is visible to all other instances in the same process. In a web framework like Odoo, where models are long-lived singleton-like objects sharing a single registry, this is exactly the kind of bug that stays dormant for years and then manifests as unexplainable behavior in production.
What _rec_names_search Does
In Odoo’s ORM, every model can define a_rec_names_search attribute that specifies which fields are used when searching for records by name. When you type into a many2one dropdown and the system searches for matching records, it looks at this attribute to know which fields to include. A product model might search by both name anddefault_code (the internal reference). A partner model might search by name, email, andvat.
Across the Odoo codebase, these attributes were defined as Python lists — ['name', 'default_code']. Functionally, this works. The ORM iterates over the list and constructs the search domain. But because lists are mutable, any code that modifies the attribute — even inadvertently, through operations like append() or extend() or+=in a subclass — mutates the shared class-level object.
The Ghost Bug Nobody Could Reproduce
In practice, this bug class is maddening to debug. Imagine a custom module that extends the product model and adds a new search field. The developer writesself._rec_names_search.append('barcode') in a method, intending to add barcode search for their specific use case. The first time this runs, it works. The product model now searches by name, default code, and barcode.
The second time it runs — maybe on the next request, maybe after a different user triggers the same code path — it appends barcode again. Now the search list has a duplicate entry. By the hundredth request, the list has grown to contain 100 copies of barcode, and every many2one dropdown referencing products is constructing a search domain with 100 redundant clauses. Performance degrades. The system slows down in ways that correlate with uptime rather than load, which makes it look like a memory leak or a caching issue to anyone investigating.
The insidious part is that restarting the Odoo server “fixes” the problem, because the class attributes are re-initialized from their original definitions. This reinforces the memory leak hypothesis and sends developers down the wrong diagnostic path entirely.
The Systematic Fix
Odoo’s approach to fixing this wasn’t to hunt down every place where someone might be mutating these lists. Instead, they made the lists unmutatable. By converting every_rec_names_searchattribute from a list to a tuple — ('name', 'default_code')instead of ['name', 'default_code']— any code that tries to mutate the attribute will raise aTypeError immediately, right at the point of mutation, instead of silently corrupting shared state.
The migration was executed in three steps. First, an automated upgrade script scanned the entire codebase and converted list definitions to tuples. This script handled the vast majority of cases — straightforward list-to-tuple syntax conversions across every module that defines _rec_names_search. Second, the automated commit was added to the project’s.git-blame-ignore-revsfile so that git blame annotations continue to point to the original authors of each line rather than the migration script. Third, a manual pass addressed false positives and false negatives — cases where the script either incorrectly converted something or missed a definition that used a non-standard syntax.
Why Tuples and Not a Custom Descriptor
There’s a more sophisticated solution to this problem: defining a custom Python descriptor that intercepts attribute access and returns a frozen copy, or using __init_subclass__to freeze attributes automatically when a class is defined. Odoo chose the simpler path — just use tuples — and that’s the right call for a codebase of this size.
Tuples are a zero-cost solution. They don’t add runtime overhead, don’t require developers to learn a new API, and don’t introduce a layer of indirection that makes debugging harder. A developer who sees_rec_names_search = ('name', 'default_code')understands immediately what it is and how it works. If they try to modify it in a way that would cause the shared-state bug, Python itself stops them with a clear error message.
The Broader Pattern
This change is worth paying attention to even if you don’t build custom Odoo modules, because it signals a maturation in how the Odoo codebase handles defensive programming. Mutable class attributes have been a known risk in Python since the language was created, but actually auditing and fixing them across a codebase with hundreds of modules requires the kind of systematic tooling (automated scripts, blame-ignore files, manual edge-case review) that only becomes practical at a certain level of engineering discipline.
For custom module developers, the takeaway is concrete: if your code appends to or extends _rec_names_searchin any method, it will break after the 19.5 upgrade. The fix is to override the attribute at the class level using concatenation — _rec_names_search = ParentModel._rec_names_search + ('barcode',)— which creates a new tuple rather than mutating the existing one. It’s a one-line change per occurrence, and it eliminates the shared-state risk permanently.