How to Add a Custom Field to Odoo POS Without Breaking Upgrades

Every Point of Sale customisation starts with the same wish: show one more piece of data on the till — a supplier code on the product card, a brand name, a note on the order. And a surprising number of them end the same way: someone edited a core file, the next Odoo upgrade overwrote it, and the field vanished on the busiest trading day of the year. The field itself is rarely the hard part. The hard part is the journey it has to make — from a column in PostgreSQL, through the session loading code, into a JavaScript application that takes a snapshot of its data when the session opens — and making that journey in a way that survives upgrades. This article walks through the upgrade-safe pattern used in Odoo 17: your own module, inheritance everywhere, and no core file ever touched.

Why POS fields are different from backend fields

In the regular Odoo web client, adding a field to a form is almost the whole job. Define the field on the model, add it to a view, and the framework fetches it on demand because the browser is always talking to the server. The Point of Sale does not work like that. POS is designed to keep selling when the network drops, so when a session opens it downloads a defined set of models and fields in bulk and then works from that local snapshot. If your field is not in the download list, it does not exist as far as the till is concerned — no error, no placeholder, just an absent value. That is why “I added the field and it shows in the backend but not in POS” is the defining bug of POS development, and why the fix is never in the template alone. A custom POS field is a three-legged change: define it, load it, and only then display or use it.

The upgrade-safe rule: extend, never edit

Everything in this article happens inside your own custom module that depends on point_of_sale. That one decision is what makes the work upgrade-safe:

  • Python models are extended with _inherit, so your class layers on top of Odoo's instead of replacing it.
  • Loader methods call super() and modify the result, so when the next Odoo version adds its own fields to the same loader, both changes compose instead of colliding.
  • JavaScript uses the patch() utility on prototypes rather than redefining components, for the same reason.
  • Templates and views are inherited with xpath anchors, so your display change is a small delta applied over the stock template, and an upgrade that changes unrelated parts of that template still applies cleanly.

The failure mode this avoids is worth naming precisely. Editing core files works immediately, which is why people do it. The cost arrives later and all at once: the upgrade replaces the edited file, the customisation disappears silently, and because nothing was packaged as a module, there is nothing to reinstall and often no record of exactly what was changed. A module, by contrast, either installs on the new version or fails loudly at upgrade time — which is exactly when you want to find out.

Leg 1: Define the field on the model

Suppose cashiers need to see a short internal label — call it x_pos_label — on each product in POS. The field lives on product.product, defined in your module:

from odoo import fields, modelsclass ProductProduct(models.Model):    _inherit = "product.product"    x_pos_label = fields.Char(string="POS Label")

Two naming habits pay off here. Prefix custom fields clearly — Studio fields use the x_ prefix, and giving your code-defined fields a consistent prefix of their own makes collisions with a future Odoo field of the same name far less likely, and makes your fields easy to find in a field list years later. And decide now whether the value is maintained in the backend, like this label, or captured at the till, like a note typed during a sale. Those are different projects: a backend-maintained field only needs loading and display, while a till-captured field also needs a return journey, covered below.

Leg 2: Load the field into the POS session

This is the leg people miss. POS session data is assembled server-side by loader methods on pos.session, one per model, following the naming pattern _loader_params_<model> with dots converted to underscores. To add your field to the product download, override the product loader:

from odoo import modelsclass PosSession(models.Model):    _inherit = "pos.session"    def _loader_params_product_product(self):        result = super()._loader_params_product_product()        result["search_params"]["fields"].append("x_pos_label")        return result

Read the shape carefully, because it generalises to almost every POS loading task. You call super() to get the standard parameters, append your field name to the fields list inside search_params, and return the modified result. The same pattern loads a custom field on partners, orders, or any other model POS already downloads — only the method name changes to match the model. If you need to load an entirely new model that POS does not download at all, the session also provides _pos_ui_models_to_load(), which returns the list of models to fetch; extend it with your model name and give that model its own _loader_params_ method defining its fields and domain. Once loaded, model-level data is processed on the JavaScript side in PosStore._processData(), which you can patch if you need to reshape or stash the loaded records somewhere your components can reach.

One operational fact belongs in every POS developer's memory: the download happens when the session opens. After changing loader code, close and reopen the POS session and reload the page — an already-open till will keep using its old snapshot no matter how many times you restart the server behind it. Half of all “the loader doesn't work” reports are really “the session was never reopened.”

Leg 3: Display it, and send it back if it changes

With the field in the downloaded product data, the product object your templates receive simply carries the new attribute, and display becomes ordinary template inheritance. Extend the product card template (or the receipt template, for order data) from your module's XML, anchoring your xpath on a stable element and rendering the value — for example <t t-esc="props.product.x_pos_label"/> next to the product name — and register both the XML and any JavaScript in the manifest under the POS asset bundle:

"depends": ["point_of_sale"],"assets": {    "point_of_sale._assets_pos": [        "your_module/static/src/**/*",    ],},

If the field is captured at the till rather than maintained in the backend, display is only half of leg three. A value typed into POS lives in the browser's order model first, so it must be included when the order is exported for printing and, critically, when the order payload is sent to the server — and the corresponding field must exist on the server-side model (for an order note, on pos.order) so the incoming value has somewhere to land. Skip the export step and the value prints nowhere and saves nowhere; skip the server field and the sync has nowhere to store it. Test this direction explicitly by placing an order offline, reconnecting, and checking the saved record in the backend.

Upgrading without fear: a short checklist

  • Install your module on a copy of the database after every Odoo upgrade, before touching production, and open a POS session there. Loader and template breaks surface immediately at install or session-open time.
  • Keep xpath anchors conservative. Anchor on structure that is central to the template's purpose; anchors on incidental markup are what snap when a new version restyles a screen.
  • Never store business data only in POS. Custom fields belong on real models in PostgreSQL; POS is a snapshot and a cache, not a database.
  • Watch your payload. Every field you load is downloaded for every product at every session open. Load the two fields you need, not the twenty you might.
  • Document the field's purpose in its help text. The developer who inherits your module — often you, in eighteen months — should not have to reverse-engineer why x_pos_label exists.

Debugging when the field still does not appear

Work the three legs in order and the fault will isolate itself. Defined? Check the field exists on the model in the backend and holds a value on a test record. Loaded? Reopen the session, then inspect the downloaded product data in the browser's developer tools and search for your field name — present means the loader works and the problem is downstream; absent means the loader override is not installed, not named for the right model, or the session was never refreshed. Displayed? Confirm your template file is actually in the POS asset bundle and that the xpath matches the current template; an unmatched xpath on an inherited template fails quietly in the interface even when the data is sitting right there. That sequence — model, loader, template — resolves the overwhelming majority of custom POS field problems, and because every step lives in your own module, the fix will still be there after the next upgrade.

Further reading

Leave a Comment