
Frappe Syntax Hooks
- 58 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Configure Frappe hooks.py for doc_events, scheduler_events, fixtures, asset includes, and method overrides across v14/v15/v16.
About
Guides configuring Frappe's hooks.py for app events, scheduler tasks, fixtures, and website routing. A developer uses it when registering hooks or customizing app behavior via hooks.py.
- Configure hooks.py for app events, scheduler, doc events, and fixtures
- Covers app_include_js, override_whitelisted_methods, and extend_doctype_class
Frappe Syntax Hooks by the numbers
- 58 all-time installs (skills.sh)
- Ranked #3,178 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-syntax-hooksAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Configure Frappe hooks.py for doc_events, scheduler_events, fixtures, asset includes, and method overrides across v14/v15/v16.
Files
Frappe Configuration Hooks (hooks.py)
Configuration hooks in hooks.py enable custom apps to extend Frappe/ERPNext behavior. This skill covers ALL non-document-event hooks. For doc_events (validate, on_submit, on_update, etc.), see frappe-syntax-hooks-events.
Quick Reference: Hook Categories
| Category | Key Hooks | Reference |
|---|---|---|
| App metadata | app_name, app_title, required_apps | Below |
| Frontend assets | app_include_js/css, web_include_js/css | Below |
| Install/migrate | before_install, after_install, after_migrate | Below |
| Scheduler | hourly, daily, cron, *_long | scheduler-events.md |
| Session/auth | on_login, on_logout, auth_hooks | bootinfo.md |
| Request middleware | before_request, after_request | request-lifecycle.md |
| Permissions | permission_query_conditions, has_permission | permissions.md |
| DocType overrides | override_doctype_class, doctype_js | overrides.md |
| Website/portal | website_route_rules, portal_menu_items | request-lifecycle.md |
| File handling | before_write_file, write_file | Below |
override_email_send, default_mail_footer | Below | |
pdf_header_html, pdf_footer_html | Below | |
| Jinja | jinja.methods, jinja.filters | Below |
| Boot/client data | extend_bootinfo, notification_config | bootinfo.md |
| Data/fixtures | fixtures, global_search_doctypes | Below |
| Method overrides | override_whitelisted_methods, standard_queries | overrides.md |
---
Decision Tree: Which Hook Do I Need?
What do you want to achieve?
|
+-- ADD JS/CSS to desk or portal?
| +-- Desk --> app_include_js / app_include_css
| +-- Portal --> web_include_js / web_include_css
| +-- Specific form --> doctype_js
| +-- List view --> doctype_list_js
|
+-- RUN periodic background tasks?
| +-- < 5 min execution --> hourly / daily / weekly / monthly
| +-- 5-25 min execution --> hourly_long / daily_long / etc.
| +-- Exact time needed --> cron
| See: frappe-syntax-hooks > scheduler-events.md
|
+-- SEND data to client at page load?
| +-- extend_bootinfo
|
+-- MODIFY controller of existing DocType?
| +-- v16+ --> extend_doctype_class (RECOMMENDED)
| +-- v14/v15 --> override_doctype_class (last app wins)
|
+-- MODIFY API endpoint?
| +-- override_whitelisted_methods
|
+-- CUSTOMIZE permissions?
| +-- List filtering --> permission_query_conditions
| +-- Document-level --> has_permission
|
+-- REACT to document save/submit/delete?
| +-- See frappe-syntax-hooks-events skill
|
+-- EXPORT/IMPORT configuration?
| +-- fixtures
|
+-- SETUP on install or migrate?
| +-- after_install / after_migrate
|
+-- ADD custom Jinja functions?
| +-- jinja.methods / jinja.filters
|
+-- CUSTOMIZE website routing?
| +-- website_route_rules
| See: request-lifecycle.md for full routing pipeline
|
+-- INTERCEPT every request/response?
| +-- before_request / after_request
| See: request-lifecycle.md for lifecycle flow
|
+-- CUSTOM page rendering?
| +-- page_renderer hook
| See: request-lifecycle.md for renderer architecture---
1. App Metadata Hooks
ALWAYS include these in every hooks.py:
app_name = "myapp"
app_title = "My App"
app_publisher = "My Company"
app_description = "Custom ERPNext extensions"
app_email = "info@mycompany.com"
app_license = "MIT"
required_apps = ["erpnext"] # Declare dependencies---
2. Frontend Asset Injection
# Desk (backend UI) assets — loaded on EVERY desk page
app_include_js = "/assets/myapp/js/myapp.min.js" # string or list
app_include_css = "/assets/myapp/css/myapp.min.css"
# Website/portal assets — loaded on EVERY web page
web_include_js = "/assets/myapp/js/web.min.js"
web_include_css = "/assets/myapp/css/web.min.css"
# Web form specific assets
webform_include_js = {"My Web Form": "public/js/my_webform.js"}
webform_include_css = {"My Web Form": "public/css/my_webform.css"}
# Form script extensions (extend OTHER apps' forms)
doctype_js = {"Sales Invoice": "public/js/sales_invoice.js"}
# List view script extensions
doctype_list_js = {"Sales Invoice": "public/js/sales_invoice_list.js"}
# Custom sounds
sounds = [{"name": "alert", "src": "/assets/myapp/sounds/alert.mp3", "volume": 0.5}]NEVER put heavy libraries in app_include_js — they load on every page.
---
3. Installation & Migration Lifecycle
before_install = "myapp.setup.before_install"
after_install = "myapp.setup.after_install"
after_sync = "myapp.setup.after_sync" # After fixture sync
before_migrate = "myapp.setup.before_migrate"
after_migrate = "myapp.setup.after_migrate"
before_uninstall = "myapp.setup.before_uninstall"
after_uninstall = "myapp.setup.after_uninstall"
before_tests = "myapp.setup.seed_test_data"All accept a single dotted-path string. The function receives no arguments.
---
4. Scheduler Events
See scheduler-events.md for full reference.
scheduler_events = {
"all": ["myapp.tasks.every_minute"], # ~60s interval
"hourly": ["myapp.tasks.hourly_check"], # default queue, 5 min timeout
"daily": ["myapp.tasks.daily_report"],
"weekly": ["myapp.tasks.weekly_cleanup"],
"monthly": ["myapp.tasks.monthly_summary"],
"daily_long": ["myapp.tasks.heavy_sync"], # long queue, 25 min timeout
"cron": {
"0 9 * * 1-5": ["myapp.tasks.weekday_morning"] # cron expression
}
}ALWAYS run bench --site sitename migrate after changing scheduler_events. NEVER define task functions with arguments — they receive none.
---
5. Session & Authentication Hooks
on_login = "myapp.auth.on_login" # Receives login_manager
on_logout = "myapp.auth.on_logout" # No arguments
on_session_creation = "myapp.auth.on_session_creation" # No arguments
auth_hooks = ["myapp.auth.validate_request"] # List of validatorsExecution order: on_login --> session created --> on_session_creation --> extend_bootinfo.
---
6. Request/Response Middleware
See request-lifecycle.md for the full request lifecycle flow, page renderer architecture, and router API.
before_request = ["myapp.middleware.before_request"] # List of dotted paths
after_request = ["myapp.middleware.after_request"]
before_job = ["myapp.middleware.before_job"] # Before background job
after_job = ["myapp.middleware.after_job"] # After background job---
7. Permission Hooks
See permissions.md for full reference.
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query_conditions"
}
has_permission = {
"Sales Invoice": "myapp.permissions.si_has_permission"
}ALWAYS check if not user: user = frappe.session.user in handlers. ALWAYS use frappe.db.escape(user) in SQL — NEVER string interpolation. permission_query_conditions works ONLY with get_list, NOT get_all.
---
8. DocType Class Overrides
See overrides.md for full reference.
# v14+ — Full replacement (LAST installed app wins)
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSalesInvoice"
}
# v16+ — Mixin-based extension (ALL apps coexist) [RECOMMENDED]
extend_doctype_class = {
"Address": ["myapp.extensions.AddressMixin"]
}ALWAYS call super().method() in overrides. Forgetting super() breaks core logic.
---
9. Website & Portal Hooks
# URL routing
website_route_rules = [
{"from_route": "/custom-page/<name>", "to_route": "Custom Page"}
]
website_redirects = [
{"source": "/old-url", "target": "/new-url"}
]
website_catch_all = "myapp.www.custom_404"
# Homepage
homepage = "my-custom-home"
role_home_page = {"Sales User": "sales-dashboard"}
get_website_user_home_page = "myapp.utils.get_home_page"
# Portal sidebar
portal_menu_items = [{"title": "My Orders", "route": "/orders", "role": "Customer"}]
standard_portal_menu_items = [{"title": "My Items", "route": "/my-items"}]
# Template overrides
base_template = "myapp/templates/base.html"
website_context = {"brand_html": "<b>My Brand</b>"}
update_website_context = "myapp.context.update_context"---
10. File Handling Hooks
before_write_file = "myapp.files.before_write" # Pre-save hook
write_file = "myapp.files.custom_write" # Replace file storage (e.g., S3/CDN)
delete_file_data_content = "myapp.files.custom_delete" # Replace file deletionUse write_file to redirect file storage to cloud providers (S3, GCS, Azure Blob).
---
11. Email Hooks
override_email_send = "myapp.email.custom_send" # Replace email backend
get_sender_details = "myapp.email.get_sender" # Override From address
default_mail_footer = "myapp.email.get_footer" # HTML footer for all emails---
12. PDF Hooks
pdf_header_html = "myapp.pdf.get_header" # Custom PDF header
pdf_body_html = "myapp.pdf.get_body" # Custom PDF body wrapper
pdf_footer_html = "myapp.pdf.get_footer" # Custom PDF footer
# pdf_generator = "myapp.pdf.generate" # [v16+] Replace PDF engine---
13. Jinja Hooks
# Add custom methods available in Jinja templates
jinja = {
"methods": ["myapp.jinja_utils.get_balance"],
"filters": ["myapp.jinja_utils.format_iban"]
}# myapp/jinja_utils.py
def get_balance(customer):
"""Usage in template: {{ get_balance(doc.customer) }}"""
return frappe.db.get_value("Customer", customer, "outstanding_amount") or 0
def format_iban(value):
"""Usage in template: {{ bank_account|format_iban }}"""
if not value: return ""
return " ".join([value[i:i+4] for i in range(0, len(value), 4)])---
14. Boot & Client Data
See bootinfo.md for full reference.
extend_bootinfo = "myapp.boot.extend_boot"
notification_config = "myapp.notifications.get_config"NEVER put secrets/API keys in bootinfo — it is sent to the browser. NEVER run heavy queries in bootinfo — it runs on EVERY page load.
---
15. Data & Fixtures
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My App"]]},
{"dt": "Role", "filters": [["name", "like", "MyApp%"]]}
]
global_search_doctypes = {"My DocType": {"index": 10}}
ignore_links_on_delete = ["Communication", "Activity Log"]
calendars = ["My Event DocType"]
clear_cache = "myapp.cache.clear_custom_cache"ALWAYS use filters in fixtures — NEVER export unfiltered (exports everything). NEVER put transactional data (Sales Invoice, Stock Entry) in fixtures.
---
16. Method Overrides
See overrides.md for full reference.
override_whitelisted_methods = {
"frappe.client.get_count": "myapp.overrides.custom_get_count"
}
standard_queries = {
"Customer": "myapp.queries.customer_query"
}ALWAYS match the original method signature exactly when overriding.
---
Version Differences
| Hook | v14 | v15 | v16+ |
|---|---|---|---|
extend_doctype_class | -- | -- | NEW |
extend_bootinfo | Yes | Yes | Yes |
auth_hooks | Yes | Yes | Yes |
after_sync | Yes | Yes | Yes |
before_uninstall | -- | Yes | Yes |
after_uninstall | -- | Yes | Yes |
website_path_resolver | -- | Yes | Yes |
| All other hooks | Yes | Yes | Yes |
---
Critical Rules
1. ALWAYS run bench --site sitename migrate after ANY hooks.py change 2. NEVER import frappe at module level in hooks.py — it runs before init 3. ALWAYS use dotted paths ("myapp.module.function") — NEVER lambdas 4. NEVER commit in hook handlers — Frappe manages transactions 5. ALWAYS test hooks in a dev environment before deploying
---
Anti-Patterns Summary
| Wrong | Correct |
|---|---|
| No filters in fixtures | ALWAYS filter by module/app |
| Secrets in bootinfo | ONLY public config in bootinfo |
| Heavy queries in bootinfo | Cache or minimize data |
get_all with permission hooks | Use get_list for permission filtering |
Override without super() | ALWAYS call super().method() first |
| Scheduler tasks with args | Tasks receive NO arguments |
Skip bench migrate | ALWAYS migrate after hook changes |
Full anti-patterns: anti-patterns.md
---
Reference Files
| File | Contents |
|---|---|
| hooks.md | Complete hooks catalog by category |
| scheduler-events.md | Scheduler frequencies, cron syntax, timeouts |
| permissions.md | Permission hooks in detail |
| overrides.md | DocType class override patterns |
| bootinfo.md | extend_bootinfo, session hooks, notification_config |
| examples.md | Working hooks.py examples for each category |
| request-lifecycle.md | Request lifecycle, routing pipeline, page renderers, router API |
| anti-patterns.md | Common hook mistakes and corrections |
For document lifecycle events (doc_events), see: frappe-syntax-hooks-events
Anti-Patterns and Best Practices
Common mistakes in hooks.py configuration and how to avoid them. For doc_events anti-patterns, see frappe-syntax-hooks-events.
---
Scheduler Anti-Patterns
NEVER Use Default Queue for Heavy Tasks
# WRONG — timeout after 5 minutes
scheduler_events = {
"daily": ["myapp.tasks.sync_all_records"] # May take 20 min
}
# CORRECT — 25 minute timeout
scheduler_events = {
"daily_long": ["myapp.tasks.sync_all_records"]
}| Queue | Timeout |
|---|---|
| default (hourly, daily, etc.) | 300s (5 min) |
| long (hourly_long, daily_long, etc.) | 1500s (25 min) |
NEVER Define Tasks with Arguments
# WRONG — scheduler passes no arguments
def my_task(company_name):
process_company(company_name)
# CORRECT — fetch data inside the function
def my_task():
for company in frappe.get_all("Company"):
process_company(company.name)ALWAYS Run bench migrate After Changes
# REQUIRED after any scheduler_events change
bench --site sitename migrateScheduler events are cached. Without migrate, changes are NOT picked up.
---
Override Anti-Patterns
NEVER Forget super() in Overrides
# WRONG — parent validate skipped, core logic broken
class CustomSalesInvoice(SalesInvoice):
def validate(self):
self.custom_validation()
# CORRECT — ALWAYS call super() first
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate()
self.custom_validation()Consequence: Forgetting super() skips core validations, calculations, and side effects. Ledger entries, tax calculations, and stock updates may break.
NEVER Mismatch Method Signatures
# WRONG — original has 4 params, override has 1
override_whitelisted_methods = {
"frappe.client.get_count": "myapp.overrides.my_get_count"
}
def my_get_count(doctype): # Missing parameters!
return frappe.db.count(doctype)
# CORRECT — exact same signature
def my_get_count(doctype, filters=None, debug=False, cache=False):
count = frappe.db.count(doctype, filters)
return countALWAYS look up the original function signature before writing an override.
---
Permission Anti-Patterns
NEVER Skip the User None Check
# WRONG — user can be None, causing errors
def my_query_conditions(user):
return f"owner = {frappe.db.escape(user)}"
# CORRECT — ALWAYS check for None
def my_query_conditions(user):
if not user:
user = frappe.session.user
return f"owner = {frappe.db.escape(user)}"NEVER Use Raw String Interpolation in SQL
# WRONG — SQL injection vulnerability
def my_query_conditions(user):
return f"owner = '{user}'"
# CORRECT — use frappe.db.escape
def my_query_conditions(user):
if not user:
user = frappe.session.user
return f"owner = {frappe.db.escape(user)}"NEVER Expect get_all to Apply Permission Hooks
# WRONG expectation — get_all IGNORES permission_query_conditions
frappe.db.get_all("Sales Invoice")
# CORRECT — get_list respects permission hooks
frappe.db.get_list("Sales Invoice")---
Fixture Anti-Patterns
NEVER Export Fixtures Without Filters
# WRONG — exports ALL custom fields from ALL apps
fixtures = ["Custom Field"]
# CORRECT — filter to your app's records only
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]}
]Risk: Unfiltered exports include other apps' custom fields, causing conflicts.
NEVER Put Transactional Data in Fixtures
# WRONG — transactional data should not be in fixtures
fixtures = ["Sales Invoice", "Stock Entry"]
# CORRECT — only configuration data
fixtures = ["Custom Field", "Property Setter", "Role"]---
Boot Info Anti-Patterns
NEVER Put Secrets in Bootinfo
# WRONG — secrets exposed to browser JavaScript
def extend_boot(bootinfo):
bootinfo.api_key = frappe.get_single("Settings").secret_key
bootinfo.db_password = get_db_password()
# CORRECT — only public configuration
def extend_boot(bootinfo):
bootinfo.app_version = "1.0.0"
bootinfo.feature_flags = {"new_ui": True}NEVER Run Heavy Queries in Bootinfo
# WRONG — runs on EVERY page load
def extend_boot(bootinfo):
bootinfo.all_customers = frappe.get_all("Customer")
# CORRECT — minimal, cached data
def extend_boot(bootinfo):
count = frappe.cache().get_value("customer_count")
if count is None:
count = frappe.db.count("Customer")
frappe.cache().set_value("customer_count", count, expires_in_sec=3600)
bootinfo.customer_count = count---
General Hook Anti-Patterns
NEVER Import frappe at Module Level in hooks.py
# WRONG — hooks.py is loaded before frappe is initialized
import frappe # Will cause ImportError during early bootstrap
app_name = "myapp"hooks.py uses plain Python assignments, not frappe API calls. All "myapp.module.function" paths are resolved lazily at runtime.
NEVER Use Lambdas or Inline Functions
# WRONG — hooks must be dotted path strings
after_install = lambda: print("installed")
# CORRECT — dotted path to a named function
after_install = "myapp.setup.after_install"NEVER Skip bench migrate After Hook Changes
Any change to hooks.py requires:
bench --site sitename migrateThis applies to ALL hook types, not just scheduler_events.
---
Best Practices Summary
ALWAYS Do
| Practice | Reason |
|---|---|
Call super() in overrides | Preserve core functionality |
Run bench migrate after changes | Hook config is cached |
Use frappe.db.escape() for SQL | Prevent injection |
Use _long for heavy tasks | Prevent timeouts |
| Filter fixtures by module | Prevent conflicts |
Check if not user in permission hooks | User can be None |
| Cache bootinfo data | Runs on every page load |
| Guard against Guest in bootinfo | Prevent data leaks |
NEVER Do
| Anti-Pattern | Problem |
|---|---|
| Heavy tasks in default queue | 5 min timeout |
| Tasks with arguments | Scheduler passes none |
get_all with permission hooks | Ignores permissions |
| Secrets in bootinfo | Exposed to browser |
| Fixtures without filters | Exports too much |
Override without super() | Breaks core logic |
| Raw SQL interpolation | Injection risk |
Skip bench migrate | Changes not picked up |
---
Debug Checklist
When hooks are not working:
1. Migrate forgotten?
bench --site sitename migrate2. Scheduler not running?
bench --site sitename scheduler status
bench --site sitename scheduler enable3. Cache stale?
bench --site sitename clear-cache4. Syntax error in hooks.py?
python -c "import myapp.hooks"5. Check logs:
tail -f ~/frappe-bench/logs/scheduler.log
tail -f ~/frappe-bench/logs/worker.logBoot & Session Hooks Reference
Complete reference for extend_bootinfo, notification_config, and session hooks in hooks.py.
---
extend_bootinfo
Inject global values into frappe.boot that are available in client-side JavaScript on every page load.
Syntax
# hooks.py
extend_bootinfo = "myapp.boot.extend_boot"Handler Signature
def extend_boot(bootinfo):
"""
Args:
bootinfo: frappe.boot object (dict-like).
Values added here appear in frappe.boot on the client.
"""Implementation Examples
Feature Flags
# myapp/boot.py
import frappe
def extend_boot(bootinfo):
settings = frappe.get_single("My App Settings")
bootinfo.feature_flags = {
"new_dashboard": settings.enable_new_dashboard,
"beta_features": settings.enable_beta
}// Client-side access
if (frappe.boot.feature_flags.new_dashboard) {
load_new_dashboard();
}User Permissions Cache
def extend_boot(bootinfo):
user = frappe.session.user
bootinfo.custom_permissions = {
"can_approve_orders": has_approval_rights(user),
"max_discount_percent": get_max_discount(user),
"allowed_warehouses": get_user_warehouses(user)
}Company Defaults
def extend_boot(bootinfo):
if frappe.session.user != "Guest":
default_company = frappe.defaults.get_user_default("Company")
if default_company:
bootinfo.company_settings = frappe.db.get_value(
"Company",
default_company,
["default_currency", "country", "tax_id"],
as_dict=True
)What frappe.boot Contains by Default
| Property | Content |
|---|---|
frappe.boot.user | Current user info |
frappe.boot.home_page | Home page route |
frappe.boot.user_info | User metadata |
frappe.boot.lang | Active language |
frappe.boot.sysdefaults | System defaults |
frappe.boot.notification_settings | Notification config |
frappe.boot.modules | Available modules |
frappe.boot.desk_settings | Desk configuration |
---
notification_config
Configure client-side notification behavior.
Syntax
# hooks.py
notification_config = "myapp.notifications.get_config"Implementation
# myapp/notifications.py
def get_config():
return {
"for_doctype": {
"Sales Order": {"status": ("!=", "Completed")},
"Issue": {"status": "Open"}
},
"for_module": {
"Selling": {"color": "orange", "count": get_open_orders}
}
}
def get_open_orders():
return frappe.db.count("Sales Order", {"status": "Draft"})---
Session & Authentication Hooks
on_login
Triggered immediately after successful authentication.
# hooks.py
on_login = "myapp.auth.on_login"
# myapp/auth.py
def on_login(login_manager):
"""
Args:
login_manager: frappe.auth.LoginManager instance
login_manager.user = username
"""
user = login_manager.user
frappe.db.set_value("User", user, "last_login_ip", frappe.local.request_ip)on_logout
Triggered when user logs out.
# hooks.py
on_logout = "myapp.auth.on_logout"
# myapp/auth.py
def on_logout():
"""No arguments. Use frappe.session for context."""
frappe.cache().delete_key(f"user_cache:{frappe.session.user}")on_session_creation
Triggered when a new session is created (after login).
# hooks.py
on_session_creation = "myapp.auth.on_session_creation"
# myapp/auth.py
def on_session_creation():
"""No arguments. Use frappe.session for context."""
frappe.logger().info(f"New session: {frappe.session.user}")auth_hooks
Request authentication validators. Called on EVERY authenticated request.
# hooks.py
auth_hooks = ["myapp.auth.validate_request"]
# myapp/auth.py
def validate_request():
"""Called on every request. Throw to block access."""
if is_blocked_ip(frappe.local.request_ip):
frappe.throw("Access denied", frappe.AuthenticationError)---
Execution Order
1. User login successful
2. on_login hook (receives login_manager)
3. Session created
4. on_session_creation hook (no args)
5. Boot data collected
6. extend_bootinfo hook (receives bootinfo)
7. frappe.boot sent to client---
Critical Rules
NEVER Put Secrets in Bootinfo
# WRONG — API keys/secrets exposed to browser JavaScript
def extend_boot(bootinfo):
bootinfo.api_key = frappe.get_single("Settings").secret_key
# CORRECT — only public configuration
def extend_boot(bootinfo):
bootinfo.app_version = "1.0.0"
bootinfo.feature_flags = {"new_ui": True}NEVER Run Heavy Queries in Bootinfo
# WRONG — runs on EVERY page load
def extend_boot(bootinfo):
bootinfo.all_customers = frappe.get_all("Customer") # Thousands of records!
# CORRECT — minimal, cached data
def extend_boot(bootinfo):
cache_key = f"customer_count:{frappe.session.user}"
count = frappe.cache().get_value(cache_key)
if count is None:
count = frappe.db.count("Customer")
frappe.cache().set_value(cache_key, count, expires_in_sec=3600)
bootinfo.customer_count = countALWAYS Guard Against Guest Users
def extend_boot(bootinfo):
if frappe.session.user == "Guest":
return # NEVER load user-specific data for guests
bootinfo.user_settings = get_user_settings()---
Debugging
Inspect Boot Data in Browser
// In browser console
console.log(frappe.boot);
console.log(JSON.stringify(frappe.boot.my_custom_key, null, 2));Test Server-Side
# In bench console
bootinfo = frappe._dict()
from myapp.boot import extend_boot
extend_boot(bootinfo)
print(bootinfo)---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| extend_bootinfo | Yes | Yes | Yes |
| notification_config | Yes | Yes | Yes |
| on_session_creation | Yes | Yes | Yes |
| on_login / on_logout | Yes | Yes | Yes |
| auth_hooks | Yes | Yes | Yes |
| Boot data compression | Basic | Improved | Improved |
Complete hooks.py Examples
Working examples for each hook category. For doc_events examples, see the frappe-syntax-hooks-events skill.
---
Minimal hooks.py
app_name = "myapp"
app_title = "My App"
app_publisher = "My Company"
app_description = "My custom ERPNext app"
app_email = "info@mycompany.com"
app_license = "MIT"---
Standard Business App hooks.py
app_name = "myapp"
app_title = "My App"
app_publisher = "My Company"
app_description = "Custom ERPNext extensions"
app_email = "info@mycompany.com"
app_license = "MIT"
required_apps = ["erpnext"]
# ============================================================
# Frontend Assets
# ============================================================
app_include_js = "/assets/myapp/js/myapp.min.js"
app_include_css = "/assets/myapp/css/myapp.min.css"
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js",
"Customer": "public/js/customer.js"
}
doctype_list_js = {
"Sales Invoice": "public/js/sales_invoice_list.js"
}
# ============================================================
# Scheduled Tasks
# ============================================================
scheduler_events = {
"daily": [
"myapp.tasks.send_daily_digest",
"myapp.tasks.cleanup_old_logs"
],
"daily_long": [
"myapp.tasks.sync_external_system"
],
"cron": {
"0 9 * * 1-5": ["myapp.tasks.weekday_morning_report"],
"0 17 * * 5": ["myapp.tasks.weekly_summary"]
}
}
# ============================================================
# Client-Side Data
# ============================================================
extend_bootinfo = "myapp.boot.extend_boot"
notification_config = "myapp.notifications.get_config"
# ============================================================
# Custom Permissions
# ============================================================
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query_conditions"
}
has_permission = {
"Sales Invoice": "myapp.permissions.si_has_permission"
}
# ============================================================
# Fixtures
# ============================================================
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My App"]]},
{"dt": "Client Script", "filters": [["module", "=", "My App"]]},
{"dt": "Role", "filters": [["name", "like", "MyApp%"]]}
]
# ============================================================
# Install / Migrate
# ============================================================
after_install = "myapp.setup.after_install"
after_migrate = "myapp.setup.after_migrate"
# ============================================================
# Jinja Extensions
# ============================================================
jinja = {
"methods": ["myapp.jinja_utils.get_customer_balance"],
"filters": ["myapp.jinja_utils.format_iban"]
}---
Setup Module (myapp/setup.py)
import frappe
def after_install():
"""Post-installation setup. No arguments."""
create_default_roles()
create_default_settings()
def create_default_roles():
roles = ["MyApp User", "MyApp Manager"]
for role in roles:
if not frappe.db.exists("Role", role):
frappe.get_doc({
"doctype": "Role",
"role_name": role
}).insert()
def create_default_settings():
if not frappe.db.exists("My App Settings"):
frappe.get_doc({
"doctype": "My App Settings",
"enable_feature_x": 1
}).insert()
def after_migrate():
"""Runs after every bench migrate. No arguments."""
frappe.cache().delete_key("myapp_config")---
Boot Module (myapp/boot.py)
import frappe
def extend_boot(bootinfo):
"""Inject app-specific data into frappe.boot."""
bootinfo.myapp_version = frappe.get_module("myapp").__version__
if frappe.session.user != "Guest":
bootinfo.myapp_settings = get_user_settings()
bootinfo.feature_flags = get_feature_flags()
default_company = frappe.defaults.get_user_default("Company")
if default_company:
bootinfo.company_config = frappe.db.get_value(
"Company",
default_company,
["default_currency", "country"],
as_dict=True
)
def get_user_settings():
user = frappe.session.user
return {
"dashboard_layout": frappe.db.get_value(
"User", user, "dashboard_layout"
) or "default"
}
def get_feature_flags():
settings = frappe.get_single("My App Settings")
return {
"new_dashboard": settings.enable_new_dashboard,
"beta_features": settings.enable_beta
}---
Tasks Module (myapp/tasks.py)
import frappe
from frappe.utils import today, add_days
def send_daily_digest():
"""Daily digest for sales team. No arguments."""
users = frappe.get_all(
"User",
filters={"enabled": 1, "user_type": "System User"},
fields=["name", "email"]
)
for user in users:
if "Sales User" in frappe.get_roles(user.name):
digest = compile_digest(user.name)
if digest:
frappe.sendmail(
recipients=[user.email],
subject=f"Daily Sales Digest - {today()}",
message=digest
)
def cleanup_old_logs():
"""Remove logs older than 30 days. No arguments."""
cutoff = add_days(today(), -30)
frappe.db.delete("Activity Log", {"creation": ["<", cutoff]})
frappe.db.commit()
def sync_external_system():
"""Long running sync. Use daily_long. No arguments."""
records = frappe.get_all(
"Sync Queue",
filters={"status": "Pending"},
limit=1000
)
for record in records:
try:
process_sync(record.name)
frappe.db.set_value("Sync Queue", record.name, "status", "Completed")
except Exception:
frappe.db.set_value("Sync Queue", record.name, "status", "Failed")
frappe.log_error(
title=f"Sync Failed: {record.name}",
message=frappe.get_traceback()
)
frappe.db.commit() # Commit per record
def weekday_morning_report():
"""Cron: 0 9 * * 1-5. No arguments."""
yesterday = add_days(today(), -1)
report = generate_daily_report(yesterday)
frappe.sendmail(
recipients=["management@mycompany.com"],
subject=f"Daily Business Report - {yesterday}",
message=report
)---
Permissions Module (myapp/permissions.py)
import frappe
def si_query_conditions(user):
"""Filter Sales Invoices in list view."""
if not user:
user = frappe.session.user
if user == "Administrator":
return ""
roles = frappe.get_roles(user)
if "Accounts Manager" in roles:
return ""
if "Accounts User" in roles:
company = frappe.defaults.get_user_default("Company")
if company:
return f"`tabSales Invoice`.company = {frappe.db.escape(company)}"
if "Sales User" in roles:
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
return "1=0"
def si_has_permission(doc, user=None, permission_type=None):
"""Document-level permission for Sales Invoice."""
if not user:
user = frappe.session.user
if permission_type == "write" and doc.status == "Closed":
return False
return None---
Jinja Utilities (myapp/jinja_utils.py)
import frappe
def get_customer_balance(customer):
"""Usage in template: {{ get_customer_balance(doc.customer) }}"""
return frappe.db.get_value(
"Customer", customer, "outstanding_amount"
) or 0
def format_iban(value):
"""Usage in template: {{ bank_account|format_iban }}"""
if not value:
return ""
return " ".join([value[i:i+4] for i in range(0, len(value), 4)])---
Website Hooks Example
# hooks.py additions for website/portal
website_route_rules = [
{"from_route": "/my-orders/<name>", "to_route": "My Order"}
]
portal_menu_items = [
{"title": "My Orders", "route": "/my-orders", "role": "Customer"}
]
role_home_page = {
"Customer": "my-orders",
"Supplier": "my-rfqs"
}
update_website_context = "myapp.context.update_context"# myapp/context.py
def update_context(context):
"""Add dynamic values to website context."""
context.app_version = "1.0.0"
context.support_email = "support@mycompany.com"---
DocType Override Example (v14/v15)
# hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSalesInvoice"
}# myapp/overrides.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call super() first
self.validate_customer_status()
def validate_customer_status(self):
status = frappe.db.get_value("Customer", self.customer, "status")
if status == "Blocked":
frappe.throw(f"Cannot create invoice for blocked customer {self.customer}")---
DocType Extension Example (v16+)
# hooks.py
extend_doctype_class = {
"Address": ["myapp.extensions.AddressMixin"]
}# myapp/extensions.py
import re
import frappe
from frappe.model.document import Document
class AddressMixin(Document):
def validate(self):
super().validate()
if self.country == "Netherlands" and self.pincode:
if not re.match(r'^\d{4}\s?[A-Z]{2}$', self.pincode):
frappe.throw("Invalid Dutch postal code")Complete Hooks Catalog
All Frappe configuration hooks organized by category. For document lifecycle events (doc_events), see the frappe-syntax-hooks-events skill.
---
App Metadata
| Hook | Type | Description |
|---|---|---|
app_name | string | Slugified app identifier (REQUIRED) |
app_title | string | Human-readable name (REQUIRED) |
app_publisher | string | Publisher name |
app_description | string | App description |
app_email | string | Contact email |
app_license | string | License identifier |
app_version | string | Semantic version |
app_icon | string | Icon reference |
app_color | string | Brand color |
required_apps | list | App dependencies |
---
Frontend Asset Injection
| Hook | Type | Scope |
|---|---|---|
app_include_js | string/list | Desk (backend) JavaScript |
app_include_css | string/list | Desk (backend) CSS |
web_include_js | string/list | Website/portal JavaScript |
web_include_css | string/list | Website/portal CSS |
webform_include_js | dict | Per-webform JavaScript |
webform_include_css | dict | Per-webform CSS |
page_js | dict | Per-page desk scripts |
doctype_js | dict | Form script extensions |
doctype_list_js | dict | List view script extensions |
sounds | list | Custom audio notifications |
---
Installation & Migration Lifecycle
| Hook | Type | When Called |
|---|---|---|
before_install | string | Before app installation |
after_install | string | After app installation |
after_sync | string | After fixture sync |
before_migrate | string | Before bench migrate |
after_migrate | string | After bench migrate |
before_uninstall | string | Before app removal [v15+] |
after_uninstall | string | After app removal [v15+] |
before_tests | string | Before test suite runs |
All receive NO arguments. Access context via frappe.local.
---
Scheduler Events
| Hook | Type | Description |
|---|---|---|
scheduler_events | dict | Periodic background tasks |
Frequency keys inside scheduler_events:
| Key | Queue | Timeout | Frequency |
|---|---|---|---|
all | default | 300s | ~60 seconds |
hourly | default | 300s | Every hour at :00 |
daily | default | 300s | Every day at 00:00 |
weekly | default | 300s | Every Sunday 00:00 |
monthly | default | 300s | 1st of month 00:00 |
hourly_long | long | 1500s | Every hour |
daily_long | long | 1500s | Every day |
weekly_long | long | 1500s | Every week |
monthly_long | long | 1500s | Every month |
cron | default | 300s | Custom cron expression |
---
Session & Authentication
| Hook | Type | Signature |
|---|---|---|
on_login | string | def handler(login_manager): |
on_logout | string | def handler(): |
on_session_creation | string | def handler(): |
auth_hooks | list | def handler(): — request validators |
---
Request/Response Middleware
| Hook | Type | When Called |
|---|---|---|
before_request | list | Before every HTTP request |
after_request | list | After every HTTP response |
before_job | list | Before background job execution |
after_job | list | After background job execution |
---
Permission Hooks
| Hook | Type | Signature |
|---|---|---|
permission_query_conditions | dict | def handler(user): -> str |
has_permission | dict | def handler(doc, user, permission_type): -> bool/None |
---
DocType Extensions & Overrides
| Hook | Type | Version | Description |
|---|---|---|---|
override_doctype_class | dict | v14+ | Replace controller class (last app wins) |
extend_doctype_class | dict | v16+ | Add mixin to controller (all coexist) |
doctype_js | dict | v14+ | Extend form scripts |
doctype_list_js | dict | v14+ | Extend list view scripts |
override_whitelisted_methods | dict | v14+ | Replace API endpoints |
standard_queries | dict | v14+ | Replace link field search |
additional_timeline_content | dict | v14+ | Add timeline entries |
---
Website & Portal
| Hook | Type | Description |
|---|---|---|
website_route_rules | list | URL-to-controller mapping |
website_redirects | list | URL redirects (regex supported) |
website_catch_all | string | Custom 404 handler |
website_path_resolver | string | Custom route resolution [v15+] |
get_web_pages_with_dynamic_routes | string | Dynamic route definitions |
homepage | string | Default homepage route |
role_home_page | dict | Role-based homepages |
get_website_user_home_page | string | Custom homepage function |
portal_menu_items | list | Hardcoded sidebar items |
standard_portal_menu_items | list | DB-synced sidebar items |
base_template | string | Override web base template |
base_template_map | dict | Regex-based template routing |
website_context | dict | Static portal context vars |
update_website_context | string | Dynamic context function |
extend_website_page_controller_context | dict | Per-page context extension |
brand_html | string | Custom navbar brand markup |
---
File Handling
| Hook | Type | Description |
|---|---|---|
before_write_file | string | Pre-save hook |
write_file | string | Replace file storage (S3, CDN) |
delete_file_data_content | string | Replace file deletion |
---
| Hook | Type | Description |
|---|---|---|
override_email_send | string | Replace email sending backend |
get_sender_details | string | Override From address/name |
default_mail_footer | string | HTML footer for all emails |
---
| Hook | Type | Description |
|---|---|---|
pdf_header_html | string | Custom PDF header HTML |
pdf_body_html | string | Custom PDF body wrapper |
pdf_footer_html | string | Custom PDF footer HTML |
---
Jinja
| Hook | Type | Description |
|---|---|---|
jinja.methods | list | Custom functions for Jinja templates |
jinja.filters | list | Custom filters for Jinja templates |
Configured via the jinja dict:
jinja = {
"methods": ["myapp.jinja_utils.my_method"],
"filters": ["myapp.jinja_utils.my_filter"]
}---
Boot & Client Data
| Hook | Type | Description |
|---|---|---|
extend_bootinfo | string | Inject data into frappe.boot |
notification_config | string | Client notification configuration |
---
Data & Fixtures
| Hook | Type | Description |
|---|---|---|
fixtures | list | DB records to export/sync as JSON |
global_search_doctypes | dict | DocTypes for global search indexing |
ignore_links_on_delete | list | Skip link validation on delete |
calendars | list | DocTypes with calendar view |
clear_cache | string | App-specific cache clearing |
---
Hook Resolution Order
When multiple apps define the same hook:
- Override hooks (
override_doctype_class,override_whitelisted_methods): Last installed app wins - Extend hooks (
doc_events,extend_bootinfo,scheduler_events): ALL handlers run in installation order - List hooks (
app_include_js,fixtures): Values are merged from all apps
Adjust installation order via: Setup > Installed Applications > Update Hooks Resolution Order
Override Hooks Reference
Complete reference for override hooks in hooks.py.
---
override_doctype_class [v14+]
Fully replace the controller class of a DocType. The LAST installed app wins when multiple apps override the same DocType.
Syntax
# hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSalesInvoice",
"ToDo": "myapp.overrides.todo.CustomToDo"
}Implementation
# myapp/overrides.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call super() first
self.custom_validation()
def on_submit(self):
super().on_submit()
self.create_custom_entry()
def custom_validation(self):
if self.grand_total > 100000:
frappe.msgprint("High value invoice - requires approval")Warnings
1. Last app wins: When multiple apps override the same DocType, ONLY the last installed app's override is active 2. Fragile: Updates to the parent class can break your override 3. ALWAYS call super(): Forgetting super() breaks core functionality (validations, calculations, ledger entries)
---
extend_doctype_class [v16+]
Add mixins to a controller WITHOUT replacing it. All apps' extensions coexist. ALWAYS prefer this over override_doctype_class on v16+.
Syntax
# hooks.py
extend_doctype_class = {
"Address": ["myapp.extensions.address.AddressMixin"],
"Contact": [
"myapp.extensions.common.ValidationMixin",
"myapp.extensions.contact.ContactMixin"
]
}Implementation
# myapp/extensions/address.py
import re
import frappe
from frappe.model.document import Document
class AddressMixin(Document):
@property
def full_address(self):
"""Computed property added to Address."""
return f"{self.address_line1}, {self.city}, {self.country}"
def validate(self):
super().validate()
self.validate_postal_code()
def validate_postal_code(self):
if self.country == "Netherlands" and self.pincode:
if not re.match(r'^\d{4}\s?[A-Z]{2}$', self.pincode):
frappe.throw("Invalid Dutch postal code format")Comparison
| Aspect | override_doctype_class | extend_doctype_class |
|---|---|---|
| Multiple apps | Last app wins | ALL coexist |
| Maintenance | Fragile | Stable |
| Availability | v14+ | v16+ only |
| Risk | High (can break core) | Low (additive) |
---
override_whitelisted_methods [v14+]
Replace existing API endpoints with custom implementations.
Syntax
# hooks.py
override_whitelisted_methods = {
"frappe.client.get_count": "myapp.overrides.custom_get_count",
"erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice":
"myapp.overrides.custom_make_sales_invoice"
}Implementation
# CRITICAL: Method signature MUST be identical to original
def custom_get_count(doctype, filters=None, debug=False, cache=False):
count = frappe.db.count(doctype, filters)
log_count_query(doctype) # Custom addition
return countALWAYS find the original function signature first. Mismatched parameters cause runtime errors.
Commonly Overridden Methods
| Method | Purpose |
|---|---|
frappe.client.get_count | Record counting |
frappe.client.get_list | List queries |
frappe.desk.search.search_link | Link field search |
erpnext.*.make_* | Document creation wizards |
---
standard_queries [v14+]
Replace the default search query for Link fields.
Syntax
# hooks.py
standard_queries = {
"Customer": "myapp.queries.customer_query"
}Implementation
# myapp/queries.py
import frappe
def customer_query(doctype, txt, searchfield, start, page_len, filters):
"""
Args:
doctype: "Customer"
txt: Search text typed by user
searchfield: Field being searched (usually "name")
start: Pagination offset
page_len: Page size
filters: Additional filters from the Link field
"""
return frappe.db.sql("""
SELECT name, customer_name, customer_group
FROM `tabCustomer`
WHERE (name LIKE %(txt)s OR customer_name LIKE %(txt)s)
AND status = 'Active'
ORDER BY customer_name
LIMIT %(start)s, %(page_len)s
""", {
"txt": f"%{txt}%",
"start": start,
"page_len": page_len
})---
doctype_js [v14+]
Extend form scripts of existing DocTypes from your custom app.
Syntax
# hooks.py
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js",
"Customer": "public/js/customer.js"
}Implementation
// public/js/sales_invoice.js
frappe.ui.form.on("Sales Invoice", {
refresh: function(frm) {
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__("Send to ERP"), function() {
frappe.call({
method: "myapp.api.send_invoice",
args: { invoice: frm.doc.name },
callback: function(r) {
frappe.msgprint(__("Invoice sent"));
}
});
});
}
},
customer: function(frm) {
if (frm.doc.customer) {
frappe.call({
method: "myapp.api.get_customer_discount",
args: { customer: frm.doc.customer },
callback: function(r) {
if (r.message) {
frm.set_value("discount_percentage", r.message);
}
}
});
}
}
});---
doctype_list_js [v14+]
Extend list view scripts of existing DocTypes.
Syntax
# hooks.py
doctype_list_js = {
"Sales Invoice": "public/js/sales_invoice_list.js"
}Implementation
// public/js/sales_invoice_list.js
frappe.listview_settings["Sales Invoice"] = {
add_fields: ["customer_name", "grand_total"],
get_indicator: function(doc) {
if (doc.grand_total > 100000) {
return [__("High Value"), "orange", "grand_total,>,100000"];
}
}
};---
Hook Resolution Order
When multiple apps define the same hook:
Override hooks (override_*): Last installed app wins
Extend hooks (extend_*, doc_events): All handlers run in installation orderAdjust order via: Setup > Installed Applications > Update Hooks Resolution Order
---
Decision Tree
Want to modify existing functionality?
|
+-- API endpoint?
| +-- override_whitelisted_methods
|
+-- DocType controller?
| +-- v16+? --> extend_doctype_class (RECOMMENDED)
| +-- v14/v15? --> override_doctype_class (last app wins)
|
+-- Form UI?
| +-- doctype_js
|
+-- List view UI?
| +-- doctype_list_js
|
+-- Link field search?
+-- standard_queries---
Version Differences
| Hook | v14 | v15 | v16 |
|---|---|---|---|
| override_whitelisted_methods | Yes | Yes | Yes |
| override_doctype_class | Yes | Yes | Yes |
| extend_doctype_class | -- | -- | NEW |
| doctype_js | Yes | Yes | Yes |
| doctype_list_js | Yes | Yes | Yes |
| standard_queries | Yes | Yes | Yes |
Permission Hooks Reference
Complete reference for permission hooks in hooks.py.
---
permission_query_conditions
Dynamically filter list views based on user/role. Returns a SQL WHERE fragment.
Syntax
# hooks.py
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query_conditions",
"Project": "myapp.permissions.project_query_conditions"
}Handler Signature
def si_query_conditions(user):
"""
Args:
user: str or None — ALWAYS check for None
Returns:
str: SQL WHERE fragment (without WHERE keyword)
"" = no restrictions (show all)
"1=0" = show nothing
"""Implementation Example
import frappe
def si_query_conditions(user):
if not user:
user = frappe.session.user
if user == "Administrator":
return ""
roles = frappe.get_roles(user)
if "Sales Manager" in roles:
return ""
if "Sales User" in roles:
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
return "1=0"Subquery Example
def project_query_conditions(user):
if not user:
user = frappe.session.user
if "Projects Manager" in frappe.get_roles(user):
return ""
return f"""
`tabProject`.name IN (
SELECT parent FROM `tabProject User`
WHERE user = {frappe.db.escape(user)}
)
"""CRITICAL: get_list vs get_all
| Method | Applies permission_query_conditions | Behavior |
|---|---|---|
frappe.db.get_list | YES | Respects permissions |
frappe.db.get_all | NO | Ignores permissions entirely |
ALWAYS use frappe.db.get_list when permission filtering is required.
---
has_permission
Custom document-level permission logic.
Syntax
# hooks.py
has_permission = {
"Sales Invoice": "myapp.permissions.si_has_permission",
"Event": "myapp.permissions.event_has_permission"
}Handler Signature
def si_has_permission(doc, user=None, permission_type=None):
"""
Args:
doc: Document object
user: str or None — ALWAYS check for None
permission_type: "read", "write", "create", "delete",
"submit", "cancel", "amend", "print",
"email", "share"
Returns:
True: Grant access
False: Deny access
None: Fallback to default permission check
"""Implementation Example
def si_has_permission(doc, user=None, permission_type=None):
if not user:
user = frappe.session.user
# Closed invoices CANNOT be edited
if permission_type == "write" and doc.status == "Closed":
return False
# Cancelled invoices CANNOT be deleted
if permission_type == "delete" and doc.docstatus == 2:
return False
# Fallback to standard permissions
return None
def event_has_permission(doc, user=None, permission_type=None):
if not user:
user = frappe.session.user
if permission_type == "read" and doc.event_type == "Public":
return True
if doc.event_type == "Private" and doc.owner != user:
return False
return NoneAll Permission Types
| Type | When Checked |
|---|---|
read | Opening document, list view |
write | Editing document |
create | Creating new document |
delete | Deleting document |
submit | Submitting document |
cancel | Cancelling document |
amend | Amending document |
print | Printing document |
email | Emailing document |
share | Sharing document |
---
Evaluation Order
1. has_permission hook (document-level)
|
2. Role Permissions (DocType level)
|
3. User Permissions (field-level restrictions)
|
4. permission_query_conditions (list filtering only)---
Combined Example
# hooks.py
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query"
}
has_permission = {
"Sales Invoice": "myapp.permissions.si_permission"
}
# myapp/permissions.py
import frappe
def si_query(user):
"""List view filter — controls what appears in lists."""
if not user:
user = frappe.session.user
if "Accounts Manager" in frappe.get_roles(user):
return ""
default_company = frappe.defaults.get_user_default("Company")
if default_company:
return f"`tabSales Invoice`.company = {frappe.db.escape(default_company)}"
return "1=0"
def si_permission(doc, user=None, permission_type=None):
"""Document-level check — controls access to individual documents."""
if not user:
user = frappe.session.user
if permission_type == "write" and doc.docstatus == 1:
return False
return None---
Critical Rules
1. ALWAYS check if not user: user = frappe.session.user 2. ALWAYS use frappe.db.escape(user) for SQL values — NEVER raw interpolation 3. ALWAYS return None (not True) to fallback to standard permissions 4. NEVER use get_all when you need permission filtering — use get_list 5. ALWAYS return "" (empty string) for "no restrictions" in query conditions
---
Debugging
# Check if user has permission
has_perm = frappe.has_permission("Sales Invoice", "read", doc=invoice)
# Test query conditions directly
from myapp.permissions import si_query_conditions
print(si_query_conditions("user@example.com"))
# Test has_permission directly
doc = frappe.get_doc("Sales Invoice", "SI-00001")
from myapp.permissions import si_has_permission
print(si_has_permission(doc, "user@example.com", "write"))---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| permission_query_conditions | Yes | Yes | Yes |
| has_permission | Yes | Yes | Yes |
| Only works with get_list | Yes | Yes | Yes |
Request Lifecycle & Routing
How Frappe processes HTTP requests from WSGI entry to response, and how hooks.py integrates at each stage.
---
1. Request Lifecycle Overview
Every HTTP request follows this pipeline:
Client Request
│
▼
┌─────────────────────────────────┐
│ WSGI Entry │
│ frappe.app.application() │
│ - Creates frappe.local context │
│ - Initializes request recorder │
│ - Applies rate limiting │
└───────────┬─────────────────────┘
│
▼
┌─────────────────────────────────┐
│ before_request hooks │
│ (all handlers from all apps) │
└───────────┬─────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Route Resolution │
│ 1. Check website_redirects │
│ 2. Match website_route_rules │
│ 3. Match dynamic routes │
│ 4. Select page renderer │
└───────────┬─────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Handler Execution │
│ - API: /api/* → REST handler │
│ - Files: /files/* → download │
│ - Pages: page renderer chain │
└───────────┬─────────────────────┘
│
▼
┌─────────────────────────────────┐
│ after_request hooks │
│ (all handlers from all apps) │
└───────────┬─────────────────────┘
│
▼
ResponseThree Request Types
| URL Pattern | Handler | Description |
|---|---|---|
/api/* | REST API handler | JSON responses for frappe.call, get_list, etc. |
/backups/*, /files/*, /private/files/* | File download handler | Serves files as downloads |
| Everything else | Website router | HTML pages via page renderer chain |
---
2. WSGI Entry Point
Frappe is a standard WSGI application. The entry point is frappe.app.application().
# Gunicorn calls this for every HTTP request:
# frappe/app.py
def application(environ, start_response):
# 1. Initialize frappe.local (thread-local request context)
# 2. Connect to site database
# 3. Run before_request hooks
# 4. Route and dispatch the request
# 5. Run after_request hooks
# 6. Return WSGI responsefrappe.local is a thread-local namespace that stores ALL request state:
frappe.local.request— the Werkzeug Request objectfrappe.local.response— response dict (built during handling)frappe.local.session— current session datafrappe.local.db— database connection for this requestfrappe.local.form_dict— parsed request parameters
NEVER store state outside frappe.local — it leaks between requests in multi-threaded Gunicorn workers.
---
3. Request Middleware Hooks
before_request
Runs BEFORE route resolution. Use for authentication checks, request logging, or request modification.
# hooks.py
before_request = ["myapp.middleware.check_maintenance_mode"]# myapp/middleware.py
import frappe
def check_maintenance_mode():
"""Block non-admin requests during maintenance."""
if frappe.cache.get_value("maintenance_mode"):
if frappe.session.user != "Administrator":
frappe.throw("Site is under maintenance", frappe.PermissionError)Rules:
- ALWAYS define as a list of dotted paths (even for a single handler)
- Handlers receive NO arguments — access request via
frappe.local.request - To abort a request, raise an exception (
frappe.throw) - ALL apps'
before_requesthandlers run in app installation order - NEVER run heavy queries here — this runs on EVERY request
after_request
Runs AFTER the response is generated but BEFORE it is sent to the client. Use for response headers, logging, or cleanup.
# hooks.py
after_request = ["myapp.middleware.add_custom_headers"]# myapp/middleware.py
import frappe
def add_custom_headers():
"""Add security headers to every response."""
frappe.local.response.headers["X-Content-Type-Options"] = "nosniff"
frappe.local.response.headers["X-Frame-Options"] = "SAMEORIGIN"Rules:
- Handlers receive NO arguments
- Access response via
frappe.local.response - NEVER raise exceptions here — the response is already committed
- Use for logging, metrics, header injection
before_job / after_job
Same pattern but for background jobs (RQ workers), NOT HTTP requests:
before_job = ["myapp.middleware.before_background_job"]
after_job = ["myapp.middleware.after_background_job"]---
4. Route Resolution Pipeline
When the website router handles a request (non-API, non-file), route resolution happens in three sequential steps:
Step 1: Redirect Check
The router checks website_redirects hook and Website Settings redirects:
# hooks.py
website_redirects = [
{"source": "/old-page", "target": "/new-page"},
{"source": "/docs(/.*)?", "target": "https://docs.example.com/\\1"} # regex
]Rules:
sourcesupports regex patterns- If matched, returns HTTP 301/302 redirect immediately
- Checked BEFORE route rules
Step 2: Route Rules
The router matches against website_route_rules:
# hooks.py
website_route_rules = [
{"from_route": "/custom-page/<name>", "to_route": "Custom Page"},
{"from_route": "/shop/<category>", "to_route": "Product Category"},
{"from_route": "/api-docs", "to_route": "API Documentation"}
]Also checks dynamic routes from DocTypes with has_web_view = 1.
Step 3: Renderer Selection
The resolved path is evaluated against page renderers. The first renderer whose can_render() returns True handles the request.
---
5. Page Renderer Architecture
Page renderers are Python classes that determine if and how a route should be rendered.
Standard Renderers (evaluated in order)
| Renderer | Purpose |
|---|---|
StaticPage | Non-markup files (PDFs, images) from www/ folders |
TemplatePage | HTML/Markdown from www/ folders or index files |
WebformPage | Web Forms matching the request route |
DocumentPage | DocType templates from /templates/[doctype].html |
ListPage | List templates from DocType /templates/ folders |
PrintPage | Print views (standard or custom print formats) |
NotFoundPage | 404 responses (fallback) |
NotPermittedPage | 403 responses |
Custom Page Renderer
Register a custom renderer via page_renderer hook. Custom renderers are evaluated BEFORE all standard renderers:
# hooks.py
page_renderer = "myapp.renderers.custom_page.CustomPage"# myapp/renderers/custom_page.py
from frappe.website.page_renderers.base_renderer import BaseRenderer
class CustomPage(BaseRenderer):
def can_render(self):
"""Return True if this renderer handles the current path."""
return self.path.startswith("my-custom-section")
def render(self):
"""Generate and return the HTTP response."""
html = "<h1>Custom rendered page</h1>"
return self.build_response(html)Rules:
- ALWAYS extend
BaseRendererfromfrappe.website.page_renderers.base_renderer can_render()MUST return a boolean — NEVER raise exceptions- Use
self.pathto access the current route path - Use
self.build_response(html)to create a proper Werkzeug response - Custom renderers get priority over ALL standard renderers
---
6. Router API (Client-Side JavaScript)
frappe.get_route()
Returns the current route as a list of path segments:
// URL: /app/sales-invoice/SI-001
let route = frappe.get_route();
// Returns: ["sales-invoice", "SI-001"]
// URL: /app/query-report/General Ledger
let route = frappe.get_route();
// Returns: ["query-report", "General Ledger"]frappe.set_route()
Navigate to a new route (client-side, no full page reload):
// Navigate to a document
frappe.set_route("sales-invoice", "SI-001");
// Navigate to a list view
frappe.set_route("List", "Sales Invoice");
// Navigate to a report
frappe.set_route("query-report", "General Ledger");
// Navigate with query parameters
frappe.set_route("List", "Sales Invoice", {"company": "My Company"});frappe.route_options
Set filters before navigating to a list:
frappe.route_options = {"customer": "CUST-001", "status": "Unpaid"};
frappe.set_route("List", "Sales Invoice");
// List opens pre-filtered by customer and statusALWAYS set frappe.route_options BEFORE calling frappe.set_route(). The options are consumed once and cleared automatically.
---
7. Website Routing Hooks Reference
website_route_rules
Map custom URL patterns to DocType or page controllers:
website_route_rules = [
{"from_route": "/custom/<name>", "to_route": "Custom Page"}
]website_redirects
Redirect old URLs to new ones (supports regex):
website_redirects = [
{"source": "/compare", "target": "/comparison"},
{"source": "/docs(/.*)?", "target": "https://docs.example.com/\\1"}
]website_path_resolver (v15+)
Override standard route resolution entirely:
website_path_resolver = "myapp.routing.custom_resolver"base_template_map
Apply different base templates to different URL patterns using regex:
base_template_map = {
r"docs.*": "myapp/templates/doc_template.html",
r"blog.*": "myapp/templates/blog_template.html"
}before_write_file
Hook into file upload processing before the file is saved:
before_write_file = "myapp.files.validate_upload"# myapp/files.py
import frappe
def validate_upload(**kwargs):
"""Validate file before saving."""
file_name = kwargs.get("file_name", "")
content = kwargs.get("content", b"")
if len(content) > 10 * 1024 * 1024: # 10 MB
frappe.throw("File too large. Maximum size is 10 MB.")
blocked_extensions = [".exe", ".bat", ".cmd", ".sh"]
if any(file_name.endswith(ext) for ext in blocked_extensions):
frappe.throw(f"File type not allowed: {file_name}")---
8. Anti-Patterns
| Wrong | Correct |
|---|---|
Heavy DB queries in before_request | Cache results, check only when needed |
| Storing state in module globals | ALWAYS use frappe.local or frappe.cache |
Raising exceptions in after_request | Log errors, NEVER throw in after_request |
Using page_renderer without can_render() guard | ALWAYS return False for unhandled paths |
Setting frappe.route_options after set_route() | ALWAYS set route_options BEFORE set_route |
Regex in website_redirects without escaping | ALWAYS escape special regex characters |
---
Sources
- Frappe Hooks API: https://docs.frappe.io/framework/user/en/python-api/hooks
- Frappe Routing & Rendering: https://docs.frappe.io/framework/user/en/python-api/routing-and-rendering
- Frappe Source:
frappe/app.py,frappe/website/router.py
Scheduler Events Reference
Complete reference for scheduler_events in hooks.py.
---
Syntax
scheduler_events = {
# Standard frequencies (default queue, 300s timeout)
"all": ["myapp.tasks.every_minute"],
"hourly": ["myapp.tasks.hourly_task"],
"daily": ["myapp.tasks.daily_task"],
"weekly": ["myapp.tasks.weekly_task"],
"monthly": ["myapp.tasks.monthly_task"],
# Long queue variants (long queue, 1500s timeout)
"hourly_long": ["myapp.tasks.heavy_hourly"],
"daily_long": ["myapp.tasks.heavy_daily"],
"weekly_long": ["myapp.tasks.heavy_weekly"],
"monthly_long": ["myapp.tasks.heavy_monthly"],
# Cron expressions (default queue, 300s timeout)
"cron": {
"0 9 * * 1-5": ["myapp.tasks.weekday_morning"],
"*/30 * * * *": ["myapp.tasks.half_hourly_sync"]
}
}---
All Event Types
Standard Frequencies (Default Queue)
| Event | Frequency | Timeout | When |
|---|---|---|---|
all | ~60 seconds | 300s | Every scheduler tick |
hourly | Every hour | 300s | At :00 |
daily | Every day | 300s | At 00:00 |
weekly | Every week | 300s | Sunday 00:00 |
monthly | Every month | 300s | 1st of month 00:00 |
Long Queue Variants
| Event | Frequency | Timeout | When |
|---|---|---|---|
hourly_long | Every hour | 1500s | At :00 |
daily_long | Every day | 1500s | At 00:00 |
weekly_long | Every week | 1500s | Sunday 00:00 |
monthly_long | Every month | 1500s | 1st of month 00:00 |
Cron (Custom Timing)
The cron key is a dict where keys are cron expressions and values are lists of dotted paths.
---
Cron Syntax
* * * * *
| | | | |
| | | | +-- Day of week (0-6, Sunday=0)
| | | +---- Month (1-12)
| | +------ Day of month (1-31)
| +-------- Hour (0-23)
+---------- Minute (0-59)Special Values
| Value | Meaning | Example |
|---|---|---|
* | Every | Every minute, hour, etc. |
*/n | Every nth | */5 = every 5 |
n-m | Range | 1-5 = Monday through Friday |
n,m | List | 1,15 = 1st and 15th |
Common Cron Patterns
| Pattern | Meaning |
|---|---|
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour at :00 |
0 9 * * * | Daily at 09:00 |
0 9 * * 1-5 | Weekdays at 09:00 |
0 0 1 * * | First day of month at 00:00 |
0 17 * * 5 | Friday at 17:00 |
*/15 9-17 * * 1-5 | Every 15 min during business hours |
30 14 * * * | Daily at 14:30 |
---
Task Implementation
Basic Task
# myapp/tasks.py
import frappe
def daily_report():
"""Scheduled tasks receive NO arguments."""
report = generate_report()
frappe.sendmail(
recipients=["manager@example.com"],
subject="Daily Report",
message=report
)Task with Error Handling
def hourly_sync():
"""ALWAYS wrap in try/except and log errors."""
try:
records = frappe.get_all("Sync Queue", filters={"status": "Pending"})
for record in records:
process_sync(record.name)
frappe.db.commit() # Commit per record for large batches
except Exception:
frappe.log_error(
title="Hourly Sync Failed",
message=frappe.get_traceback()
)Long Running Task
def monthly_aggregation():
"""Use _long variant for tasks exceeding 5 minutes."""
for company in frappe.get_all("Company"):
aggregate_data(company.name)
frappe.db.commit() # Commit per iteration to prevent memory buildup---
Queue Selection Guide
| Scenario | Use | Reason |
|---|---|---|
| Quick check (< 5 min) | hourly, daily, etc. | Default queue, 5 min timeout |
| Heavy processing (5-25 min) | hourly_long, daily_long, etc. | Long queue, 25 min timeout |
| Precise timing needed | cron | Exact schedule control |
| Sub-hourly frequency | cron with */n * * * * | Standard events are hourly minimum |
| Near real-time | all | ~60s interval, default queue |
---
Critical Rules
1. ALWAYS Run bench migrate After Changes
bench --site sitename migrateScheduler events are cached. Without migrate, changes are NOT picked up.
2. NEVER Define Tasks with Arguments
# WRONG — tasks receive no arguments
def my_task(company_name):
process_company(company_name)
# CORRECT — fetch data inside the function
def my_task():
for company in frappe.get_all("Company"):
process_company(company.name)3. ALWAYS Commit in Long Loops
def process_all_invoices():
invoices = frappe.get_all("Sales Invoice", limit=0)
for inv in invoices:
process_invoice(inv.name)
frappe.db.commit() # Prevent memory buildup4. ALWAYS Use Error Handling
def risky_task():
try:
do_work()
except Exception:
frappe.log_error(title="Task Failed", message=frappe.get_traceback())---
Debugging
Manual Execution
# In bench console
frappe.get_doc("Scheduled Job Type", "myapp.tasks.daily_report").execute()Check Scheduler Status
bench --site sitename scheduler status
bench --site sitename scheduler enable
bench --site sitename scheduler disableView Logs
tail -f ~/frappe-bench/logs/scheduler.log
tail -f ~/frappe-bench/logs/worker.log---
Complete Example
# hooks.py
scheduler_events = {
"hourly": [
"myapp.tasks.check_pending_orders"
],
"daily": [
"myapp.tasks.send_daily_digest",
"myapp.tasks.cleanup_temp_files"
],
"daily_long": [
"myapp.tasks.recalculate_all_balances"
],
"weekly_long": [
"myapp.tasks.generate_weekly_analytics"
],
"cron": {
"0 9 * * 1-5": ["myapp.tasks.send_payment_reminders"],
"*/30 * * * *": ["myapp.tasks.sync_external_api"],
"0 17 * * 5": ["myapp.tasks.weekly_summary"]
}
}---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| All standard events | Yes | Yes | Yes |
| Cron syntax | Yes | Yes | Yes |
_long variants | Yes | Yes | Yes |
| Scheduler UI | Basic | Improved | Improved |