Three Model Types in Odoo
Odoo has three model base classes, each serving a different purpose. Choosing the wrong one causes either unnecessary database tables, missing functionality, or data that never gets cleaned up.
| Type | Database Table | Persistence | Use Case |
|---|---|---|---|
| models.Model | Yes | Permanent | Business data (orders, contacts, products) |
| models.AbstractModel | No | None | Mixins and shared behavior |
| models.TransientModel | Yes (temporary) | Auto-cleaned | Wizards and temporary dialogs |
models.Model — Persistent Models
Standard Odoo models. Every record is stored permanently in the database until explicitly deleted:
class SaleOrder(models.Model):
_name = 'sale.order'
_description = 'Sales Order'
name = fields.Char(required=True)
partner_id = fields.Many2one('res.partner')
amount_total = fields.Float()Characteristics
- Creates a PostgreSQL table named after _name (dots replaced with underscores)
- Records persist until deleted via unlink()
- Supports all ORM features: access rules, record rules, workflows
- Supports mail.thread for chatter integration
- This is what you use for 95% of business models
models.AbstractModel — Mixins
Abstract models define reusable behavior without creating database tables:
class ApprovableMixin(models.AbstractModel):
_name = 'approvable.mixin'
_description = 'Approvable Mixin'
approved = fields.Boolean(default=False)
approved_by = fields.Many2one('res.users')
approved_date = fields.Datetime()
def action_approve(self):
self.write({
'approved': True,
'approved_by': self.env.uid,
'approved_date': fields.Datetime.now(),
})
def action_reject(self):
self.write({
'approved': False,
'approved_by': False,
'approved_date': False,
})Using the Mixin
class PurchaseRequest(models.Model):
_name = 'purchase.request'
_inherit = ['approvable.mixin', 'mail.thread']
name = fields.Char(required=True)
amount = fields.Float()Characteristics
- No database table created
- Fields are added to the inheriting model's table
- Methods are available on the inheriting model
- Cannot be instantiated directly
- Cannot have record rules or access rights
- Used for: mail.thread, mail.activity.mixin, portal.mixin, image.mixin
When to Create an AbstractModel
- You have behavior shared across 2+ unrelated models
- You want to standardize field names and methods
- You are building a framework-level feature (like tracking, approval, or soft-delete)
models.TransientModel — Wizards
Transient models create temporary records that are automatically garbage collected:
class MassMailWizard(models.TransientModel):
_name = 'mass.mail.wizard'
_description = 'Mass Mail Wizard'
subject = fields.Char(required=True)
body = fields.Html(required=True)
partner_ids = fields.Many2many('res.partner')
def action_send(self):
for partner in self.partner_ids:
# Send email logic
partner.message_post(
body=self.body,
subject=self.subject,
)
return {'type': 'ir.actions.act_window_close'}Characteristics
- Creates a database table (records are stored temporarily)
- Records are automatically deleted by a cron job (default: after ~1 hour)
- Designed for user dialogs and multi-step wizards
- Access rules are simpler (users can always access their own wizard records)
- Should NOT be used for permanent data storage
Wizard View and Action
<!-- Form view -->
<record id="mass_mail_wizard_form" model="ir.ui.view">
<field name="name">mass.mail.wizard.form</field>
<field name="model">mass.mail.wizard</field>
<field name="arch" type="xml">
<form string="Send Mass Mail">
<group>
<field name="subject"/>
<field name="partner_ids" widget="many2many_tags"/>
</group>
<field name="body"/>
<footer>
<button string="Send" type="object" name="action_send" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action -->
<record id="mass_mail_wizard_action" model="ir.actions.act_window">
<field name="name">Send Mass Mail</field>
<field name="res_model">mass.mail.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>Pre-Populating Wizards
def open_wizard(self):
return {
'type': 'ir.actions.act_window',
'res_model': 'mass.mail.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_partner_ids': [(6, 0, self.partner_ids.ids)],
'active_ids': self.ids,
},
}Garbage Collection
TransientModel records are cleaned by _transient_vacuum():
- Default retention: records older than 1 hour are deleted
- Configurable via
transient_age_limitconfig parameter (hours) - The cron runs periodically to clean up
- Do not rely on wizard records existing after the user closes the dialog
Common Pitfalls
- Using TransientModel for persistent data — Records will be garbage collected. Use Model for anything that needs to persist.
- Creating AbstractModel for one model — If only one model uses the behavior, skip the mixin. Abstract models add complexity.
- Forgetting target='new' on wizards — Without this, the wizard opens as a full-page form instead of a dialog.
- Wizard data after close — Never reference wizard records from permanent models. The wizard record will be deleted by garbage collection.