
Frappe Syntax Customapp
- 59 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Build Frappe custom apps from scratch with correct app structure, pyproject.toml, modules, patches, and fixtures.
About
Guides building Frappe custom apps from scratch, covering structure, configuration, modules, patches, and fixtures. A developer uses it when scaffolding and organizing a new custom app.
- Build Frappe custom apps: structure, pyproject.toml, modules
- Covers patches and fixtures for v14/v15/v16
Frappe Syntax Customapp by the numbers
- 59 all-time installs (skills.sh)
- Ranked #3,167 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-customappAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 59 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Build Frappe custom apps from scratch with correct app structure, pyproject.toml, modules, patches, and fixtures.
Files
Frappe Custom App Syntax
Deterministic syntax reference for building Frappe custom apps — scaffolding, configuration, modules, patches, and fixtures.
Decision Tree
What do you need?
├─ Brand new app from scratch → bench new-app
├─ Extend existing ERPNext behavior → bench new-app + required_apps = ["frappe", "erpnext"]
├─ Install existing app from Git → bench get-app <url>
└─ Add functionality to an installed app
├─ New data model → Add module to modules.txt + create DocType
├─ New fields on existing DocType → Fixtures (Custom Field)
├─ Modify field properties → Fixtures (Property Setter)
└─ Data migration → Patch in patches.txt
New app vs extend existing?
├─ Independent functionality → New app
├─ Tightly coupled to one app → New app with required_apps dependency
└─ Small customization (fields, properties) → Extend via fixtures in existing custom appCreating an App
# Create new app (interactive prompts for title, description, publisher, etc.)
bench new-app my_custom_app
# Install on site
bench --site mysite install-app my_custom_app
# Get existing app from Git
bench get-app https://github.com/org/my_custom_app
# Build frontend assets
bench build --app my_custom_app
# Run migrations (patches + fixtures + schema sync)
bench --site mysite migrateApp Directory Structure
[v15+] pyproject.toml (Primary)
apps/my_custom_app/
├── pyproject.toml # Build configuration (flit)
├── README.md
├── my_custom_app/ # Inner Python package
│ ├── __init__.py # MUST contain __version__
│ ├── hooks.py # Frappe integration hooks
│ ├── modules.txt # Module registration
│ ├── patches.txt # Migration scripts
│ ├── patches/ # Patch files
│ │ └── __init__.py
│ ├── my_custom_app/ # Default module (same name as app)
│ │ ├── __init__.py
│ │ └── doctype/
│ ├── public/ # Static assets → /assets/my_custom_app/
│ │ ├── css/
│ │ └── js/
│ ├── templates/ # Jinja templates
│ │ └── includes/
│ └── www/ # Portal pages (URL = directory path)
└── .git/[v14] setup.py (Legacy)
apps/my_custom_app/
├── setup.py # Build configuration (setuptools)
├── MANIFEST.in
├── requirements.txt # Python dependencies
├── dev-requirements.txt # Dev dependencies (developer_mode only)
├── package.json # Node dependencies
├── my_custom_app/
│ ├── __init__.py
│ ├── hooks.py
│ ├── modules.txt
│ ├── patches.txt
│ └── [same inner structure as v15]
└── .git/Critical Files
__init__.py (REQUIRED)
# my_custom_app/__init__.py
__version__ = "0.0.1"CRITICAL: Without __version__, the flit build FAILS and the app CANNOT be installed.
pyproject.toml [v15+]
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "my_custom_app"
authors = [
{ name = "Your Company", email = "dev@example.com" }
]
description = "Description of your app"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = [] # Python packages ONLY — NEVER Frappe/ERPNext
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0" # Only if app extends ERPNextCRITICAL rules for pyproject.toml:
nameMUST match the inner directory name exactlydynamic = ["version"]is REQUIRED — flit reads__version__from__init__.py- NEVER put
frappeorerpnextin[project] dependencies(they are not on PyPI) - ALWAYS put Frappe app dependencies in
[tool.bench.frappe-dependencies]
setup.py [v14] (Legacy)
from setuptools import setup, find_packages
setup(
name="my_custom_app",
version="0.0.1",
description="Description of your app",
author="Your Company",
author_email="dev@example.com",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=[],
)hooks.py (Minimal Skeleton)
app_name = "my_custom_app"
app_title = "My Custom App"
app_publisher = "Your Company"
app_description = "Description"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe"] # Or ["frappe", "erpnext"] if extending ERPNext
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My Custom App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My Custom App"]]},
]Modules
modules.txt
My Custom App
Integrations
Settings
ReportsRules:
- One module name per line — NEVER leave empty lines or trailing spaces
- Module name uses spaces; directory name uses underscores (
My Custom App→my_custom_app/) - Every DocType MUST belong to a registered module
- ALWAYS include
__init__.pyin every module directory
Module Directory Structure
my_custom_app/
├── my_custom_app/ # "My Custom App" module
│ ├── __init__.py
│ └── doctype/
├── integrations/ # "Integrations" module
│ ├── __init__.py
│ └── doctype/
├── settings/ # "Settings" module
│ ├── __init__.py
│ └── doctype/
└── reports/ # "Reports" module
├── __init__.py
└── report/DocType Directory (within a module)
doctype/my_doctype/
├── __init__.py # Empty (REQUIRED)
├── my_doctype.json # DocType definition (generated by UI)
├── my_doctype.py # Python controller
├── my_doctype.js # Client script
├── test_my_doctype.py # Unit tests
└── my_doctype_dashboard.py # Dashboard configPatches (Migration Scripts)
patches.txt with INI Sections
[pre_model_sync]
# Runs BEFORE schema sync — old fields still available
myapp.patches.v1_0.backup_old_data
[post_model_sync]
# Runs AFTER schema sync — new fields available
myapp.patches.v1_0.populate_new_fields
myapp.patches.v1_0.cleanup_dataPatch Implementation
# myapp/patches/v1_0/populate_new_fields.py
import frappe
def execute():
"""Populate new fields with default values."""
batch_size = 1000
offset = 0
while True:
records = frappe.get_all(
"MyDocType",
filters={"new_field": ["is", "not set"]},
fields=["name"],
limit_page_length=batch_size,
limit_start=offset,
)
if not records:
break
for record in records:
frappe.db.set_value(
"MyDocType", record.name,
"new_field", "default_value",
update_modified=False,
)
frappe.db.commit()
offset += batch_sizePre vs Post Model Sync
| Situation | Section | Reason |
|---|---|---|
| Migrate data from old field | [pre_model_sync] | Old field still exists |
| Rename field + preserve data | [pre_model_sync] | Old name still available |
| Populate new required fields | [post_model_sync] | New field already exists |
| General data cleanup | [post_model_sync] | No schema dependency |
Re-running a Patch
# Patches run ONCE. To re-run, make the line unique with a comment:
myapp.patches.v1_0.my_patch #2024-01-15bench migrate Workflow
1. before_migrate hooks execute 2. [pre_model_sync] patches execute 3. Database schema sync (DocType JSON → tables) 4. [post_model_sync] patches execute 5. Fixtures sync 6. after_migrate hooks execute
Fixtures
hooks.py Configuration
fixtures = [
"Category", # All records
{"dt": "Custom Field", "filters": [["module", "=", "My Custom App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My Custom App"]]},
{"dt": "Role", "filters": [["name", "like", "MyApp%"]]},
]Exporting and Importing
# Export fixtures to JSON files
bench --site mysite export-fixtures --app my_custom_app
# Import happens automatically during bench migrate or install-appFixtures vs Patches
| What | Fixtures | Patches |
|---|---|---|
| Custom Fields | YES | NO |
| Property Setters | YES | NO |
| Roles, Workflows | YES | NO |
| Data transformation | NO | YES |
| One-time migration | NO | YES |
| Seed configuration data | YES | NO |
Fixture Ordering
ALWAYS order fixtures so dependencies come first:
fixtures = [
"Workflow State", # FIRST — Workflow depends on states
"Workflow", # SECOND
]Version Differences
| Aspect | v14 | v15+ | v16+ |
|---|---|---|---|
| Build config | setup.py | pyproject.toml | pyproject.toml |
| Build backend | setuptools | flit_core | flit_core |
| Dependencies file | requirements.txt | pyproject.toml | pyproject.toml |
| Python minimum | >=3.10 | >=3.10 | >=3.14 |
| INI patches | YES | YES | YES |
Migration v14 to v15
1. Create pyproject.toml with flit_core build-system 2. Move dependencies from requirements.txt to [project] dependencies 3. Verify __version__ in __init__.py 4. Optionally remove: setup.py, MANIFEST.in, requirements.txt 5. Test with bench get-app and bench install-app
Critical Rules
ALWAYS
1. Define __version__ in __init__.py — flit build fails without it 2. Add dynamic = ["version"] in pyproject.toml 3. Register EVERY module in modules.txt 4. Include __init__.py in EVERY Python directory 5. Put Frappe dependencies in [tool.bench.frappe-dependencies], NEVER in [project] dependencies 6. Use batch processing and error handling in patches 7. Set module field on Custom Fields and Property Setters for correct fixture export 8. Order fixtures by dependency (states before workflows)
NEVER
1. Put frappe or erpnext in pip dependencies (not on PyPI — install fails) 2. Create patches without try/except and logging 3. Include user data or transactional data (Sales Invoice, User) in fixtures 4. Hardcode site-specific values in patches 5. Process large datasets without batching and periodic frappe.db.commit() 6. Use spaces in directory names (spaces in modules.txt only) 7. Change module names after DocTypes have been created in production
Reference Files
| File | Contents |
|---|---|
| structure.md | Complete directory structure for v14 and v15 |
| pyproject-toml.md | Full pyproject.toml and setup.py configuration |
| modules.md | Module organization, naming, workspaces |
| patches.md | Patch syntax, pre/post model sync, batch processing |
| fixtures.md | Fixture configuration, filters, common DocTypes |
| examples.md | Complete minimal and ERPNext extension app examples |
| anti-patterns.md | Top 10 mistakes and corrections |
See Also
frappe-syntax-hooks— Full hooks.py referencefrappe-syntax-controllers— DocType controller methodsfrappe-impl-customapp— Implementation patterns and workflows
Anti-Patterns and Common Mistakes
Mistakes to avoid when developing Frappe custom apps.
---
Build Configuration Anti-Patterns
❌ Missing __version__
# WRONG - no version
# my_custom_app/__init__.py
pass# ✅ CORRECT
# my_custom_app/__init__.py
__version__ = "0.0.1"Result: Flit build fails, app cannot be installed.
---
❌ Frappe in pyproject.toml Dependencies
# WRONG - frappe is not on PyPI
[project]
dependencies = [
"frappe>=15.0.0",
"erpnext>=15.0.0",
]# ✅ CORRECT - use tool.bench section
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"Result: pip install fails because frappe is not on PyPI.
---
❌ Package Name Mismatch
# WRONG - pyproject.toml says "my_custom_app" but directory is "my-custom-app"
apps/my-custom-app/ # Directory with hyphen
├── pyproject.toml # name = "my_custom_app" (underscore)Result: Package not found, import errors.
---
❌ Wrong hooks.py Location
# WRONG - hooks.py in wrong directory
apps/my_custom_app/hooks.py # Too high level
# ✅ CORRECT
apps/my_custom_app/my_custom_app/hooks.py # In inner packageResult: Hooks not loaded, no events trigger.
---
Module Anti-Patterns
❌ Module Not Registered
# modules.txt - FORGOT to add new_module
My Custom App
# New Module <- missing!Result: DocTypes in unregistered modules don't work correctly.
---
❌ Missing __init__.py
# WRONG - no __init__.py
my_custom_app/
└── new_module/
└── doctype/ # ImportError!# ✅ CORRECT - __init__.py in every directory
my_custom_app/
└── new_module/
├── __init__.py # Empty file
└── doctype/
└── __init__.pyResult: Python cannot import the module.
---
Patches Anti-Patterns
❌ No Error Handling
# WRONG - crashes without logging
def execute():
frappe.db.sql("DELETE FROM `tabOldTable`")# ✅ CORRECT - with error handling
def execute():
try:
frappe.db.sql("DELETE FROM `tabOldTable`")
frappe.db.commit()
except Exception as e:
frappe.log_error(title="Delete Old Table Failed")
raiseResult: Patch fails without diagnostic information.
---
❌ Wrong Model Sync Section
# WRONG - pre_model_sync but needs new field
# patches.txt: [pre_model_sync] myapp.patches.v1_0.fill_new_field
def execute():
# new_field doesn't exist yet!
frappe.db.sql("UPDATE `tabCustomer` SET new_field = 'value'")# ✅ CORRECT - in post_model_sync
# patches.txt: [post_model_sync] myapp.patches.v1_0.fill_new_field
def execute():
frappe.db.sql("UPDATE `tabCustomer` SET new_field = 'value'")Result: SQL error because column doesn't exist.
---
❌ Large Dataset Without Batching
# WRONG - can run out of memory
def execute():
all_records = frappe.get_all("HugeDocType", fields=["*"]) # 1M+ records
for record in all_records:
process(record)# ✅ CORRECT - batch processing
def execute():
batch_size = 1000
offset = 0
while True:
records = frappe.get_all(
"HugeDocType",
fields=["name"],
limit_page_length=batch_size,
limit_start=offset
)
if not records:
break
for record in records:
process(record)
frappe.db.commit()
offset += batch_sizeResult: Server memory exhaustion, process kill.
---
❌ Hardcoded Site-Specific Values
# WRONG - site-specific values
def execute():
frappe.db.set_value("Company", "My Company Ltd", "default_currency", "USD")# ✅ CORRECT - dynamic lookup
def execute():
companies = frappe.get_all("Company")
for company in companies:
if not frappe.db.get_value("Company", company.name, "default_currency"):
frappe.db.set_value("Company", company.name, "default_currency", "USD")Result: Patch fails on other sites where "My Company Ltd" doesn't exist.
---
❌ Duplicate Patch Entry
# WRONG - duplicate is ignored
myapp.patches.v1_0.my_patch
myapp.patches.v1_0.my_patch # Will NOT run again# ✅ CORRECT - make unique with comment
myapp.patches.v1_0.my_patch
myapp.patches.v1_0.my_patch #run-2024-01-15Result: Second entry is ignored, patch doesn't re-run.
---
❌ Missing __init__.py in Patches
# WRONG - Python cannot find module
myapp/
└── patches/
└── v1_0/
└── my_patch.py # ImportError!# ✅ CORRECT - __init__.py in every directory
myapp/
└── patches/
├── __init__.py
└── v1_0/
├── __init__.py
└── my_patch.py---
Fixtures Anti-Patterns
❌ User Data in Fixtures
# WRONG - user specific data
fixtures = [
"User", # DON'T - contains passwords
"Communication" # DON'T - site specific data
]# ✅ CORRECT - configuration only
fixtures = [
"Custom Field",
"Property Setter",
"Role"
]Result: Security risk, privacy violation, deployment problems.
---
❌ Transactional Data in Fixtures
# WRONG - transactional data
fixtures = [
"Sales Invoice", # DON'T
"Sales Order" # DON'T
]Result: Production data overwritten, data loss.
---
❌ Overly Broad Filters
# WRONG - may export too much
fixtures = [
{"dt": "DocType"} # Exports ALL DocTypes!
]# ✅ CORRECT - specific filter
fixtures = [
{"dt": "DocType", "filters": [["module", "=", "My Module"]]}
]Result: Unintended system DocTypes get overwritten.
---
❌ Circular Dependency in Fixtures
# WRONG - Workflow depends on Workflow State
fixtures = [
"Workflow", # Needs states
"Workflow State" # Comes too late
]# ✅ CORRECT - proper order
fixtures = [
"Workflow State",
"Workflow"
]Result: Import errors, incomplete workflows.
---
❌ Missing Module in Custom Fields
[
{
"doctype": "Custom Field",
"name": "Sales Invoice-custom_field",
"dt": "Sales Invoice",
"fieldname": "custom_field",
"module": "" // WRONG - no module
}
][
{
"doctype": "Custom Field",
"name": "Sales Invoice-custom_field",
"dt": "Sales Invoice",
"fieldname": "custom_field",
"module": "My Custom App" // ✅ CORRECT
}
]Result: Custom field not exported correctly on next export.
---
General Anti-Patterns
❌ No Version Compatibility Check
# WRONG - no version check
def execute():
# v15-only feature
frappe.new_v15_function()# ✅ CORRECT - version check
def execute():
import frappe
frappe_version = int(frappe.__version__.split('.')[0])
if frappe_version >= 15:
frappe.new_v15_function()
else:
frappe.legacy_function()---
❌ No Commit After Bulk Updates
# WRONG - no commit
def execute():
for i in range(10000):
frappe.db.set_value("DocType", name, "field", value)
# Implicit rollback on error!# ✅ CORRECT - explicit commits
def execute():
for i, item in enumerate(items):
frappe.db.set_value("DocType", item.name, "field", value)
if i % 100 == 0:
frappe.db.commit()
frappe.db.commit()---
❌ Print Statements in Production Code
# WRONG - print disappears in production
def execute():
print("Starting migration...")# ✅ CORRECT - use logging
def execute():
frappe.log_error(message="Starting migration", title="Migration Info")
# Or for non-errors:
frappe.logger().info("Starting migration...")---
Summary: Top 10 Mistakes
| # | Mistake | Result |
|---|---|---|
| 1 | Missing __version__ | Build fails |
| 2 | Frappe in pip dependencies | Install fails |
| 3 | Module not in modules.txt | DocTypes don't work |
| 4 | Missing __init__.py | Import errors |
| 5 | Patch without error handling | No diagnostics |
| 6 | Wrong model sync section | SQL errors |
| 7 | No batch processing | Memory exhaustion |
| 8 | User data in fixtures | Security risk |
| 9 | Hardcoded values | Multi-site failures |
| 10 | No commits | Data rollback |
Complete App Examples
Working examples of Frappe custom apps with all components.
---
Example 1: Minimal App (v15)
Directory Structure
minimal_app/
├── pyproject.toml
├── README.md
├── minimal_app/
│ ├── __init__.py
│ ├── hooks.py
│ ├── modules.txt
│ ├── patches.txt
│ └── minimal_app/
│ ├── __init__.py
│ └── fixtures/
│ └── role.json
└── .git/pyproject.toml
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "minimal_app"
authors = [
{ name = "Your Company", email = "dev@example.com" }
]
description = "A minimal Frappe app"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = []
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"minimal_app/__init__.py
__version__ = "0.0.1"minimal_app/hooks.py
app_name = "minimal_app"
app_title = "Minimal App"
app_publisher = "Your Company"
app_description = "A minimal Frappe app"
app_email = "dev@example.com"
app_license = "MIT"
fixtures = [
{"dt": "Role", "filters": [["name", "like", "Minimal%"]]}
]minimal_app/modules.txt
Minimal Appminimal_app/patches.txt
# Patches hereminimal_app/minimal_app/fixtures/role.json
[
{
"doctype": "Role",
"name": "Minimal User",
"desk_access": 1,
"is_custom": 1
}
]---
Example 2: ERPNext Extension App
Directory Structure
erpnext_extension/
├── pyproject.toml
├── README.md
├── erpnext_extension/
│ ├── __init__.py
│ ├── hooks.py
│ ├── modules.txt
│ ├── patches.txt
│ ├── patches/
│ │ ├── __init__.py
│ │ └── v1_0/
│ │ ├── __init__.py
│ │ └── setup_custom_fields.py
│ ├── overrides/
│ │ ├── __init__.py
│ │ └── sales_invoice.py
│ ├── erpnext_extension/
│ │ ├── __init__.py
│ │ ├── doctype/
│ │ │ └── extension_settings/
│ │ │ ├── __init__.py
│ │ │ ├── extension_settings.json
│ │ │ └── extension_settings.py
│ │ └── fixtures/
│ │ ├── custom_field.json
│ │ └── property_setter.json
│ └── public/
│ └── js/
│ └── sales_invoice.js
└── .git/pyproject.toml
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "erpnext_extension"
authors = [
{ name = "Your Company", email = "dev@example.com" }
]
description = "Extends ERPNext functionality"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = []
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"erpnext_extension/__init__.py
__version__ = "1.0.0"erpnext_extension/hooks.py
app_name = "erpnext_extension"
app_title = "ERPNext Extension"
app_publisher = "Your Company"
app_description = "Extends ERPNext functionality"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe", "erpnext"]
# Fixtures
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "ERPNext Extension"]]
},
{
"dt": "Property Setter",
"filters": [["module", "=", "ERPNext Extension"]]
},
{
"dt": "Role",
"filters": [["name", "like", "Extension%"]]
}
]
# Document events
doc_events = {
"Sales Invoice": {
"validate": "erpnext_extension.overrides.sales_invoice.validate",
"on_submit": "erpnext_extension.overrides.sales_invoice.on_submit"
}
}
# DocType specific JavaScript
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js"
}erpnext_extension/modules.txt
ERPNext Extensionerpnext_extension/patches.txt
[post_model_sync]
erpnext_extension.patches.v1_0.setup_custom_fieldserpnext_extension/patches/v1_0/setup_custom_fields.py
import frappe
def execute():
"""Setup default values for custom fields."""
# Set default value for existing invoices
invoices = frappe.get_all(
"Sales Invoice",
filters={"custom_extension_status": ["is", "not set"]},
fields=["name"]
)
for inv in invoices:
frappe.db.set_value(
"Sales Invoice",
inv.name,
"custom_extension_status",
"Pending",
update_modified=False
)
frappe.db.commit()erpnext_extension/overrides/sales_invoice.py
import frappe
def validate(doc, method):
"""Validate Sales Invoice extensions."""
if doc.custom_extension_status == "Approved":
if not doc.custom_approved_by:
frappe.throw("Approved By is required when status is Approved")
def on_submit(doc, method):
"""Handle Sales Invoice submission."""
if doc.custom_extension_status != "Approved":
frappe.throw("Cannot submit invoice without approval")erpnext_extension/public/js/sales_invoice.js
frappe.ui.form.on('Sales Invoice', {
refresh: function(frm) {
if (frm.doc.docstatus === 0) {
frm.add_custom_button(__('Request Approval'), function() {
frm.set_value('custom_extension_status', 'Pending Approval');
frm.save();
}, __('Extension'));
}
},
custom_extension_status: function(frm) {
if (frm.doc.custom_extension_status === 'Approved') {
frm.set_value('custom_approved_by', frappe.session.user);
}
}
});erpnext_extension/erpnext_extension/fixtures/custom_field.json
[
{
"doctype": "Custom Field",
"name": "Sales Invoice-custom_extension_status",
"dt": "Sales Invoice",
"module": "ERPNext Extension",
"fieldname": "custom_extension_status",
"fieldtype": "Select",
"options": "\nPending\nPending Approval\nApproved\nRejected",
"label": "Extension Status",
"insert_after": "status",
"translatable": 0
},
{
"doctype": "Custom Field",
"name": "Sales Invoice-custom_approved_by",
"dt": "Sales Invoice",
"module": "ERPNext Extension",
"fieldname": "custom_approved_by",
"fieldtype": "Link",
"options": "User",
"label": "Approved By",
"insert_after": "custom_extension_status",
"read_only": 1,
"translatable": 0
}
]---
Example 3: App with Patches (Data Migration)
patches.txt
[pre_model_sync]
# Data backup for field removal
myapp.patches.v2_0.backup_old_field_data
[post_model_sync]
# Migrate to new format
myapp.patches.v2_0.migrate_to_new_format
myapp.patches.v2_0.cleanup_temp_datapatches/v2_0/backup_old_field_data.py
import frappe
import json
def execute():
"""Backup data from old_field before it gets removed."""
records = frappe.get_all(
"MyDocType",
filters={"old_field": ["is", "set"]},
fields=["name", "old_field"]
)
if records:
# Store in temporary table or log
frappe.db.sql("""
CREATE TABLE IF NOT EXISTS `_backup_old_field` (
`name` VARCHAR(140),
`old_field` TEXT,
PRIMARY KEY (`name`)
)
""")
for record in records:
frappe.db.sql("""
INSERT INTO `_backup_old_field` (name, old_field)
VALUES (%s, %s)
ON DUPLICATE KEY UPDATE old_field = VALUES(old_field)
""", (record.name, record.old_field))
frappe.db.commit()patches/v2_0/migrate_to_new_format.py
import frappe
def execute():
"""Migrate data from backup to new field format."""
# Check if backup table exists
if not frappe.db.table_exists("_backup_old_field"):
return
batch_size = 500
offset = 0
while True:
records = frappe.db.sql("""
SELECT name, old_field FROM `_backup_old_field`
LIMIT %s OFFSET %s
""", (batch_size, offset), as_dict=True)
if not records:
break
for record in records:
try:
new_value = transform_value(record.old_field)
frappe.db.set_value(
"MyDocType",
record.name,
"new_field",
new_value,
update_modified=False
)
except Exception as e:
frappe.log_error(
f"Migration failed for {record.name}: {str(e)}",
"Data Migration Error"
)
frappe.db.commit()
offset += batch_size
def transform_value(old_value):
"""Transform old format to new format."""
# Implement transformation logic
return old_value.upper() if old_value else Nonepatches/v2_0/cleanup_temp_data.py
import frappe
def execute():
"""Remove temporary backup table."""
if frappe.db.table_exists("_backup_old_field"):
frappe.db.sql("DROP TABLE `_backup_old_field`")
frappe.db.commit()---
Installation Workflow
# 1. Create app
cd frappe-bench
bench new-app my_custom_app
# 2. Install app on site
bench --site mysite install-app my_custom_app
# 3. Migrate (load fixtures)
bench --site mysite migrate
# 4. Clear cache
bench --site mysite clear-cache
# 5. Build assets
bench build --app my_custom_app---
Existing App from Git
# 1. Get app
bench get-app https://github.com/org/my_custom_app
# 2. Install
bench --site mysite install-app my_custom_app
# 3. Migrate
bench --site mysite migrateFixtures (Data Export/Import)
Fixtures are JSON files that are automatically imported during app installation or migration.
---
Fixtures Hook Configuration
Location: hooks.py
Basic Syntax
# Export ALL records of a DocType
fixtures = [
"Category",
"Custom Field"
]With Filters
fixtures = [
# All records of Category
"Category",
# Only specific records with filter
{"dt": "Role", "filters": [["role_name", "like", "MyApp%"]]},
# Multiple filters
{
"dt": "Custom Field",
"filters": [
["module", "=", "MyApp"],
["dt", "in", ["Sales Invoice", "Sales Order"]]
]
},
# Or filters (v14+)
{
"dt": "Property Setter",
"or_filters": [
["module", "=", "MyApp"],
["name", "like", "myapp%"]
]
}
]---
Exporting Fixtures
Export Command
# Export all fixtures for an app
bench --site sitename export-fixtures --app myapp
# Export fixtures for all apps
bench --site sitename export-fixturesOutput Location
myapp/
└── {module}/
└── fixtures/
├── category.json
├── role.json
└── custom_field.json---
Fixture File Structure
Example: custom_field.json
[
{
"doctype": "Custom Field",
"name": "Sales Invoice-custom_field_name",
"dt": "Sales Invoice",
"fieldname": "custom_field_name",
"fieldtype": "Data",
"label": "Custom Field",
"insert_after": "customer"
},
{
"doctype": "Custom Field",
"name": "Sales Invoice-another_field",
"dt": "Sales Invoice",
"fieldname": "another_field",
"fieldtype": "Link",
"options": "Customer",
"label": "Another Field"
}
]---
Fields NOT Exported
The following system fields are automatically excluded:
| Field | Reason |
|---|---|
modified_by | System managed |
creation | System managed |
owner | Site-specific |
idx | Order system managed |
lft | Tree structure (internal) |
rgt | Tree structure (internal) |
For child table records also:
docstatusdoctypemodifiedname
---
Fixtures Import Behavior
Fixtures are imported during:
1. App installation: bench --site sitename install-app myapp 2. Migration: bench --site sitename migrate 3. Update: bench update
Sync Behavior
| Action | Description |
|---|---|
| Insert | New records are added |
| Update | Existing records are overwritten |
| Delete | Records NOT in fixture are NOT deleted |
---
Commonly Used Fixture DocTypes
| DocType | Usage |
|---|---|
Custom Field | Add custom fields to existing DocTypes |
Property Setter | Modify properties of existing fields |
Role | Custom roles |
Custom DocPerm | Custom permissions |
Workflow | Workflow definitions |
Workflow State | Workflow states |
Workflow Action | Workflow actions |
Print Format | Print templates |
Report | Custom reports |
---
Custom Field Fixture Example
hooks.py
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "My Custom App"]]
}
]fixtures/custom_field.json
[
{
"doctype": "Custom Field",
"name": "Sales Invoice-custom_reference",
"dt": "Sales Invoice",
"module": "My Custom App",
"fieldname": "custom_reference",
"fieldtype": "Data",
"label": "Custom Reference",
"insert_after": "naming_series",
"translatable": 0
},
{
"doctype": "Custom Field",
"name": "Sales Invoice-custom_category",
"dt": "Sales Invoice",
"module": "My Custom App",
"fieldname": "custom_category",
"fieldtype": "Link",
"options": "Category",
"label": "Category",
"insert_after": "custom_reference"
}
]---
Property Setter Fixture Example
[
{
"doctype": "Property Setter",
"name": "Sales Invoice-customer-reqd",
"doc_type": "Sales Invoice",
"module": "My Custom App",
"field_name": "customer",
"property": "reqd",
"property_type": "Check",
"value": "1"
},
{
"doctype": "Property Setter",
"name": "Sales Invoice-main-default_print_format",
"doc_type": "Sales Invoice",
"module": "My Custom App",
"field_name": null,
"property": "default_print_format",
"property_type": "Data",
"value": "My Custom Format"
}
]---
after_sync Hook
# hooks.py
after_sync = "myapp.setup.after_sync"# myapp/setup.py
def after_sync():
"""Runs after fixtures are synchronized."""
setup_default_values()
create_default_records()---
Fixtures vs Patches: When to Use What?
| Scenario | Fixtures | Patches |
|---|---|---|
| Add Custom Fields | ✅ | ❌ |
| Property Setters | ✅ | ❌ |
| Standard configuration (Roles, Workflows) | ✅ | ❌ |
| Data transformation | ❌ | ✅ |
| Data cleanup | ❌ | ✅ |
| One-time data import | ❌ | ✅ |
| Field value migration | ❌ | ✅ |
| Default seed data | ✅ | ❌ (or after_install) |
---
Filter Syntax
Comparison Operators
| Operator | Example |
|---|---|
= | ["field", "=", "value"] |
!= | ["field", "!=", "value"] |
like | ["field", "like", "prefix%"] |
not like | ["field", "not like", "%pattern%"] |
in | ["field", "in", ["val1", "val2"]] |
not in | ["field", "not in", ["val1", "val2"]] |
is | ["field", "is", "set"] or ["field", "is", "not set"] |
---
Critical Rules
✅ ALWAYS
1. Set module field for Custom Fields/Property Setters 2. Use specific filters (don't export all records) 3. Test fixtures after export with clean install 4. Respect fixtures order for dependencies
❌ NEVER
1. User data in fixtures (User, Communication) 2. Transactional data (Sales Invoice, Sales Order) 3. Overly broad filters (may export too much) 4. Site-specific values in fixtures
⚠️ USE WITH CAUTION
1. Custom DocPerm - overwrites user customizations 2. Workflow - can affect active workflows 3. Records with dependencies (correct order!)
Custom App — Bench Command Reference
App Creation
# Create new app (interactive prompts for metadata)
bench new-app my_custom_app
# Prompts: Title, Description, Publisher, Email, Icon, Color, License
# Create app in a specific directory (rarely needed)
cd apps && bench new-app my_custom_app---
App Installation
# Install app on a site
bench --site mysite install-app my_custom_app
# Uninstall app from a site
bench --site mysite uninstall-app my_custom_app
# List installed apps
bench --site mysite list-apps---
Getting Apps from Git
# Get app from GitHub
bench get-app https://github.com/org/my_custom_app
# Get specific branch
bench get-app https://github.com/org/my_custom_app --branch develop
# Get specific tag/version
bench get-app https://github.com/org/my_custom_app --branch v1.0.0---
Migration & Build
# Run migrations (patches + schema sync + fixtures)
bench --site mysite migrate
# Skip failing patches (NEVER in production)
bench --site mysite migrate --skip-failing
# Build frontend assets for specific app
bench build --app my_custom_app
# Build all apps
bench build
# Clear cache
bench --site mysite clear-cache---
Patch Management
# Create a new patch interactively
bench create-patch
# Prompts: App, DocType, Description, Filename
# Run a specific patch manually (development only)
bench --site mysite run-patch myapp.patches.v1_0.my_patch---
Fixture Management
# Export fixtures for specific app
bench --site mysite export-fixtures --app my_custom_app
# Export fixtures for all apps
bench --site mysite export-fixtures---
Development Commands
# Start development server
bench start
# Watch for file changes and auto-rebuild
bench watch
# Run tests for an app
bench --site mysite run-tests --app my_custom_app
# Run specific test
bench --site mysite run-tests --module myapp.my_module.doctype.my_doctype.test_my_doctype---
Programmatic App Info
import frappe
# Get installed apps
apps = frappe.get_installed_apps()
# Get app version
version = frappe.get_attr("my_custom_app.__version__")
# Get app hooks
hooks = frappe.get_hooks(app_name="my_custom_app")
# Check if app is installed
is_installed = "my_custom_app" in frappe.get_installed_apps()---
required_apps in hooks.py
# hooks.py — declare app dependencies
required_apps = ["frappe"] # Frappe-only app
required_apps = ["frappe", "erpnext"] # ERPNext extension
required_apps = ["frappe", "erpnext", "hrms"] # HRMS extensionrequired_apps is checked during bench get-app — missing dependencies are auto-installed.
---
Version Check Pattern
import frappe
frappe_version = int(frappe.__version__.split(".")[0])
if frappe_version >= 15:
# v15+ specific code
pass
else:
# v14 fallback
passModule Organization
Modules structure your app into logical components. Every DocType MUST belong to a module.
---
modules.txt Structure
Location: {app}/{app}/modules.txt
My Custom App
Integrations
Reports
SettingsRules:
- One module name per line
- Module name = directory name with spaces instead of underscores
- Default module has the same name as the app
- Every DocType MUST belong to a registered module
---
Module Name to Directory Mapping
| modules.txt | Directory | Example DocType Path |
|---|---|---|
| My Custom App | my_custom_app | .../my_custom_app/doctype/... |
| Integrations | integrations | .../integrations/doctype/... |
| Sales Reports | sales_reports | .../sales_reports/report/... |
| HR Settings | hr_settings | .../hr_settings/doctype/... |
Conversion rule: Spaces → underscores, lowercase
---
Module Directory Structure
my_custom_app/
├── modules.txt # Module registration
├── my_custom_app/ # Default module
│ ├── __init__.py # REQUIRED
│ └── doctype/
│ └── my_doctype/
├── integrations/ # Extra module
│ ├── __init__.py # REQUIRED
│ └── doctype/
│ └── api_settings/
├── reports/ # Reports module
│ ├── __init__.py # REQUIRED
│ └── report/
│ └── sales_summary/
└── settings/ # Settings module
├── __init__.py # REQUIRED
└── doctype/
└── app_settings/---
Adding a Module
Step 1: Add to modules.txt
My Custom App
New ModuleStep 2: Create directory structure
mkdir -p my_custom_app/new_module/doctype
touch my_custom_app/new_module/__init__.pyStep 3: Select module when creating DocType
When creating a new DocType via the UI:
- Select the correct module in the "Module" dropdown
---
Module Components
Each module can contain:
| Component | Directory | Description |
|---|---|---|
| DocTypes | doctype/ | Data models |
| Reports | report/ | Query/Script Reports |
| Print Formats | print_format/ | Print templates |
| Dashboards | dashboard/ | Dashboard definitions |
| Workspace | workspace/ | Module workspace |
---
Module Icon Configuration
Via config/desktop.py (Legacy)
# my_custom_app/config/desktop.py
def get_data():
return [
{
"module_name": "My Custom App",
"color": "blue",
"icon": "octicon octicon-package",
"type": "module",
"label": "My Custom App"
},
{
"module_name": "Integrations",
"color": "green",
"icon": "octicon octicon-plug",
"type": "module",
"label": "Integrations"
}
]Available Icons
Frappe supports Octicons: octicon octicon-{name}
Commonly used:
octicon-package- Generic moduleocticon-plug- Integrationsocticon-graph- Reports/Analyticsocticon-gear- Settingsocticon-file- Documentsocticon-person- Users/HR
---
Module Best Practices
Logical Grouping
# GOOD - functional grouping
My Custom App # Core functionality
Integrations # External system connections
Settings # Configuration
Reports # Reporting# AVOID - too generic or too specific
Module 1 # Unclear name
Everything # Too broad
Customer Invoice PDF # Too specific (not a module)Recommended Module Structure
| Module Type | Purpose | Examples |
|---|---|---|
| Core (app name) | Main DocTypes | Project, Task |
| Settings | Configuration | App Settings, Defaults |
| Integrations | API connections | API Settings, Webhooks |
| Reports | Reporting | Sales Summary, Analytics |
| Utilities | Helper functions | Import/Export tools |
---
Module in DocType JSON
When you create a DocType, the module is stored:
{
"doctype": "DocType",
"name": "My DocType",
"module": "My Custom App",
...
}CRITICAL: If module is not in modules.txt, the DocType won't work correctly.
---
Module Workspace (v15+)
Workspaces replace the old desktop icons:
// my_custom_app/my_custom_app/workspace/my_custom_app/my_custom_app.json
{
"doctype": "Workspace",
"name": "My Custom App",
"module": "My Custom App",
"label": "My Custom App",
"is_standard": 1,
"links": [
{
"label": "Documents",
"links": [
{
"type": "doctype",
"name": "My DocType",
"label": "My DocType"
}
]
}
]
}---
Critical Rules
✅ ALWAYS
1. Register each module in modules.txt 2. Include __init__.py in every module directory 3. Use module name consistently (with spaces in modules.txt) 4. Assign DocTypes to the correct module
❌ NEVER
1. Create DocTypes in unregistered modules 2. Use spaces in module directory names 3. Have empty lines or trailing spaces in modules.txt 4. Change module names after DocTypes have been created
---
Troubleshooting
DocType not visible
1. Check if module is in modules.txt 2. Check if module name is spelled correctly 3. Run bench clear-cache
Module icon not visible
1. Check config/desktop.py syntax 2. Verify module_name matches exactly 3. Run bench build and bench clear-cache
Import errors
1. Verify __init__.py in every directory 2. Check module name conversion (spaces → underscores)
Patches (Migration Scripts)
Patches are Python scripts that execute data migrations during app updates.
---
patches.txt Structure
Location: {app}/{app}/patches.txt
Basic Syntax
# Simple patch reference (dotted path)
myapp.patches.v1_0.my_awesome_patch
# One-off Python statements
execute:frappe.delete_doc('Page', 'applications', ignore_missing=True)INI-Style Sections (v14+)
[pre_model_sync]
# Patches that run BEFORE DocType schema sync
# Have access to OLD schema (old fields still available)
myapp.patches.v1_0.migrate_old_field_data
myapp.patches.v1_0.backup_deprecated_records
[post_model_sync]
# Patches that run AFTER DocType schema sync
# Have access to NEW schema (new fields available)
# Do NOT need to call frappe.reload_doc
myapp.patches.v1_0.populate_new_field
myapp.patches.v1_0.cleanup_orphan_records---
Pre vs Post Model Sync
| Situation | Section | Reason |
|---|---|---|
| Migrate data from old field | [pre_model_sync] | Old fields still available |
| Populate new required fields | [post_model_sync] | New fields already exist |
| General data cleanup | [post_model_sync] | No schema dependency |
| Rename field and preserve data | [pre_model_sync] | Old field name still available |
---
Patch Directory Structure
Conventional Structure
myapp/
├── patches/
│ ├── __init__.py # REQUIRED (empty)
│ ├── v1_0/
│ │ ├── __init__.py # REQUIRED (empty)
│ │ ├── setup_defaults.py
│ │ └── migrate_data.py
│ └── v2_0/
│ ├── __init__.py # REQUIRED
│ └── schema_upgrade.py
└── patches.txtAlternative Structure (bench create-patch)
myapp/
├── {module}/
│ └── doctype/
│ └── {doctype}/
│ └── patches/
│ ├── __init__.py
│ └── improve_indexing.py
└── patches.txt---
Patch Implementation
Basic Template
import frappe
def execute():
"""Patch description here."""
# Patch logic
passComplete Example: Data Migration
# myapp/patches/v1_0/migrate_customer_type.py
import frappe
def execute():
"""Migrate customer_type from Text to Link field."""
type_mapping = {
"individual": "Individual",
"company": "Company",
"Individual": "Individual",
"Company": "Company"
}
customers = frappe.get_all(
"Customer",
filters={"customer_type": ["in", list(type_mapping.keys())]},
fields=["name", "customer_type"]
)
for customer in customers:
new_type = type_mapping.get(customer.customer_type)
if new_type:
frappe.db.set_value(
"Customer",
customer.name,
"customer_type",
new_type,
update_modified=False
)
frappe.db.commit()---
Schema Reload in Pre-Model-Sync
import frappe
def execute():
"""Patch that needs new schema in pre_model_sync."""
# Load new DocType definition BEFORE schema sync runs
frappe.reload_doc("module_name", "doctype", "doctype_name")
# Now new fields are available
frappe.db.sql("""
UPDATE `tabMyDocType`
SET new_field = old_field
WHERE old_field IS NOT NULL
""")Note: In [post_model_sync], frappe.reload_doc() is NOT needed.
---
Patch Execution Rules
| Rule | Description |
|---|---|
| Unique lines | Each line in patches.txt must be unique |
| One-time execution | Patches run only once per site |
| Order | Patches run in the order they appear |
| Tracking | Executed patches are stored in Patch Log DocType |
| Re-run | Add comment to re-run a patch |
---
Re-running a Patch
# Original
myapp.patches.v1_0.my_patch
# To re-run, add comment (makes line unique)
myapp.patches.v1_0.my_patch #2024-01-15
myapp.patches.v1_0.my_patch #run-again---
bench create-patch Command
$ bench create-patch
Select app for new patch (frappe, erpnext, myapp): myapp
Provide DocType name on which this patch will apply: Customer
Describe what this patch does: Improve customer indexing
Provide filename for this patch [improve_indexing.py]:
Patch folder doesn't exist, create it? [Y/n]: y
Created patch file and updated patches.txt---
Error Handling
Basic Try/Except
import frappe
def execute():
try:
perform_migration()
except Exception as e:
frappe.log_error(
message=frappe.get_traceback(),
title="Patch Error: migrate_customer_type"
)
raise # Re-raise to mark patch as failedAtomic Operations
import frappe
def execute():
"""Patch with transaction control."""
try:
for item in get_items_to_migrate():
process_item(item)
frappe.db.commit()
except Exception:
frappe.db.rollback()
raise---
Batch Processing
import frappe
def execute():
"""Patch with batch processing for large datasets."""
batch_size = 1000
offset = 0
while True:
items = frappe.db.sql("""
SELECT name FROM `tabMyDocType`
LIMIT %s OFFSET %s
""", (batch_size, offset), as_dict=True)
if not items:
break
for item in items:
process_item(item)
# Commit per batch
frappe.db.commit()
offset += batch_size---
bench migrate Workflow
The bench migrate command executes:
1. before_migrate hooks execute 2. [pre_model_sync] patches execute 3. Database schema synchronize (DocType JSON → database) 4. [post_model_sync] patches execute 5. Fixtures synchronize 6. Background jobs synchronize 7. Translations update 8. Search index rebuild 9. after_migrate hooks execute
Migrate Command Options
# Standard migration
bench --site sitename migrate
# Skip failing patches (NOT for production!)
bench --site sitename migrate --skip-failing
# Skip search index rebuild (faster)
bench --site sitename migrate --skip-search-index---
Critical Rules
✅ ALWAYS
1. Include __init__.py in every patches directory 2. Implement error handling with logging 3. Use batch processing for large datasets 4. Call frappe.db.commit() after bulk updates 5. Test on development environment first
❌ NEVER
1. Hardcode site-specific values 2. Create patches without error handling 3. Process large datasets without batching 4. Duplicate the same patch line (it will be ignored) 5. Use pre-model-sync patch that needs new fields without reload_doc
Custom App — Common Patterns
Pattern 1: Minimal Frappe-Only App
# hooks.py
app_name = "my_tool"
app_title = "My Tool"
app_publisher = "Your Company"
app_description = "Standalone tool"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe"]Use when building standalone functionality that does NOT depend on ERPNext modules.
---
Pattern 2: ERPNext Extension App
# hooks.py
app_name = "erp_extension"
app_title = "ERP Extension"
app_publisher = "Your Company"
app_description = "Extends ERPNext Sales"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe", "erpnext"]
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "ERP Extension"]]},
{"dt": "Property Setter", "filters": [["module", "=", "ERP Extension"]]},
]
doc_events = {
"Sales Invoice": {
"validate": "erp_extension.overrides.sales_invoice.validate",
"on_submit": "erp_extension.overrides.sales_invoice.on_submit",
}
}
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js",
}Use when adding custom fields, validation, or workflows to existing ERPNext DocTypes.
---
Pattern 3: Override Pattern (doc_events)
# erp_extension/overrides/sales_invoice.py
import frappe
def validate(doc, method):
"""Called during Sales Invoice validate."""
if doc.custom_approval_status == "Rejected":
frappe.throw("Cannot save a rejected invoice")
def on_submit(doc, method):
"""Called after Sales Invoice submit."""
create_audit_log(doc)The function signature is ALWAYS (doc, method) for doc_events hooks.
---
Pattern 4: Settings Singleton
# Create a Single DocType called "My App Settings"
# Access from code:
settings = frappe.get_single("My App Settings")
api_key = settings.api_key
is_enabled = settings.enable_integrationALWAYS use a Single DocType for app-wide configuration. NEVER hardcode settings.
---
Pattern 5: Fixture-Based Custom Fields
1. Create Custom Fields via desk UI 2. Set module to your app module name 3. Add to hooks.py:
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
]4. Export:
bench --site mysite export-fixtures --app my_app5. Commit the generated JSON files to Git
This ensures Custom Fields are version-controlled and deployed consistently.
---
Pattern 6: Version-Compatible Code
import frappe
frappe_version = int(frappe.__version__.split(".")[0])
if frappe_version >= 15:
from frappe.utils.background_jobs import is_job_enqueued
else:
# v14 fallback
def is_job_enqueued(job_id):
from frappe.core.page.background_jobs.background_jobs import get_info
return job_id in [d.get("job_name") for d in get_info()]---
Pattern 7: Workspace Definition [v15+]
{
"doctype": "Workspace",
"name": "My App",
"module": "My App",
"label": "My App",
"is_standard": 1,
"links": [
{
"label": "Documents",
"links": [
{"type": "doctype", "name": "My DocType", "label": "My DocType"}
]
},
{
"label": "Settings",
"links": [
{"type": "doctype", "name": "My App Settings", "label": "Settings"}
]
}
]
}Place in: my_app/my_app/workspace/my_app/my_app.json
---
Pattern 8: App with after_install Hook
# hooks.py
after_install = "my_app.setup.install.after_install"
# my_app/setup/install.py
import frappe
def after_install():
"""Set up default data after app installation."""
create_default_roles()
create_default_settings()
def create_default_roles():
for role_name in ["My App User", "My App Admin"]:
if not frappe.db.exists("Role", role_name):
frappe.get_doc({"doctype": "Role", "role_name": role_name}).insert()
def create_default_settings():
settings = frappe.get_single("My App Settings")
settings.default_status = "Active"
settings.save()---
Pattern 9: Multi-App Dependency Chain
# App C depends on App B which depends on App A
# app_c/hooks.py
required_apps = ["frappe", "app_a", "app_b"]
# bench get-app will auto-install dependencies in orderALWAYS declare ALL dependencies in required_apps, not just immediate ones.
Build Configuration: pyproject.toml and setup.py
Complete configuration for Frappe app packaging in v14 (setup.py) and v15 (pyproject.toml).
---
pyproject.toml (v15 - Primary)
Minimal Configuration
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "my_custom_app"
authors = [
{ name = "Your Company", email = "developers@example.com" }
]
description = "Description of your custom app"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = []---
Full Configuration
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "my_custom_app"
authors = [
{ name = "Your Company", email = "developers@example.com" }
]
description = "Description of your custom app"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
license = "MIT"
keywords = ["frappe", "erpnext", "custom-app"]
# Python package dependencies (NOT Frappe/ERPNext!)
dependencies = [
"requests~=2.31.0",
"pandas~=2.0.0",
]
classifiers = [
"Development Status :: 4 - Beta",
"Framework :: Frappe",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.10",
]
[project.urls]
Homepage = "https://example.com"
Repository = "https://github.com/your-org/my_custom_app.git"
"Bug Reports" = "https://github.com/your-org/my_custom_app/issues"
# Frappe app dependencies (bench manages these)
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"
# APT dependencies for Frappe Cloud
[deploy.dependencies.apt]
packages = ["libmagic1", "ffmpeg"]
# Ruff linter configuration
[tool.ruff]
line-length = 110
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "B"]
[tool.ruff.lint.isort]
known-first-party = ["frappe", "erpnext", "my_custom_app"]---
Section Details
[build-system] (REQUIRED)
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"| Field | Value | Explanation |
|---|---|---|
requires | ["flit_core >=3.4,<4"] | Frappe standard build tool |
build-backend | "flit_core.buildapi" | Flit reads __version__ from __init__.py |
CRITICAL: Flit requires dynamic = ["version"] in [project] section.
---
[project] Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Package name (MUST match directory) |
authors | list | ✅ | Author(s) with name and email |
description | string | ✅ | Short description |
requires-python | string | ✅ | Python version (>=3.10 for v14/v15) |
readme | string | Recommended | Path to README file |
dynamic | list | ✅ | ALWAYS ["version"] for flit |
dependencies | list | Optional | Python package dependencies |
license | string | Optional | SPDX license identifier |
keywords | list | Optional | Keywords |
classifiers | list | Optional | PyPI classifiers |
---
Dependencies Syntax
dependencies = [
# Exact version
"requests==2.31.0",
# Compatible version (2.31.x)
"requests~=2.31.0",
# Minimum version
"requests>=2.31.0",
# Version range
"requests>=2.28.0,<3.0.0",
# No version restriction (AVOID)
"requests",
]CRITICAL: Frappe/ERPNext dependencies do NOT go in dependencies!
---
[tool.bench.frappe-dependencies]
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"
erpnext = ">=15.0.0,<16.0.0"
hrms = ">=15.0.0,<16.0.0"These are checked by bench get-app, not by pip.
---
[deploy.dependencies.apt]
[deploy.dependencies.apt]
packages = [
"libmagic1",
"ffmpeg",
"wkhtmltopdf"
]For Frappe Cloud deployments - installs system packages.
---
setup.py (v14 - Legacy)
Minimal Configuration
from setuptools import setup, find_packages
setup(
name="my_custom_app",
version="0.0.1",
description="Description of your custom app",
author="Your Company",
author_email="developers@example.com",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=[],
)---
Full Configuration
from setuptools import setup, find_packages
with open("requirements.txt") as f:
install_requires = f.read().strip().split("\n")
with open("README.md") as f:
long_description = f.read()
setup(
name="my_custom_app",
version="0.0.1",
description="Description of your custom app",
long_description=long_description,
long_description_content_type="text/markdown",
author="Your Company",
author_email="developers@example.com",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=install_requires,
python_requires=">=3.10",
classifiers=[
"Development Status :: 4 - Beta",
"Framework :: Frappe",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.10",
],
)---
requirements.txt (v14)
requests~=2.31.0
pandas>=2.0.0
python-dateutil>=2.8.0dev-requirements.txt (v14)
pytest>=7.0.0
black>=23.0.0
ruff>=0.0.280Note: With developer_mode=True, dev-requirements are also installed.
---
__init__.py (REQUIRED)
# my_custom_app/__init__.py
__version__ = "0.0.1"CRITICAL: The __version__ variable is REQUIRED. Flit reads this automatically.
Optional Additions
# my_custom_app/__init__.py
"""My Custom App - A brief description."""
__version__ = "0.0.1"
__title__ = "My Custom App"
__author__ = "Your Company"
__license__ = "MIT"---
Version Numbering Convention
| Format | Example | Usage |
|---|---|---|
| Major.Minor.Patch | 1.2.3 | Stable releases |
| Major.Minor.Patch-dev | 1.2.3-dev | Development versions |
| Major.x.x-develop | 15.x.x-develop | Branch versions (ERPNext style) |
---
Python Version Requirements
| Frappe Version | Python Minimum |
|---|---|
| v14 | >=3.10 |
| v15 | >=3.10 |
| v16 | >=3.14 |
---
Migration v14 → v15
1. Create pyproject.toml with correct structure 2. Move dependencies from requirements.txt to pyproject.toml 3. Verify __version__ in __init__.py 4. Remove (optional): setup.py, MANIFEST.in, requirements.txt 5. Test with bench get-app and bench install-app
---
Critical Rules
✅ ALWAYS
1. Package name in pyproject.toml MUST match directory name 2. Add dynamic = ["version"] for flit 3. Define __version__ in __init__.py 4. Frappe dependencies in [tool.bench.frappe-dependencies]
❌ NEVER
1. Put Frappe/ERPNext in project dependencies (not on PyPI) 2. Forget __version__ (flit build fails) 3. Use build-backend other than flit_core
Custom App Directory Structure
Complete directory structure for Frappe custom apps in v14 and v15.
---
Full Structure (v15 - pyproject.toml)
apps/my_custom_app/
├── README.md # App description
├── pyproject.toml # Build configuration (v15)
├── my_custom_app/ # Main Python package
│ ├── __init__.py # Package init with __version__
│ ├── hooks.py # Frappe integration hooks
│ ├── modules.txt # List of modules
│ ├── patches.txt # Database migration patches
│ ├── config/ # Configuration files
│ │ ├── __init__.py
│ │ ├── desktop.py # Desktop shortcuts (legacy)
│ │ └── docs.py # Documentation configuration
│ ├── my_custom_app/ # Default module (same name as app)
│ │ ├── __init__.py
│ │ └── doctype/
│ │ └── my_doctype/
│ │ ├── __init__.py
│ │ ├── my_doctype.json
│ │ ├── my_doctype.py
│ │ └── my_doctype.js
│ ├── public/ # Static assets (client-side)
│ │ ├── css/
│ │ └── js/
│ ├── templates/ # Jinja templates
│ │ ├── __init__.py
│ │ ├── includes/
│ │ └── pages/
│ │ └── __init__.py
│ └── www/ # Portal/web pages
└── .git/ # Git repository---
Directory Structure (v14 - setup.py)
apps/my_custom_app/
├── MANIFEST.in # Package manifest
├── README.md
├── license.txt
├── requirements.txt # Python dependencies
├── dev-requirements.txt # Development dependencies
├── setup.py # Build configuration (v14)
├── package.json # Node dependencies
├── my_custom_app/
│ ├── __init__.py
│ ├── hooks.py
│ ├── modules.txt
│ ├── patches.txt
│ └── [rest identical to v15]
└── my_custom_app.egg-info/ # Generated after install
├── PKG-INFO
├── SOURCES.txt
├── dependency_links.txt
├── not-zip-safe
├── requires.txt
└── top_level.txt---
Required vs Optional Files
| File | v14 | v15 | Description |
|---|---|---|---|
pyproject.toml | ❌ | Required | Build and metadata configuration |
setup.py | Required | ❌ | Build configuration (legacy) |
my_app/__init__.py | Required | Required | Package definition with __version__ |
my_app/hooks.py | Required | Required | Frappe integration points |
my_app/modules.txt | Required | Required | Module registration |
my_app/patches.txt | Recommended | Recommended | Migration tracking |
README.md | Recommended | Recommended | Documentation |
requirements.txt | Recommended | ❌ | Replaced by pyproject.toml |
my_app/config/ | Optional | Optional | Extra configuration |
my_app/public/ | Optional | Optional | Client-side assets |
my_app/templates/ | Optional | Optional | Jinja templates |
my_app/www/ | Optional | Optional | Portal pages |
---
Module Directory Structure
my_custom_app/
├── my_custom_app/ # Default module
│ ├── __init__.py
│ └── doctype/
│ └── my_doctype/
│ ├── __init__.py
│ ├── my_doctype.py
│ ├── my_doctype.json
│ └── my_doctype.js
├── integrations/ # Extra module
│ ├── __init__.py
│ └── doctype/
│ └── api_settings/
│ └── ...
├── reports/ # Reports module
│ ├── __init__.py
│ └── report/
│ └── sales_summary/
│ └── ...
└── settings/ # Settings module
├── __init__.py
└── doctype/
└── app_settings/
└── ...---
DocType Directory Structure
doctype/my_doctype/
├── __init__.py # Empty (required)
├── my_doctype.json # DocType definition (UI-generated)
├── my_doctype.py # Python controller
├── my_doctype.js # Client script
├── test_my_doctype.py # Unit tests (optional)
└── my_doctype_dashboard.py # Dashboard config (optional)---
Report Directory Structure
report/sales_summary/
├── __init__.py # Empty
├── sales_summary.json # Report definition
├── sales_summary.py # Python (Query/Script Report)
├── sales_summary.js # Client script (optional)
└── sales_summary.html # Print format template (optional)---
Public Assets Structure
my_custom_app/
└── public/
├── js/
│ ├── my_custom_app.js # Main desk JS
│ ├── website.js # Website JS
│ └── sales_invoice.js # DocType-specific
├── css/
│ ├── my_custom_app.css # Main desk CSS
│ └── website.css # Website CSS
└── images/
└── logo.pngAssets URL: /assets/my_custom_app/**/*
---
Templates Structure
my_custom_app/
└── templates/
├── __init__.py
├── includes/
│ └── footer.html # Reusable snippets
└── pages/
├── __init__.py
└── custom_page.html # Standalone pages---
WWW (Portal) Structure
my_custom_app/
└── www/
├── projects/
│ ├── index.html # Template
│ └── index.py # Context controller
└── contact/
├── index.html
└── index.pyURL: /projects → www/projects/index.html
---
Patches Directory Structure
my_custom_app/
└── patches/
├── __init__.py # Required
├── v1_0/
│ ├── __init__.py # Required
│ ├── migrate_data.py
│ └── setup_defaults.py
└── v2_0/
├── __init__.py
└── schema_upgrade.py---
Config Directory Structure
my_custom_app/
└── config/
├── __init__.py
├── desktop.py # Module icons (legacy)
└── docs.py # Documentation setup---
Bench Folder Structure (Context)
frappe-bench/
├── apps/ # All apps here
│ ├── frappe/
│ ├── erpnext/
│ └── my_custom_app/ # Your app
├── sites/
│ ├── apps.txt # Installed apps on bench
│ └── mysite/
│ ├── site_config.json # Site-specific config
│ └── public/ # Site uploads
└── env/ # Python virtual environment---
Critical Paths
| Component | Path | Importance |
|---|---|---|
| Package init | my_app/__init__.py | MUST contain __version__ |
| Hooks | my_app/hooks.py | MUST be in inner package |
| Modules | my_app/modules.txt | Registers all modules |
| Patches | my_app/patches.txt | Migration scripts |
| Assets | my_app/public/ | Accessible via /assets/ |
---
Creating a New App
# From frappe-bench directory
bench new-app my_custom_app
# Interactive prompts:
# - App Title
# - App Description
# - App Publisher
# - App Email
# - App Icon (default: 'octicon octicon-file-directory')
# - App Color (default: 'grey')
# - App License (default: 'MIT')Custom App — Syntax Quick Reference
__init__.py
# my_custom_app/__init__.py — REQUIRED
__version__ = "0.0.1"---
pyproject.toml [v15+]
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "my_custom_app"
authors = [{ name = "Company", email = "dev@example.com" }]
description = "Description"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = []
[tool.bench.frappe-dependencies]
frappe = ">=15.0.0,<16.0.0"---
setup.py [v14]
from setuptools import setup, find_packages
setup(
name="my_custom_app",
version="0.0.1",
description="Description",
author="Company",
author_email="dev@example.com",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=[],
)---
hooks.py (Minimal)
app_name = "my_custom_app"
app_title = "My Custom App"
app_publisher = "Company"
app_description = "Description"
app_email = "dev@example.com"
app_license = "MIT"
required_apps = ["frappe"]
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My Custom App"]]},
]---
modules.txt
My Custom App
Integrations
Settings---
patches.txt
[pre_model_sync]
myapp.patches.v1_0.backup_data
[post_model_sync]
myapp.patches.v1_0.populate_fields---
Patch Function
import frappe
def execute():
"""One-line description."""
pass---
Fixture Hook
fixtures = [
"Category",
{"dt": "Custom Field", "filters": [["module", "=", "My Module"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My Module"]]},
]---
Bench Commands
bench new-app my_custom_app
bench --site mysite install-app my_custom_app
bench get-app https://github.com/org/app
bench --site mysite migrate
bench build --app my_custom_app
bench --site mysite export-fixtures --app my_custom_app
bench --site mysite clear-cacheCustom App — Templates & Portal Pages
Templates Directory
my_custom_app/
└── templates/
├── __init__.py
├── includes/ # Reusable Jinja snippets
│ ├── header.html
│ └── footer.html
└── pages/ # Standalone template pages
├── __init__.py
└── custom_page.htmlTemplates in the templates/includes/ directory are automatically available for inclusion in other Jinja templates across the app.
---
WWW (Portal Pages)
my_custom_app/
└── www/
├── projects/
│ ├── index.html # Jinja template
│ └── index.py # Context controller
└── contact/
├── index.html
└── index.pyURL mapping: Directory path maps directly to URL.
www/projects/index.html→/projectswww/contact/index.html→/contact
---
Portal Page Template (index.html)
{% extends "templates/web.html" %}
{% block page_content %}
<div class="container">
<h1>{{ title }}</h1>
{% for item in items %}
<div class="item">
<h3>{{ item.name }}</h3>
<p>{{ item.description }}</p>
</div>
{% endfor %}
</div>
{% endblock %}---
Portal Page Context (index.py)
import frappe
def get_context(context):
"""Provide template context."""
context.title = "My Projects"
context.items = frappe.get_all(
"Project",
filters={"status": "Open"},
fields=["name", "description"],
order_by="creation desc",
limit_page_length=20,
)
return context---
Template Variables
Available in all portal templates:
| Variable | Description |
|---|---|
frappe.session.user | Current logged-in user |
frappe.utils.now() | Current datetime |
frappe.form_dict | URL query parameters |
csrf_token | CSRF token for forms |
---
Hooks for Website
# hooks.py
# Custom routes
website_route_rules = [
{"from_route": "/custom/<path:app_path>", "to_route": "custom_page"},
]
# Portal menu items
portal_menu_items = [
{"title": "My Projects", "route": "/projects", "role": "Customer"},
]
# Website context
website_context = {
"favicon": "/assets/my_custom_app/images/favicon.ico",
}---
Including App Assets in Templates
<!-- Include CSS -->
<link rel="stylesheet" href="/assets/my_custom_app/css/custom.css">
<!-- Include JS -->
<script src="/assets/my_custom_app/js/custom.js"></script>Assets in public/ are served at /assets/{app_name}/.
---
Print Format Templates
my_custom_app/
└── my_module/
└── print_format/
└── custom_invoice/
├── custom_invoice.json # Print format definition
└── custom_invoice.html # Jinja template<!-- custom_invoice.html -->
<div class="print-format">
<h1>{{ doc.name }}</h1>
<p>Customer: {{ doc.customer }}</p>
<table>
{% for item in doc.items %}
<tr>
<td>{{ item.item_code }}</td>
<td>{{ item.qty }}</td>
<td>{{ frappe.utils.fmt_money(item.amount) }}</td>
</tr>
{% endfor %}
</table>
</div>