What Uninstalling a Module Does
When you uninstall an Odoo module, the framework performs a cascade of cleanup operations. Understanding this process is essential for module developers who need to handle uninstallation gracefully.
Automatically Removed
- Views: All ir.ui.view records owned by the module
- Menu items: All ir.ui.menu records from the module
- Actions: ir.actions.act_window, ir.actions.server, ir.actions.client
- Security: ir.model.access and ir.rule records
- Groups: res.groups defined by the module
- Cron jobs: ir.cron records from the module
- Data records: Records with
noupdate="0"in data files - Email templates: mail.template records
- Report definitions: ir.actions.report records
NOT Automatically Removed
- Database tables: Tables created by module models are NOT dropped
- Columns: Fields added to existing tables are NOT removed
- Data with noupdate="1": Records flagged as no-update survive uninstallation
- User data: Records created by users (not data files) stay in the database
- Custom indexes: Indexes created in init() are not dropped
- ir.config_parameter: System parameters persist
The uninstall_hook
Use uninstall_hook to clean up resources the framework does not handle:
def _uninstall_hook(env):
"""Clean up before module removal."""
# Remove config parameters
env['ir.config_parameter'].sudo().search([
('key', 'like', 'my_module.%')
]).unlink()
# Drop custom indexes
env.cr.execute("DROP INDEX IF EXISTS my_custom_index")
# Clean up data from other models
env['mail.template'].search([
('name', 'like', 'My Module:%')
]).unlink()
# Remove custom fields from existing models
fields_to_remove = env['ir.model.fields'].search([
('model', '=', 'res.partner'),
('name', 'like', 'x_mymodule_%'),
])
fields_to_remove.unlink()Why Tables Are Not Dropped
Odoo intentionally preserves database tables after uninstallation for several reasons:
- Data safety: Users may have created thousands of records that should not be silently destroyed
- Reinstallation: If the module is reinstalled, existing data is preserved
- Foreign keys: Other tables may reference the module's tables
- Audit trail: Regulatory requirements may mandate data retention
If you need to drop tables, do it explicitly in the uninstall_hook:
def _uninstall_hook(env):
# WARNING: This permanently deletes all data in this table
env.cr.execute("DROP TABLE IF EXISTS my_module_data CASCADE")Handling Orphaned Columns
When your module adds fields to existing models (via inheritance), those columns remain after uninstallation:
class ResPartner(models.Model):
_inherit = 'res.partner'
x_loyalty_points = fields.Integer() # column stays after uninstallClean up in uninstall_hook:
def _uninstall_hook(env):
env.cr.execute("""
ALTER TABLE res_partner
DROP COLUMN IF EXISTS x_loyalty_points
""")Cleaning Up Cron Jobs
Cron jobs defined in data files are usually removed automatically. But if they were created with noupdate="1", they persist:
def _uninstall_hook(env):
cron = env.ref('my_module.cron_daily_sync', raise_if_not_found=False)
if cron:
cron.write({'active': False})
cron.unlink()Safe Uninstall Pattern
A comprehensive uninstall_hook:
import logging
_logger = logging.getLogger(__name__)
def _uninstall_hook(env):
_logger.info('Running my_module uninstall hook')
# 1. Deactivate cron jobs first
crons = env['ir.cron'].search([
('ir_actions_server_id.model_id.model', 'in', [
'my.model1', 'my.model2'
])
])
crons.write({'active': False})
# 2. Clean up config parameters
env['ir.config_parameter'].sudo().search([
('key', 'like', 'my_module.%')
]).unlink()
# 3. Remove custom indexes
for idx in ['my_index_1', 'my_index_2']:
env.cr.execute(f"DROP INDEX IF EXISTS {idx}")
# 4. Clean up orphaned columns on inherited models
for col in ['x_custom_field1', 'x_custom_field2']:
env.cr.execute(f"""
ALTER TABLE res_partner
DROP COLUMN IF EXISTS {col}
""")
# 5. Optionally drop module tables (destructive!)
# env.cr.execute("DROP TABLE IF EXISTS my_module_data CASCADE")
_logger.info('my_module uninstall hook completed')Testing Uninstallation
class TestUninstall(TransactionCase):
def test_uninstall_hook_cleans_params(self):
# Setup
self.env['ir.config_parameter'].sudo().set_param('my_module.test', 'value')
# Run hook
_uninstall_hook(self.env)
# Verify cleanup
param = self.env['ir.config_parameter'].sudo().get_param('my_module.test')
self.assertFalse(param)Common Mistakes
- Not handling errors: If uninstall_hook raises, the uninstallation aborts. Wrap operations in try/except
- Forgetting CASCADE: When dropping tables with foreign keys, use
DROP TABLE ... CASCADE - Not logging: Silent hooks are impossible to debug in production
- Dropping user data without warning: Consider archiving instead of deleting
- Circular dependencies: If Module A depends on Module B, uninstalling B first triggers A's removal too
Module Dependencies and Uninstall Order
When you uninstall a module, all modules that depend on it are also uninstalled. The order is:
- Dependent modules are uninstalled first (reverse dependency order)
- Each module's uninstall_hook runs during its uninstallation
- Framework cleanup (views, menus, etc.) happens per module
- The base module being uninstalled is processed last
Best Practices
- Always define an uninstall_hook if your module creates custom indexes, config parameters, or modifies other models
- Log all cleanup operations for traceability
- Use
raise_if_not_found=Falsewhen referencing XML IDs — they may already be deleted - Wrap operations in try/except to prevent uninstallation from failing
- Consider the impact on user data — archive instead of delete when possible
- Test uninstallation as part of your module's test suite