What Is a Wizard?#
A wizard is a temporary model instance - created, used, and automatically cleaned up - that collects input from the user before executing a business action. Examples built into Odoo:
- Validate account move - confirm posting a journal entry with a date override
- Create invoices from sales orders - pick which orders to invoice and choose the invoice date
- Transfer picking - handle partial quantities, backorders, and lot selection in one dialog
The technical base is models.TransientModel. Unlike regular models, TransientModel records are stored in the database but automatically deleted by the base.automation cleanup cron (by default after 24 hours). They do not appear in menus and are not included in standard exports.
Minimal Wizard#
from odoo import models, fields, api
class StockValidateWizard(models.TransientModel):
_name = 'stock.validate.wizard'
_description = 'Validate Transfer Wizard'
picking_id = fields.Many2one('stock.picking', required=True)
confirm_backorder = fields.Boolean(default=False)
note = fields.Text()
def action_validate(self):
self.ensure_one()
# Do the work here - the wizard instance has the user's values
self.picking_id.do_transfer()
return {'type': 'ir.actions.act_window_close'}<record id="action_stock_validate_wizard" model="ir.actions.act_window">
<field name="name">Validate Transfer</field>
<field name="res_model">stock.validate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_picking_id': active_id}</field>
</record>Key points:
target="new"opens the wizard as a modal dialogdefault_picking_idis injected viacontext, so the wizard auto-links to the triggering recordaction_validatereturnsir.actions.act_window_closeto dismiss the modal
Binding the Wizard to a Button#
From a parent model's view (preferred):
<button name="action_open_validate_wizard" type="object" string="Validate" class="btn-primary"/>class StockPicking(models.Model):
_inherit = 'stock.picking'
def action_open_validate_wizard(self):
return {
'type': 'ir.actions.act_window',
'res_model': 'stock.validate.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_picking_id': self.id},
}From a server action (bound to a model - adds to the Action menu):
<record id="action_stock_validate_server" model="ir.actions.server">
<field name="name">Validate Transfer</field>
<field name="model_id" ref="stock.model_stock_picking"/>
<field name="binding_model_id" ref="stock.model_stock_picking"/>
<field name="state">code</field>
<field name="code">action = records.action_open_validate_wizard()</field>
</record>The binding_model_id field makes the action appear in the Action dropdown of the model's list and form views.
Default Values from Context#
Wizards receive context from the triggering action. Use _default_{field_name} or override default_get:
class InvoiceWizard(models.TransientModel):
_name = 'sale.invoice.wizard'
invoice_date = fields.Date(default=fields.Date.today)
order_ids = fields.Many2many('sale.order')
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
active_ids = self.env.context.get('active_ids', [])
defaults['order_ids'] = [(6, 0, active_ids)]
return defaultsThe (6, 0, ids) command replaces the Many2many with the given list. This is the canonical way to pass a multi-record selection from a list view to a wizard.
Multi-Step Wizards#
For longer flows, keep state in the wizard model and switch views based on it:
class DataImportWizard(models.TransientModel):
_name = 'data.import.wizard'
state = fields.Selection([
('upload', 'Upload File'),
('preview', 'Preview'),
('done', 'Done'),
], default='upload')
file_data = fields.Binary(string='CSV File')
preview_html = fields.Html(readonly=True)
imported_count = fields.Integer(readonly=True)
def action_preview(self):
self.ensure_one()
self.preview_html = self._parse_preview()
self.state = 'preview'
return self._reopen()
def action_import(self):
self.ensure_one()
count = self._do_import()
self.imported_count = count
self.state = 'done'
return self._reopen()
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}The _reopen helper re-opens the same wizard instance with the updated state. The view should use attrs or invisible to show the right fields per state.
Common Gotchas#
Gotcha 1: Missing ensure_one()#
Wizard action methods are called from a recordset. Even though a wizard should always have one record, the framework may pass a multi-record set in edge cases. Always call self.ensure_one() at the start of any action method.
Gotcha 2: Context not passed through _reopen#
When you re-open the wizard with _reopen(), the original active_id / active_ids are NOT automatically re-injected. Explicitly preserve context:
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
'context': self.env.context, # preserve caller context
}Gotcha 3: Wizard records persist until cron cleanup#
TransientModel records are NOT deleted immediately when the modal is closed. They live in the database until the base.action.cleandb scheduled action runs (default: every 24h). Do not store sensitive data in wizard fields and do not rely on the record being deleted synchronously.
Gotcha 4: Many2many commands in default_get#
Using (4, id) (link) instead of (6, 0, ids) (replace) in default_get can cause duplicate entries if the wizard is re-opened. Always use (6, 0, ids) when setting an initial Many2many value from context.
Gotcha 5: target="new" vs target="current"#
target="new" opens as a modal. target="current" replaces the current view. For wizard confirmation dialogs, always use target="new". Using target="current" for wizards that need to return to the parent record requires explicit breadcrumb management.
Testing Wizards#
def test_validate_wizard(self):
picking = self.env['stock.picking'].create({...})
wizard = self.env['stock.validate.wizard'].create({
'picking_id': picking.id,
'confirm_backorder': False,
})
wizard.action_validate()
self.assertEqual(picking.state, 'done')Instantiate the wizard directly in tests - no need to simulate button clicks or UI context. Pass fields as create values. Then call the action method and assert on the result.
ERPeek can answer questions like "which wizard models are defined in this module?" or "what does this wizard's action_validate method actually write?" across any installed Odoo version and codebase. See the contact page for details.

