hooks.py is a file in every Frappe app that tells the framework what the app wants. Frappe reads it from every installed app and merges the results. That merge is the whole extension system. You never edit Frappe. You add a line that says "also run this".
Say what you want to do, and the module shows the hook, a short example, when it fires and what it cannot do.
Try it
What do you want to do?
Pick a goal. The answer is the hooks.py key that does it, and what it cannot do.
I want to
- The hook
doc_events- When it fires
- On that event of that DocType, right after the controller's own method. Use "*" for every DocType.
- What it needs
- Clear the cache (bench clear-cache). The handler is def on_invoice_submit(doc, method=None).
- What it cannot do
- Commit on v16: a commit inside a handler is skipped, with a warning.
Example, not from a client
- The hook
scheduler_events- When it fires
- When the scheduler finds it due and a worker picks it up. daily_long moves it to the long queue (1,500 s).
- What it needs
- bench migrate, so Scheduled Job Type gets the row. A scheduler and a worker running.
- What it cannot do
- Run while the scheduler is off or paused, or beyond its queue's timeout.
Example, not from a client
- The hook
permission_query_conditions- When it fires
- Whenever that DocType is queried with get_list. get_all skips permissions and ignores it.
- What it needs
- A function that returns an SQL condition as a string.
- What it cannot do
- Grant access. Affect get_all. Stop someone opening a record from its link.
- Where it comes from
- frappe/
permissions. py: 483–500model/ db_query. py: 1041–1106
Example, not from a client
- The hook
has_permission- When it fires
- When Frappe checks one document: open, save, submit, delete.
- What it needs
- Return True or False. On v16 returning None denies; on v15 it meant no opinion.
- What it cannot do
- Grant access that roles did not. Hide a row from a list.
Example, not from a client
- The hook
extend_doctype_class- When it fires
- Your mixin sits in front of the controller. Several apps can extend one DocType.
- What it needs
- v16. Call super() in anything you override.
- What it cannot do
- Exist on v15.123.0 (it has only override_doctype_class).
- Where it comes from
- model/
base_document. py: 110–127model/ base_document. py: 190–208
Example, not from a client
- The hook
override_doctype_class- When it fires
- Your class is used instead of the original. It must subclass it.
- What it needs
- bench migrate warns if more than one app overrides the same DocType.
- What it cannot do
- Stack: the last app wins. Receive upstream fixes to that controller without a hand merge.
Example, not from a client
- The hook
fixtures- When it fires
- At bench migrate, the JSON in the app's fixtures folder is imported.
- What it needs
- bench export-fixtures writes the JSON first.
- What it cannot do
- Run when the migrate is told to skip fixtures.
- Where it comes from
- frappe/
migrate. py: 130–133utils/ fixtures. py: 16–29
Example, not from a client
- The hook
naming_series_variables- When it fires
- When a series contains .REGION. as a part. The function receives (doc, variable).
- What it needs
- A name that cannot be mistaken for text. On v16 custom tokens are checked before the built-in ones.
- What it cannot do
- Change the counter key rule: the text before the # decides it.
- Where it comes from
- model/
naming. py: 360–361model/ naming. py: 362–381model/ naming. py: 360–362
Example, not from a client
- The hook
after_migrate- When it fires
- At the end of bench migrate, after fixtures and customisations are synced. before_migrate runs before the schema work.
- What it needs
- Nothing else.
- What it cannot do
- Run on a site that is not migrated.
- Where it comes from
- frappe/
migrate. py: 130–133
Example, not from a client
- The hook
before_job / after_job- When it fires
- Around each job a worker runs: before_job gets (method, kwargs, transaction_type); after_job gets (method, kwargs, result).
- What it needs
- Restart workers after a change.
- What it cannot do
- Skip the job.
- Where it comes from
- utils/
background_jobs. py: 269–270
Example, not from a client
- The hook
override_whitelisted_methods- When it fires
- When the original method is called through the API. ERPNext uses this very line for the contact form.
- What it needs
- Keep the same arguments.
- What it cannot do
- Stack: if two apps override one method, the last one wins.
Example, not from a client
Frappe v16.52.0. Where v15.123.0 differs, the answer says so.
How do hooks from several apps combine?
Lists are joined in the order of the site's installed-apps list. Dictionaries such as doc_events are merged key by key, and a key can be a tuple of DocTypes. So two apps can both hook Sales Invoice on on_submit, and both run, in that order. Outside developer mode the merged hooks are cached, so a change to hooks.py is not live until you clear the cache with bench clear-cache.
Which hook does what?
doc_events runs your function on a document event of any DocType, yours or another app's. * means every DocType. ERPNext uses it itself: submitting or cancelling a Stock Entry updates the Material Request.
scheduler_events lists functions to run hourly, daily or on a cron line. They become rows in Scheduled Job Type only when bench migrate runs (chapter 5).
permission_query_conditions returns SQL that narrows a DocType's list. has_permission can deny one document. Neither can grant (chapter 4).
extend_doctype_class, on v16, adds a mixin to a DocType's controller. override_doctype_class replaces the controller (chapter 7).
fixtures exports records such as Custom Fields to JSON in your app and imports them at migrate. before_migrate and after_migrate run around the migrate.
naming_series_variables adds a token such as FY to naming series (chapter 6). before_job and after_job wrap every background job. override_whitelisted_methods sends an API call to your function instead of Frappe's; if two apps override one method, the last one wins.
What can a hook not do?
It cannot commit. On v16 a frappe.db.commit() inside a doc_events handler does nothing and warns. has_permission can only deny, and permission_query_conditions shapes get_list, which the list view and reports use, but not get_all, which skips permissions. Overriding one DocType's class from two apps does not stack: the last app wins.
How do you see what Frappe actually merged?
In bench console, frappe.get_hooks("doc_events") returns the merged result across apps. When a handler does not run, look there before anywhere else.
Where this comes from
Read at Frappe v16.52.0 and ERPNext v16.50.0. “(v15)” marks Frappe v15.123.0 and “(ERPNext v15)” marks ERPNext v15.122.0. Each link opens that file at that tag, on that line.
frappe/__init__.py:965–988 · frappe/__init__.py:1020–1038 · frappe/__init__.py:927–945 · frappe/__init__.py:948–962 · model/document.py:1658–1660 · frappe/__init__.py:1004–1017 · frappe/cache_manager.py:23–25 · frappe/cache_manager.py:297–329 · commands/utils.py:149–150 · erpnext/hooks.py:377–420 (ERPNext) · frappe/migrate.py:169–170 · scheduled_job_type/scheduled_job_type.py:221–293 · frappe/permissions.py:483–500 · model/db_query.py:1173–1196 · model/base_document.py:110–127 · model/base_document.py:179–208 · frappe/migrate.py:134–142 · erpnext/hooks.py:47 (ERPNext v15) · erpnext/hooks.py:82 (ERPNext) · utils/boilerplate.py:545–640 · frappe/migrate.py:130–133 · frappe/migrate.py:177–181 · frappe/migrate.py:208–211 · model/naming.py:360–361 · model/naming.py:390–392 · utils/background_jobs.py:269–270 · utils/background_jobs.py:312–313 · frappe/__init__.py:1580–1583 · frappe/handler.py:66–68
Official page: Hooks, read 14 October 2026. Blog: building a custom Frappe app that survives upgrades.