
Frappe Syntax Doctypes
- 25 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-syntax-doctypes is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-syntax-doctypes
- AI & Agent Building
- AI-coding skill
Frappe Syntax Doctypes by the numbers
- 25 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #9,764 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-syntax-doctypesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
DocType JSON Design
DocTypes are the foundation of every Frappe application. A DocType defines both the data model (database schema) and the view (form layout). ALWAYS design DocTypes before writing any controller logic.
Quick Reference
DocType JSON Top-Level Properties
| Property | Type | Purpose |
|---|---|---|
name | str | DocType identifier (singular, e.g. "Sales Invoice") |
module | str | App module this DocType belongs to |
is_submittable | bool | Enables Draft -> Submitted -> Cancelled workflow |
is_tree | bool | Enables NestedSet hierarchy (lft/rgt columns) |
is_virtual | bool | No database table; data from custom backend |
issingle | bool | Single-instance settings document |
istable | bool | Child table DocType (embedded in parent) |
is_calendar_and_gantt | bool | Enables calendar/gantt views |
track_changes | bool | Stores version history on every save |
track_seen | bool | Tracks which users viewed the document |
track_views | bool | Counts total document views |
allow_rename | bool | Permits renaming after creation |
allow_copy | bool | Enables "Duplicate" action |
allow_import | bool | Enables Data Import for this DocType |
naming_rule | str | Naming method selector (see Naming section) |
autoname | str | Naming pattern string |
title_field | str | Field used as display title |
search_fields | str | Comma-separated fields for search results |
show_title_field_in_link | bool | Display title instead of name in Link fields |
image_field | str | Field containing image for avatar display |
sort_field | str | Default sort column |
sort_order | str | "ASC" or "DESC" |
default_print_format | str | Print Format name |
max_attachments | int | Attachment limit |
Common Fieldtypes (Quick Lookup)
| Fieldtype | Stores | DB Column |
|---|---|---|
| Data | Text up to 140 chars | VARCHAR(140) |
| Link | Reference to another DocType | VARCHAR(140) |
| Dynamic Link | Reference to any DocType | VARCHAR(140) |
| Select | Single choice from options | VARCHAR(140) |
| Table | Child table rows | Separate table |
| Table MultiSelect | Multi-select link rows | Separate table |
| Check | Boolean 0/1 | TINYINT |
| Int | Whole number | INT |
| Float | Decimal (9 places) | DECIMAL |
| Currency | Money value (6 decimals) | DECIMAL |
| Date | Calendar date | DATE |
| Datetime | Date + time | DATETIME |
| Text Editor | Rich text (HTML) | LONGTEXT |
| Attach | File reference | VARCHAR(140) |
| Small Text | Short multi-line text | TEXT |
| Long Text | Unlimited text | LONGTEXT |
Full fieldtype reference with all 35+ types: references/fieldtypes.md
Essential Field Properties
| Property | Type | Purpose |
|---|---|---|
reqd | bool | Field is mandatory |
unique | bool | Database UNIQUE constraint |
search_index | bool | Database INDEX for faster queries |
in_list_view | bool | Show in list view columns |
in_standard_filter | bool | Show as filter in list view |
in_preview | bool | Show in document preview |
allow_on_submit | bool | Editable after submission |
read_only | bool | Not editable by user |
hidden | bool | Not visible on form |
depends_on | str | Visibility condition (e.g. eval:doc.status=="Active") |
mandatory_depends_on | str | Conditional mandatory |
read_only_depends_on | str | Conditional read-only |
fetch_from | str | Auto-populate from linked doc (e.g. customer.customer_name) |
fetch_if_empty | bool | Only fetch when field is empty |
options | str | Fieldtype-specific (DocType name, select options, etc.) |
default | str | Default value (supports __user, Today, etc.) |
description | str | Help text below field |
collapsible | bool | Section starts collapsed (Section Break only) |
Decision Tree: Which DocType Type?
Need to store data?
├─ YES: Need multiple records?
│ ├─ YES: Need submit/cancel workflow?
│ │ ├─ YES → Standard DocType + is_submittable=1
│ │ └─ NO: Need hierarchy/tree?
│ │ ├─ YES → Tree DocType (is_tree=1)
│ │ └─ NO: Embedded in parent?
│ │ ├─ YES → Child DocType (istable=1)
│ │ └─ NO → Standard DocType
│ └─ NO: Single config/settings → Single DocType (issingle=1)
└─ NO: Data from external source → Virtual DocType (is_virtual=1)Naming Rules
ALWAYS set naming_rule on the DocType. The autoname field holds the pattern.
| naming_rule Value | autoname Pattern | Example Output |
|---|---|---|
| Set by User | _(empty)_ | User types name manually |
| Autoincrement | _(empty)_ | 1, 2, 3 |
| By Fieldname | field:{fieldname} | Value of that field |
| By Naming Series | naming_series: | INV-2024-00001 (from series field) |
| Expression | PRE-.##### | PRE-00001, PRE-00002 |
| Expression (Old Style) | {prefix}-{YYYY}-{#####} | INV-2024-00001 |
| Random | hash | Random 10-char string |
| UUID | _(empty)_ | 550e8400-e29b-... |
| By Script | _(custom)_ | Controller autoname() decides |
NEVER use Autoincrement in production -- gaps appear when records are deleted. Use Expression or Naming Series instead.
Full naming reference: references/naming.md
Child Table Design
A Child DocType is a DocType with istable=1. It ALWAYS belongs to a parent.
Parent side -- add a field with:
fieldtype:Table(orTable MultiSelect)options: Child DocType name
Child records automatically get:
parent-- name of the parent documentparenttype-- DocType of the parentparentfield-- fieldname of the Table field in parentidx-- row order (1-based)
# Adding child rows programmatically
doc = frappe.get_doc("Sales Invoice", "INV-001")
doc.append("items", {
"item_code": "ITEM-001",
"qty": 5,
"rate": 100.0
})
doc.save()NEVER create a Child DocType without istable=1. NEVER reference a non-child DocType in a Table field.Table vs Table MultiSelect
| Aspect | Table | Table MultiSelect |
|---|---|---|
| UI | Full editable grid with "Add Row" | Tag-style picker, no "Add Row" |
| Child DocType | Full child with many fields | Typically 1 Link field only |
| Use case | Line items, detail rows | Multi-select references |
Single DocType (Settings Pattern)
Set issingle=1. Data is stored in tabSingles as key-value pairs, NOT in a dedicated table.
# Access Single DocType
settings = frappe.get_single("My Settings")
value = settings.some_field
# Or directly
value = frappe.db.get_single_value("My Settings", "some_field")- NEVER expect a list view for Single DocTypes -- they have exactly one instance.
- ALWAYS use for app-wide configuration (API keys, default values, feature toggles).
Tree DocType (NestedSet)
Set is_tree=1. Frappe adds lft, rgt, parent_{doctype_fieldname}, old_parent columns automatically.
- ALWAYS define a
parent_fieldin the DocType JSON (e.g.parent_accountfor Chart of Accounts). - The NestedSet model uses
lft/rgtintegers for efficient subtree queries. - NEVER manually edit
lft/rgtvalues. Usefrappe.utils.nestedset.rebuild_tree()if corrupted.
# Get all descendants
descendants = frappe.get_all("Account",
filters={"lft": [">", node.lft], "rgt": ["<", node.rgt]})
# Get ancestors (path to root)
ancestors = frappe.get_all("Account",
filters={"lft": ["<", node.lft], "rgt": [">", node.rgt]},
order_by="lft asc")Virtual DocType
Set is_virtual=1. No database table is created. ALWAYS implement these controller methods:
class MyVirtualDoc(Document):
def db_insert(self, *args, **kwargs):
# Persist to your custom backend
pass
def load_from_db(self):
# Load document data from your source
pass
def db_update(self, *args, **kwargs):
# Update in your custom backend
pass
def delete(self):
# Remove from your custom backend
pass
@staticmethod
def get_list(args):
# Return list of documents
pass
@staticmethod
def get_count(args):
# Return total count
pass
@staticmethod
def get_stats(args):
# Return statistics
passNEVER use frappe.db.* calls for Virtual DocType data -- they only work with the site database, not your custom backend.Customization APIs
Custom Fields (Programmatic)
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
# Dict format: {DocType: [field_dicts]}
create_custom_fields({
"Sales Invoice": [
dict(fieldname="custom_tracking", label="Tracking ID",
fieldtype="Data", insert_after="naming_series")
],
"Purchase Order": [
dict(fieldname="custom_vendor_ref", label="Vendor Ref",
fieldtype="Data", insert_after="supplier")
]
}, update=True)Property Setter (Programmatic)
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
# Change a field property on an existing DocType
make_property_setter("Sales Invoice", "customer", "reqd", 1, "Check")
make_property_setter("Sales Invoice", "posting_date", "default", "Today", "Text")Full customization reference: references/customization.md
Data Masking (v16+)
Fields with mask=1 hide sensitive values from users without mask permission at the field's permlevel. The server replaces values with patterns like XXXXXXXX before sending to the client. Administrator ALWAYS sees unmasked values.
{ "fieldname": "phone", "fieldtype": "Data", "options": "Phone", "mask": 1, "permlevel": 1 }Full masking reference: references/data-masking.md
Python Type Stubs
Frappe auto-generates type annotations in controller files via TypeExporter. Fields get DF.* types inside a TYPE_CHECKING guard:
if TYPE_CHECKING:
from frappe.types import DF
customer: DF.Link
items: DF.Table[SalesInvoiceItem]
status: DF.Literal["Draft", "Submitted", "Paid"]NEVER modify code between # begin: auto-generated types and # end: auto-generated types.
Full type stubs reference: references/type-stubs.md
Critical Rules
1. ALWAYS name DocTypes in singular form ("Sales Invoice", not "Sales Invoices"). 2. ALWAYS use the tab prefix mentally -- the DB table is tabSales Invoice. 3. NEVER exceed 140 characters for Data/Link/Select field values. 4. ALWAYS set search_index=1 on fields used in frequent filters or get_list calls. 5. ALWAYS set in_standard_filter=1 on fields users frequently filter by. 6. NEVER use allow_on_submit=1 on child table fields that affect calculations without recalculating totals. 7. ALWAYS set fetch_if_empty=1 alongside fetch_from unless you want to overwrite user edits. 8. NEVER define depends_on with raw Python -- use eval:doc.fieldname == "value" syntax.
See Also
- references/fieldtypes.md -- Complete fieldtype reference
- references/naming.md -- All naming methods with examples
- references/examples.md -- Real DocType JSON examples
- references/anti-patterns.md -- Common schema design mistakes
- references/customization.md -- Custom Fields and Property Setter APIs
- references/data-masking.md -- Field-level data masking for privacy (v16+)
- references/type-stubs.md -- Python type hints, DF types, TypeExporter
DocType Anti-Patterns
Common schema design mistakes and how to avoid them.
Naming Anti-Patterns
Using Autoincrement in Production
Problem: naming_rule: "Autoincrement" creates gaps when records are deleted. Names like 1, 2, 5 confuse users and are meaningless.
Fix: ALWAYS use Expression (PRE-.#####) or Naming Series for production DocTypes.
Too Few Hash Characters
Problem: autoname: "INV-##" overflows at 100 records.
Fix: ALWAYS use at least 5 hashes: INV-.#####. For high-volume DocTypes, use 6-7.
Field-Based Naming on Non-Unique Fields
Problem: autoname: "field:customer_name" causes DuplicateEntryError when two customers share a name.
Fix: ALWAYS set unique=1 on the source field. Prefer fields with natural uniqueness (codes, IDs).
Changing Naming Scheme After Data Exists
Problem: Switching from INV-.#### to SINV-.##### creates inconsistent names. Old records keep old format.
Fix: NEVER change naming scheme after go-live. Plan naming during DocType design.
---
Field Design Anti-Patterns
Missing search_index on Filtered Fields
Problem: Fields used in frappe.get_list() filters or get_all() without search_index=1 cause slow full-table scans.
Fix: ALWAYS set search_index=1 on fields used in filters, especially Link fields with many records.
Using Data Instead of Link
Problem: Storing a reference as plain text (fieldtype: "Data") instead of Link. No referential integrity, no validation, no autocomplete.
Fix: ALWAYS use Link fieldtype when referencing another DocType. Use Dynamic Link when the target DocType varies.
Currency Without options
Problem: fieldtype: "Currency" without options pointing to a currency field. Amount displays in default system currency, even for multi-currency documents.
Fix: ALWAYS set options on Currency fields to a field containing the currency code:
{
"fieldname": "amount",
"fieldtype": "Currency",
"options": "currency"
}Overusing allow_on_submit
Problem: Setting allow_on_submit=1 on fields that affect calculations (amounts, quantities) without triggering recalculation.
Fix: NEVER use allow_on_submit on calculation-critical fields unless the controller recalculates totals in on_update_after_submit.
fetch_from Without fetch_if_empty
Problem: fetch_from: "customer.customer_name" overwrites user edits every time the Link field changes.
Fix: ALWAYS set fetch_if_empty=1 unless you explicitly want forced overwriting.
Dynamic Link Without Type Selector
Problem: Creating a Dynamic Link field but forgetting the corresponding type-selector field.
Fix: ALWAYS create a pair:
[
{
"fieldname": "party_type",
"fieldtype": "Select",
"options": "\nCustomer\nSupplier"
},
{
"fieldname": "party",
"fieldtype": "Dynamic Link",
"options": "party_type"
}
]Using depends_on with Raw Python
Problem: depends_on: "doc.status == 'Active'" -- missing the eval: prefix.
Fix: ALWAYS use eval: prefix: depends_on: "eval:doc.status == 'Active'".
---
Child Table Anti-Patterns
Child DocType Without istable=1
Problem: Using a regular DocType in a Table field. The relationship breaks; child records have no parent linkage.
Fix: ALWAYS set istable=1 on DocTypes used in Table/Table MultiSelect fields.
Too Many Fields in Table MultiSelect Child
Problem: Using Table MultiSelect with a child DocType that has 10+ editable fields. The UI becomes unusable.
Fix: NEVER use Table MultiSelect for complex child records. Use regular Table for editable multi-field rows.
Missing in_list_view on Child Fields
Problem: Child table fields without in_list_view=1 are hidden from the grid and only visible when expanding a row.
Fix: ALWAYS set in_list_view=1 on the 3-5 most important child table fields.
Orphaned Child Records
Problem: Deleting parent records via raw SQL without cleaning up child tables.
Fix: ALWAYS use frappe.delete_doc() which handles cascade deletion. NEVER delete parents via frappe.db.sql("DELETE ...").
---
Structure Anti-Patterns
Single DocType for Multi-Record Data
Problem: Using issingle=1 when you need multiple records. Single DocTypes store ONE instance in tabSingles.
Fix: Use issingle=1 ONLY for app-wide settings/configuration. For multi-record data, use a standard DocType.
Tree DocType Without nsm_parent_field
Problem: Setting is_tree=1 but not defining nsm_parent_field. The NestedSet model cannot build the hierarchy.
Fix: ALWAYS set nsm_parent_field to the self-referencing Link field name.
Manual lft/rgt Manipulation
Problem: Directly updating lft/rgt values via SQL. Corrupts the entire tree structure.
Fix: NEVER touch lft/rgt directly. Use frappe.utils.nestedset.rebuild_tree() to fix corruption.
Virtual DocType Using frappe.db
Problem: Using frappe.db.get_list() or frappe.db.sql() inside Virtual DocType methods. These only query the site database, not your custom backend.
Fix: ALWAYS implement custom data access in every Virtual DocType method. The frappe.db.* API is for site-database-backed DocTypes only.
---
Permission Anti-Patterns
No Permissions Defined
Problem: Creating a DocType without any permission rules. No one can access it except Administrator.
Fix: ALWAYS define at least one permission entry in the DocType JSON.
Permissions on Child DocType
Problem: Adding permission rules to a child DocType (istable=1). Child tables inherit permissions from their parent.
Fix: NEVER define permissions on child DocTypes. Control access via the parent DocType's permissions.
---
Performance Anti-Patterns
Too Many Fields in One DocType
Problem: DocTypes with 100+ fields cause slow form loads and large database rows.
Fix: Split into parent + child tables, or use separate linked DocTypes. Keep parent DocTypes under 50 data fields.
Missing Indexes on High-Volume DocTypes
Problem: High-volume DocTypes (100k+ records) without search_index on filtered fields.
Fix: ALWAYS add search_index=1 to:
- All Link fields used in filters
- Date fields used in date-range queries
- Status/Select fields used in list filters
- Any field in
search_fields
Unnecessary track_changes
Problem: Enabling track_changes on high-volume, frequently-updated DocTypes creates massive tabVersion records.
Fix: ONLY enable track_changes on business-critical documents where audit history is required.
Customization API Reference
Programmatic APIs for customizing existing DocTypes without modifying their source JSON.
Custom Fields
create_custom_fields() -- Batch Creation
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
create_custom_fields({
"Sales Invoice": [
dict(
fieldname="custom_tracking_id",
label="Tracking ID",
fieldtype="Data",
insert_after="naming_series",
reqd=0,
in_list_view=1
),
dict(
fieldname="custom_delivery_status",
label="Delivery Status",
fieldtype="Select",
options="Pending\nShipped\nDelivered",
insert_after="custom_tracking_id",
default="Pending"
)
],
"Purchase Order": [
dict(
fieldname="custom_vendor_ref",
label="Vendor Reference",
fieldtype="Data",
insert_after="supplier"
)
]
}, ignore_validate=False, update=True)Signature:
def create_custom_fields(
custom_fields: dict, # {DocType: [field_dicts]}
ignore_validate=False, # Skip field validation
update=True # Update existing fields if they exist
)Behavior:
- Skips fields that already exist (no
DuplicateEntryError). - When
update=True, updates existing custom fields with new properties. - Clears DocType cache and rebuilds database schema after creation.
- Sets field owner to "Administrator".
Field dict properties (most common):
| Property | Required | Purpose |
|---|---|---|
fieldname | YES | Internal name (auto-generated from label if omitted) |
label | YES | Display label |
fieldtype | YES | Data type (Data, Link, Select, etc.) |
insert_after | YES | Fieldname to position after |
options | Depends | Target DocType (Link), choices (Select), etc. |
reqd | No | Mandatory (0 or 1) |
default | No | Default value |
depends_on | No | Visibility condition |
in_list_view | No | Show in list view (0 or 1) |
in_standard_filter | No | Show as filter (0 or 1) |
read_only | No | Non-editable (0 or 1) |
hidden | No | Not visible (0 or 1) |
fetch_from | No | Auto-populate source |
description | No | Help text |
create_custom_field() -- Single Field
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
create_custom_field(
"Sales Invoice",
dict(
fieldname="custom_approval_status",
label="Approval Status",
fieldtype="Select",
options="Pending\nApproved\nRejected",
insert_after="status"
),
ignore_validate=False,
is_system_generated=True
)Signature:
def create_custom_field(
doctype: str, # Target DocType
df: dict, # Field definition
ignore_validate=False, # Skip validation
is_system_generated=True # Mark as system-generated
)Tuple Keys for Shared Fields
Apply the same custom fields to multiple DocTypes:
create_custom_fields({
("Sales Invoice", "Purchase Invoice"): [
dict(
fieldname="custom_external_ref",
label="External Reference",
fieldtype="Data",
insert_after="naming_series"
)
]
})Custom Fields via Fixtures (Recommended for Apps)
For app-distributed customizations, use the fixtures approach:
Step 1: Add fields via Frappe UI (Customize Form).
Step 2: Update hooks.py:
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "=", "My App"]]
}
]Step 3: Export:
bench --site mysite export-fixturesThis creates JSON files in your app's fixtures/ directory, synced on bench migrate.
- ALWAYS use fixtures for app-distributed custom fields.
- ALWAYS use
create_custom_fields()for programmatic setup duringafter_installhooks.
---
Property Setters
make_property_setter() -- Modify DocType/Field Properties
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
# Make a field mandatory
make_property_setter(
"Sales Invoice", # doctype
"customer", # fieldname
"reqd", # property
1, # value
"Check" # property_type
)
# Change field default
make_property_setter(
"Sales Invoice",
"posting_date",
"default",
"Today",
"Text"
)
# Change a DocType-level property (not a field)
make_property_setter(
"Sales Invoice",
"", # empty string for DocType-level
"allow_rename",
1,
"Check",
for_doctype=True # IMPORTANT: set True for DocType properties
)Signature:
def make_property_setter(
doctype: str, # Target DocType
fieldname: str, # Field name (empty string for DocType-level)
property: str, # Property to change
value, # New value
property_type: str, # Value data type ("Check", "Text", "Data", etc.)
for_doctype=False, # True = DocType property, False = field property
validate_fields_for_doctype=True, # Validate after setting
is_system_generated=True # Mark as system-generated
)Common property_type values:
| property_type | Use for |
|---|---|
| "Check" | Boolean properties (reqd, hidden, read_only, etc.) |
| "Text" | String properties (default, description, options) |
| "Data" | Short string properties (label, fieldname) |
| "Int" | Integer properties (precision, columns) |
| "Select" | Select properties (fieldtype changes) |
| "Small Text" | Multi-line text properties |
Common Property Setter Use Cases
# Hide a field
make_property_setter("Sales Invoice", "tax_id", "hidden", 1, "Check")
# Change field label
make_property_setter("Sales Invoice", "customer", "label", "Client", "Data")
# Change select options
make_property_setter("Sales Invoice", "status", "options",
"Draft\nUnpaid\nPaid\nCancelled", "Text")
# Set field as read-only
make_property_setter("Sales Invoice", "company", "read_only", 1, "Check")
# Change sort order for DocType
make_property_setter("Sales Invoice", "", "sort_field", "posting_date", "Data",
for_doctype=True)
# Add description/help text
make_property_setter("Sales Invoice", "customer", "description",
"Select the billing customer", "Small Text")Property Setter via Fixtures
# hooks.py
fixtures = [
{
"dt": "Property Setter",
"filters": [["module", "=", "My App"]]
}
]---
Customize Form API (Web UI)
The Customize Form interface at /app/customize-form creates Custom Fields and Property Setters behind the scenes. Programmatic access:
from frappe.custom.doctype.customize_form.customize_form import CustomizeForm
cf = CustomizeForm()
cf.doc_type = "Sales Invoice"
cf.run_method("fetch_to_customize")
# Modify properties
for field in cf.get("fields"):
if field.fieldname == "customer":
field.reqd = 0
cf.run_method("save_customization")- NEVER use Customize Form API in production code -- use
create_custom_fields()andmake_property_setter()instead. - Customize Form is designed for interactive (UI) customization.
---
extend_doctype_class (v16+)
Add methods to existing DocType controllers without replacing them:
# hooks.py
extend_doctype_class = {
"Sales Invoice": "my_app.overrides.sales_invoice.CustomSalesInvoice"
}# my_app/overrides/sales_invoice.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def custom_validation(self):
# Additional validation logic
if self.total < 0:
frappe.throw("Total cannot be negative")- Available in Frappe v16+.
- Multiple apps can extend the same DocType.
- ALWAYS inherit from the original controller class.
---
Rules for Customization
1. ALWAYS prefix custom fieldnames with custom_ to avoid conflicts with core fields. 2. ALWAYS use insert_after to control field position -- fields without it appear at the end. 3. NEVER modify core DocType JSON files directly -- use Custom Fields and Property Setters. 4. ALWAYS use fixtures for distributing customizations with your app. 5. ALWAYS test customizations with bench migrate to ensure they apply cleanly. 6. NEVER use make_property_setter() to change fieldtype on fields with existing data -- it may cause data loss. 7. Property Setters bypass permissions -- ALWAYS use them in controlled contexts (setup, hooks).
Data Masking (Frappe v16+)
Field-level data masking in Frappe hides sensitive values from users who lack the appropriate permission level. This is a server-enforced privacy feature -- masked values are replaced before they reach the client.
Core Concept
A DocField with mask=1 requires explicit mask permission at the field's permlevel for a user to see the real value. Users without that permission see obfuscated placeholders (e.g. XXXXXXXX).
Key principle: The Administrator role ALWAYS sees unmasked values. Masking NEVER applies to Administrator.
Enabling Masking on a Field
Set the mask property on a DocField in the DocType JSON:
{
"fieldname": "phone",
"fieldtype": "Data",
"options": "Phone",
"label": "Phone",
"mask": 1,
"permlevel": 1
}Then grant mask permission at that permlevel to roles that need to see the real value:
| Role | permlevel | mask permission |
|---|---|---|
| HR Manager | 1 | Yes (sees real value) |
| Employee | 1 | No (sees 042XXXXXX) |
How Masking Is Resolved
The Meta.get_masked_fields() method determines which fields are masked for the current user:
# frappe/model/meta.py -- simplified logic
def get_masked_fields(self):
if frappe.session.user == "Administrator":
return [] # Administrator NEVER sees masked values
masked_fields = []
for df in self.fields:
if df.get("mask") and not self.has_permlevel_access_to(
fieldname=df.fieldname, df=df, permission_type="mask"
):
df_copy = copy.deepcopy(df)
df_copy.mask_readonly = 1
masked_fields.append(df_copy)
return masked_fieldsResults are cached per user + DocType combination: masked_fields::{doctype}::{user}.
Masking Patterns by Fieldtype
The frappe.model.utils.mask module applies type-aware obfuscation:
| Fieldtype + Options | Input | Masked Output |
|---|---|---|
| Data (Phone) | +31612345678 | +31XXXXXX (first 3 chars + XXXXXX) |
| Data (Email) | user@example.com | XXXXXX@example.com (domain preserved) |
| Date | 2024-03-15 | XX-XX-XXXX |
| Time | 14:30:00 | XX:XX |
| All others | any value | XXXXXXXX |
| Empty/None | None or "" | Returned as-is |
Where Masking Is Applied
Masking is enforced at multiple layers:
| Layer | Module | How |
|---|---|---|
| Document load | frappe.model.document | get_masked_fields() check on load_from_db and as_dict |
| Report Builder | frappe.model.db_query | get_masked_fields() applied to query results |
| Query Builder | frappe.query_builder.utils | Masked fields replaced after query execution |
| Script Reports | frappe.desk.query_report | Column-level masking using ref_doctype_meta.get_masked_fields() |
| Form view (JS) | frappe/form/form.js | Masked fields rendered read-only with placeholder |
| List view (JS) | frappe/list/list_view.js | Masked fields are non-filterable, non-clickable |
| Form meta (JS) | frappe/desk/form/meta.py | masked_fields list sent with meta for client-side rendering |
Utility Functions
from frappe.model.utils.mask import mask_field_value, mask_dict_results, mask_list_results
# Mask a single value
masked = mask_field_value(field_df, "user@example.com")
# → "XXXXXX@example.com"
# Mask all sensitive fields in dict-based query results
results = [{"name": "EMP-001", "phone": "+31612345678"}]
masked_results = mask_dict_results(results, masked_fields)
# Mask tuple-based results (with field index map)
results = [("EMP-001", "+31612345678")]
field_map = {"phone": 1}
masked_results = mask_list_results(results, masked_fields, field_map)GDPR / Privacy Use Cases
| Scenario | Implementation |
|---|---|
| Employee personal data | Set mask=1 on phone, email, address fields at permlevel=1; grant mask to HR Manager only |
| Customer PII | Mask contact details; grant mask permission to Account Manager role |
| Financial data | Mask salary, bank details at elevated permlevel |
| Audit compliance | Masked fields are read-only in the UI (mask_readonly=1), preventing accidental edits |
Critical Rules
1. ALWAYS set permlevel > 0 on masked fields -- masking at permlevel=0 has no practical effect since most roles have level-0 access. 2. NEVER rely on client-side masking alone -- the server masks values before sending them. 3. ALWAYS grant mask permission explicitly via Role Permission for DocType at the correct permlevel. 4. NEVER assume masked values are encrypted -- they are obfuscated for display only. The real values remain in the database. 5. ALWAYS clear cache after changing mask permissions: frappe.cache.delete_value(f"masked_fields::{doctype}::{user}").
Source Files
| File | Purpose |
|---|---|
frappe/model/utils/mask.py | mask_field_value, mask_dict_results, mask_list_results |
frappe/model/meta.py | Meta.get_masked_fields() -- permission-aware field resolution |
frappe/model/db_query.py | Report Builder masking integration |
frappe/query_builder/utils.py | Query Builder masking integration |
frappe/desk/form/meta.py | Sends masked_fields list to client |
frappe/model/document.py | Document-level masking on load and serialize |
DocType JSON Examples
Real-world examples of DocType JSON definitions for all DocType types.
Standard DocType (Multi-Record)
{
"name": "Project Task",
"module": "Projects",
"naming_rule": "Expression",
"autoname": "PTASK-.#####",
"title_field": "subject",
"search_fields": "subject, project",
"show_title_field_in_link": 1,
"is_submittable": 0,
"track_changes": 1,
"allow_rename": 0,
"allow_import": 1,
"sort_field": "modified",
"sort_order": "DESC",
"fields": [
{
"fieldname": "subject",
"fieldtype": "Data",
"label": "Subject",
"reqd": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"search_index": 1
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project",
"reqd": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"search_index": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Open\nWorking\nCompleted\nCancelled",
"default": "Open",
"in_list_view": 1,
"in_standard_filter": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "assigned_to",
"fieldtype": "Link",
"label": "Assigned To",
"options": "User",
"in_standard_filter": 1
},
{
"fieldname": "due_date",
"fieldtype": "Date",
"label": "Due Date"
},
{
"fieldname": "section_details",
"fieldtype": "Section Break",
"label": "Details"
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
}
],
"permissions": [
{
"role": "Projects User",
"read": 1,
"write": 1,
"create": 1,
"delete": 1
},
{
"role": "Projects Manager",
"read": 1,
"write": 1,
"create": 1,
"delete": 1,
"export": 1,
"import": 1
}
]
}Submittable DocType
{
"name": "Payment Entry",
"module": "Accounts",
"naming_rule": "By \"Naming Series\" field",
"autoname": "naming_series:",
"is_submittable": 1,
"title_field": "title",
"search_fields": "party, paid_amount, payment_type",
"track_changes": 1,
"fields": [
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
"options": "PAY-.YYYY.-.#####\nREC-.YYYY.-.#####",
"reqd": 1,
"default": "PAY-.YYYY.-.#####"
},
{
"fieldname": "payment_type",
"fieldtype": "Select",
"label": "Payment Type",
"options": "Receive\nPay\nInternal Transfer",
"reqd": 1,
"in_standard_filter": 1
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
"label": "Posting Date",
"reqd": 1,
"default": "Today"
},
{
"fieldname": "section_party",
"fieldtype": "Section Break",
"label": "Party"
},
{
"fieldname": "party_type",
"fieldtype": "Select",
"label": "Party Type",
"options": "\nCustomer\nSupplier\nEmployee",
"in_standard_filter": 1
},
{
"fieldname": "party",
"fieldtype": "Dynamic Link",
"label": "Party",
"options": "party_type",
"in_standard_filter": 1,
"search_index": 1
},
{
"fieldname": "party_name",
"fieldtype": "Data",
"label": "Party Name",
"fetch_from": "party.name",
"read_only": 1
},
{
"fieldname": "column_break_party",
"fieldtype": "Column Break"
},
{
"fieldname": "paid_amount",
"fieldtype": "Currency",
"label": "Paid Amount",
"options": "paid_currency",
"reqd": 1,
"in_list_view": 1
},
{
"fieldname": "paid_currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency",
"reqd": 1
},
{
"fieldname": "section_references",
"fieldtype": "Section Break",
"label": "References"
},
{
"fieldname": "references",
"fieldtype": "Table",
"label": "Payment References",
"options": "Payment Entry Reference",
"allow_on_submit": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"options": "Payment Entry",
"read_only": 1,
"no_copy": 1
}
]
}Key points for submittable DocTypes:
- ALWAYS include
amended_fromfield (Link to self, read_only, no_copy). - Use
allow_on_submit=1on fields that should be editable after submission. - The
docstatusfield is auto-managed: 0=Draft, 1=Submitted, 2=Cancelled.
Child DocType (istable=1)
{
"name": "Payment Entry Reference",
"module": "Accounts",
"istable": 1,
"fields": [
{
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Type",
"options": "DocType",
"reqd": 1,
"in_list_view": 1
},
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"label": "Name",
"options": "reference_doctype",
"reqd": 1,
"in_list_view": 1
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"label": "Allocated",
"reqd": 1,
"in_list_view": 1,
"columns": 2
}
]
}Key points for child DocTypes:
- ALWAYS set
istable=1. - NEVER add naming_rule -- child docs use hash naming.
- NEVER add permissions -- they inherit from the parent.
- Set
in_list_view=1on fields to show in the grid. - Use
columnsto control grid column width (1-10).
Single DocType (Settings)
{
"name": "Notification Settings",
"module": "Core",
"issingle": 1,
"fields": [
{
"fieldname": "enable_email",
"fieldtype": "Check",
"label": "Enable Email Notifications",
"default": 1
},
{
"fieldname": "sender_email",
"fieldtype": "Data",
"label": "Sender Email",
"options": "Email",
"depends_on": "eval:doc.enable_email",
"mandatory_depends_on": "eval:doc.enable_email"
},
{
"fieldname": "section_defaults",
"fieldtype": "Section Break",
"label": "Defaults",
"collapsible": 1
},
{
"fieldname": "default_currency",
"fieldtype": "Link",
"label": "Default Currency",
"options": "Currency"
}
],
"permissions": [
{
"role": "System Manager",
"read": 1,
"write": 1,
"create": 1
}
]
}Key points for Single DocTypes:
- ALWAYS set
issingle=1. - Data stored in
tabSinglesas key-value rows, NOT a dedicated table. - No list view -- only a form view at
/app/{doctype-slug}. - ALWAYS restrict permissions to admin roles (System Manager).
Tree DocType
{
"name": "Department",
"module": "HR",
"is_tree": 1,
"nsm_parent_field": "parent_department",
"naming_rule": "Set by User",
"allow_rename": 1,
"title_field": "name",
"fields": [
{
"fieldname": "parent_department",
"fieldtype": "Link",
"label": "Parent Department",
"options": "Department",
"in_standard_filter": 1
},
{
"fieldname": "is_group",
"fieldtype": "Check",
"label": "Is Group",
"default": 0
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"reqd": 1,
"in_standard_filter": 1
},
{
"fieldname": "lft",
"fieldtype": "Int",
"label": "Left",
"hidden": 1,
"search_index": 1
},
{
"fieldname": "rgt",
"fieldtype": "Int",
"label": "Right",
"hidden": 1,
"search_index": 1
},
{
"fieldname": "old_parent",
"fieldtype": "Data",
"label": "Old Parent",
"hidden": 1
}
]
}Key points for Tree DocTypes:
- ALWAYS set
is_tree=1. - ALWAYS define
nsm_parent_fieldpointing to the self-referencing Link field. - The
lft,rgt,old_parentfields are auto-managed -- include them hidden. is_groupfield distinguishes leaf nodes from group nodes.- NEVER manually modify
lft/rgtvalues.
Virtual DocType
{
"name": "External API Record",
"module": "Integrations",
"is_virtual": 1,
"fields": [
{
"fieldname": "external_id",
"fieldtype": "Data",
"label": "External ID",
"in_list_view": 1,
"reqd": 1
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"in_list_view": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Active\nInactive",
"in_list_view": 1,
"in_standard_filter": 1
},
{
"fieldname": "payload",
"fieldtype": "JSON",
"label": "Raw Data"
}
]
}Corresponding controller (REQUIRED):
# external_api_record.py
import frappe
from frappe.model.document import Document
class ExternalAPIRecord(Document):
@staticmethod
def get_list(args):
# Fetch from external API
response = call_external_api("/records", params=args)
return [
frappe._dict(
name=r["id"],
external_id=r["id"],
title=r["title"],
status=r["status"]
)
for r in response["data"]
]
@staticmethod
def get_count(args):
response = call_external_api("/records/count", params=args)
return response["count"]
@staticmethod
def get_stats(args):
return {}
def db_insert(self, *args, **kwargs):
call_external_api("/records", method="POST", data=self.as_dict())
def load_from_db(self):
response = call_external_api(f"/records/{self.name}")
for key, value in response.items():
self.set(key, value)
def db_update(self, *args, **kwargs):
call_external_api(f"/records/{self.name}", method="PUT", data=self.as_dict())
def delete(self):
call_external_api(f"/records/{self.name}", method="DELETE")Key points for Virtual DocTypes:
- ALWAYS implement ALL 7 methods (get_list, get_count, get_stats, db_insert, load_from_db, db_update, delete).
- NEVER use
frappe.db.*for Virtual DocType data queries. - The
/api/resourceendpoints work automatically with Virtual DocTypes.
Table MultiSelect Example
Child DocType:
{
"name": "Project User",
"module": "Projects",
"istable": 1,
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User",
"in_list_view": 1,
"reqd": 1
}
]
}Parent field:
{
"fieldname": "users",
"fieldtype": "Table MultiSelect",
"label": "Project Members",
"options": "Project User"
}- The child DocType for Table MultiSelect ALWAYS has exactly one Link field.
- UI renders as a tag/pill selector instead of a grid.
Fieldtype Reference
Complete reference for all Frappe fieldtypes. Organized by category.
Data Entry Fields
| Fieldtype | Stores | DB Column | options Field |
|---|---|---|---|
| Data | Text, max 140 chars | VARCHAR(140) | Validation: "Name", "Email", "Phone", "URL", "Barcode", "IBAN" |
| Small Text | Short multi-line text | TEXT | _(none)_ |
| Text | Multi-line text | LONGTEXT | _(none)_ |
| Long Text | Unlimited text | LONGTEXT | _(none)_ |
| Text Editor | Rich text (WYSIWYG HTML) | LONGTEXT | _(none)_ |
| Markdown Editor | Markdown with preview | LONGTEXT | _(none)_ |
| HTML Editor | Raw HTML editing | LONGTEXT | _(none)_ |
| Code | Code with syntax highlighting | LONGTEXT | Language: "Python", "PythonExpression", "JavaScript", "HTML", "CSS", "JSON", "Jinja" |
| Password | Encrypted sensitive data | VARCHAR(140) | _(none)_ |
| Read Only | Non-editable display field | VARCHAR(140) | _(none)_ |
| Phone | Phone number input | VARCHAR(140) | _(none)_ -- Data subtype with phone formatting |
| Autocomplete | Text with autocomplete | VARCHAR(140) | _(none)_ |
Code Field Extra Properties
| Property | Purpose |
|---|---|
options | Syntax highlighting language |
wrap | Enable text wrapping (bool) |
max_lines | Maximum editor height |
min_lines | Minimum editor height |
Numeric Fields
| Fieldtype | Stores | DB Column | options Field |
|---|---|---|---|
| Int | Whole number | INT(11) | _(none)_ |
| Float | Decimal number (up to 9 places) | DECIMAL(21,9) | _(none)_ |
| Currency | Money value (up to 6 decimals) | DECIMAL(21,9) | Currency field name for symbol display |
| Percent | Percentage value | DECIMAL(21,9) | _(none)_ |
| Rating | Star rating (0-1 stored) | DECIMAL(21,9) | Number 3-10 for star count; supports half ratings |
| Duration | Time duration | DECIMAL(21,9) | "Hide Days", "Hide Seconds" toggles |
Currency Field options Behavior
- If
optionspoints to another field (e.g.currency), the value of that field determines the currency symbol displayed. - If
optionsis empty, the system default currency is used. - ALWAYS set
optionson Currency fields to display the correct symbol.
Date and Time Fields
| Fieldtype | Stores | DB Column | options Field |
|---|---|---|---|
| Date | Calendar date | DATE | _(none)_ |
| Datetime | Date + time | DATETIME(6) | _(none)_ |
| Time | Time only | TIME(6) | _(none)_ |
Relationship Fields
| Fieldtype | Stores | DB Column | options Field |
|---|---|---|---|
| Link | FK to another DocType | VARCHAR(140) | Target DocType name (REQUIRED) |
| Dynamic Link | FK to any DocType | VARCHAR(140) | Fieldname containing the target DocType |
| Table | Child table rows | _(separate table)_ | Child DocType name (REQUIRED, must have istable=1) |
| Table MultiSelect | Multi-select link rows | _(separate table)_ | Child DocType name (child must have a single Link field) |
Link Field Behavior
- Displays a search input with autocomplete.
optionsMUST be the exact DocType name (case-sensitive).- ALWAYS ensure the target DocType exists before adding a Link field.
Dynamic Link Behavior
optionspoints to ANOTHER field (Select or Data) in the same DocType.- That field holds the DocType name at runtime.
- Example: field
party_type(Select with "Customer\nSupplier") + fieldparty(Dynamic Link withoptions=party_type). - ALWAYS pair a Dynamic Link with a corresponding type-selector field.
Table MultiSelect vs Table
- Table MultiSelect child DocType typically has ONE Link field.
- UI renders as a tag/pill selector instead of a full grid.
- NEVER use Table MultiSelect for child DocTypes with many editable fields.
Selection Fields
| Fieldtype | Stores | DB Column | options Field |
|---|---|---|---|
| Select | One choice from dropdown | VARCHAR(140) | Newline-separated values (first line = default if no default set) |
| Check | Boolean (0 or 1) | TINYINT(1) | _(none)_ -- set default=1 for checked by default |
Select Field Options Format
Draft
Submitted
Cancelled- First option is shown by default unless
defaultis set. - NEVER include blank line unless you want an empty option.
- Options are stored as the string value, not an index.
File and Media Fields
| Fieldtype | Stores | DB Column | options Field |
|---|---|---|---|
| Attach | File path/URL | VARCHAR(140) | _(none)_ |
| Attach Image | Image file path/URL | VARCHAR(140) | _(none)_ |
| Signature | Base64 signature data | LONGTEXT | _(none)_ |
| Image | Display-only image | _(no storage)_ | Fieldname of Attach field to display |
| Barcode | Barcode data | LONGTEXT | _(none)_ |
Image vs Attach Image
Attach Imagestores a file and shows upload widget.Imageis DISPLAY ONLY -- it renders the image from another Attach field.Imagefieldoptions= the fieldname of the Attach/Attach Image field.
Special Data Fields
| Fieldtype | Stores | DB Column | options Field |
|---|---|---|---|
| Color | Hex color string | VARCHAR(140) | _(none)_ |
| Geolocation | GeoJSON feature collection | LONGTEXT | _(none)_ |
| JSON | Arbitrary JSON | JSON / LONGTEXT | _(none)_ |
| Icon | Icon name string | VARCHAR(140) | _(none)_ |
Layout and UI Fields (No Data Storage)
| Fieldtype | Purpose | options Field |
|---|---|---|
| Section Break | Horizontal section divider | Label text (optional) |
| Column Break | Multi-column layout within section | _(none)_ |
| Tab Break | Form tab divider | Tab label |
| Heading | Display heading text | _(none)_ |
| HTML | Render static HTML content | HTML content string |
| Button | Actionable button | _(none)_ |
Tab Break Rules
- If the first field is NOT a Tab Break, Frappe auto-creates a "Details" tab.
- ALWAYS start with a Tab Break if you want custom tab naming from the first tab.
- Each Tab Break starts a new tab; all fields until the next Tab Break belong to it.
Button Field Properties
| Property | Purpose |
|---|---|
options | _(none)_ |
btn_size | "xs", "sm", "lg" |
- Button clicks are handled via client script (
frappe.ui.form.on). - Buttons store NO data in the database.
Fieldtype Selection Guide
What kind of data?
├─ Text?
│ ├─ Short (< 140 chars) → Data
│ ├─ Formatted/Rich → Text Editor
│ ├─ Code → Code (set options to language)
│ ├─ Multi-line plain → Small Text or Text
│ └─ Very long → Long Text
├─ Number?
│ ├─ Whole number → Int
│ ├─ Decimal → Float
│ ├─ Money → Currency
│ └─ Percentage → Percent
├─ Date/Time?
│ ├─ Date only → Date
│ ├─ Date + Time → Datetime
│ └─ Time only → Time
├─ Reference to another record?
│ ├─ Fixed DocType → Link
│ ├─ Variable DocType → Dynamic Link
│ ├─ Multiple rows → Table
│ └─ Multiple selections → Table MultiSelect
├─ Yes/No → Check
├─ One of several options → Select
├─ File upload → Attach (or Attach Image for images)
└─ Layout only → Section Break / Column Break / Tab BreakNaming Rules Reference
Complete reference for all DocType naming methods in Frappe.
Overview
Every document in Frappe has a name field -- the primary key. The naming_rule property on the DocType controls how name is generated.
All Naming Methods
1. Set by User
{ "naming_rule": "Set by User" }- User types the name manually on document creation.
- Name becomes the primary key and CANNOT be changed unless
allow_rename=1. - ALWAYS validate uniqueness -- Frappe raises
DuplicateEntryErroron collision.
2. Autoincrement
{ "naming_rule": "Autoincrement" }- Sequential integers:
1,2,3, ... - NEVER use in production -- deleted records leave gaps, names are not meaningful.
- NEVER switch naming scheme once documents exist (data corruption risk).
3. By Fieldname
{
"naming_rule": "By fieldname",
"autoname": "field:employee_name"
}- Uses the value of the specified field as the document name.
- The field value MUST be unique across all documents of this type.
- ALWAYS set
unique=1on the source field. - Good for: code-based records (item_code, currency abbreviation).
4. By Naming Series Field
{
"naming_rule": "By \"Naming Series\" field",
"autoname": "naming_series:"
}- Requires a
naming_seriesfield (Select type) on the DocType. - Each option in the Select field is a pattern:
INV-.YYYY.-.##### - Users choose which series to use per document.
- ALWAYS include
.#####(or more hashes) for the auto-increment portion.
Series pattern syntax:
| Token | Meaning | Example |
|---|---|---|
.#### | Zero-padded counter | 0001, 0002 |
.YYYY. | 4-digit year | 2024 |
.YY. | 2-digit year | 24 |
.MM. | 2-digit month | 01-12 |
.DD. | 2-digit day | 01-31 |
{fieldname} | Field value | Dynamic prefix |
Example series options:
INV-.YYYY.-.#####
CN-.YYYY.-.#####
DN-.YYYY.-.#####Output: INV-2024-00001, CN-2024-00001
5. Expression (Current Style)
{
"naming_rule": "Expression",
"autoname": "PRE-.#####"
}- Fixed prefix with auto-incrementing padded number.
- The
#count determines zero-padding width. - ALWAYS use at least 5 hashes (
#####) for production systems.
Examples:
| autoname | Output |
|---|---|
PRE-.##### | PRE-00001 |
INV-.YYYY.-.###### | INV-2024-000001 |
HR-EMP-.#### | HR-EMP-0001 |
6. Expression (Old Style) -- Deprecated in v16
{
"naming_rule": "Expression (old style)",
"autoname": "EXAMPLE-{MM}-{fieldname1}-{#####}"
}Supported tokens:
| Token | Meaning |
|---|---|
{YYYY} | 4-digit year |
{YY} | 2-digit year |
{MM} | Month (01-12) |
{DD} | Day (01-31) |
{fieldname} | Value of a document field |
{#####} | Auto-increment counter |
| Static text | Included literally |
- NEVER use in new v15+ projects -- migrate to Expression or Naming Series.
- Will be removed in Frappe v16.
7. Random (Hash)
{
"naming_rule": "Random",
"autoname": "hash"
}- Generates a random 10-character alphanumeric string.
- Good for: records where the name has no business meaning.
- NEVER use if users need to reference records by name.
8. UUID
{
"naming_rule": "UUID"
}- Standard UUID v4 format:
550e8400-e29b-41d4-a716-446655440000. - Available in Frappe v15+.
- Good for: API-first systems, external system integration.
9. By Script (Controller autoname)
{
"naming_rule": "By script"
}Implement autoname() in the controller:
# my_doctype.py
class MyDocType(Document):
def autoname(self):
prefix = f"P-{self.customer}-"
self.name = make_autoname(prefix + ".#####")Utility functions:
from frappe.model.naming import make_autoname, getseries
# make_autoname: parse pattern and generate name
name = make_autoname("INV-.YYYY.-.#####")
# getseries: get next number in a series
name = getseries("INV-2024-", 5) # Returns "INV-2024-00042" (next in sequence)- The controller
autoname()method takes PRIORITY over the DocTypenaming_rule. - ALWAYS use
make_autoname()orgetseries()-- NEVER construct names with raw SQL counters.
Document Naming Rules (Dynamic)
Frappe v14+ supports "Document Naming Rule" DocType for rule-based naming:
# Managed via Document Naming Rule DocType, not code
# Fields: priority, conditions (filters), prefix, digits- Higher
priorityvalue = applied first. - Conditions filter which documents get which naming pattern.
- Overrides the DocType's default naming_rule.
- NEVER use for Child DocTypes -- they use random hash naming.
Priority Order
1. Document Naming Rule (if conditions match) 2. Controller `autoname()` method (if defined) 3. DocType `naming_rule` / `autoname` (default)
Amended Document Names
When a submitted document is amended:
- Original:
INV-2024-00001 - First amendment:
INV-2024-00001-1 - Second amendment:
INV-2024-00001-2
NEVER override this behavior -- it maintains the audit trail for submittable documents.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Using Autoincrement in production | Gaps after deletes, no meaning | Use Expression with prefix |
Too few hashes (##) | Overflow at 100 records | Use at least ##### |
field: on non-unique field | DuplicateEntryError on insert | Add unique=1 to the field |
| Changing naming scheme after data exists | Inconsistent names | Plan naming before go-live |
| Expression (Old Style) in v15+ | Will break in v16 | Migrate to Expression |
Python Type Stubs for Frappe
The frappe.types module provides Python type hints for DocField values and common Frappe data structures. These types enable IDE autocompletion, static analysis, and self-documenting controllers.
Module Structure
frappe/types/
├── __init__.py # Re-exports: Filters, FilterSignature, FilterTuple, _dict
├── DF.py # DocField type aliases (32 types)
├── filter.py # Filter types: FilterTuple, Filters, FilterSignature
├── frappedict.py # _dict class (attribute-access dict)
├── exporter.py # TypeExporter -- auto-generates type stubs in controllers
└── lazytranslatedstring.py # _LazyTranslate for deferred translationsDF Module -- DocField Type Aliases
frappe.types.DF maps every Frappe fieldtype to a Python type:
| DF Type | Python Type | Fieldtype |
|---|---|---|
DF.Data | str | Data, Autocomplete, Attach, AttachImage, Barcode, Color, Link, DynamicLink, Password, Phone, ReadOnly |
DF.Text | str | Text, Code, HTMLEditor, JSON, LongText, MarkdownEditor, SmallText, TextEditor |
DF.Int | int | Int |
DF.Float | float | Float |
DF.Currency | float | Currency |
DF.Percent | float | Percent |
DF.Rating | float | Rating |
DF.Check | `bool \ | int` |
DF.Duration | int | Duration (seconds) |
DF.Date | `str \ | date` |
DF.Datetime | `str \ | datetime` |
DF.Time | `str \ | time` |
DF.Select | Literal | Select (parameterized with options) |
DF.Table | list | Table (parameterized with child type) |
DF.TableMultiSelect | list | Table MultiSelect |
Auto-Generated Type Stubs (TypeExporter)
Frappe automatically generates type annotations in controller files when a DocType schema is saved. The TypeExporter class in frappe/types/exporter.py handles this.
Generated Code Block
The exporter inserts a guarded block in the controller:
class SalesInvoice(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.stock.doctype.sales_invoice_item.sales_invoice_item import SalesInvoiceItem
company: DF.Link
customer: DF.Link
customer_name: DF.Data | None
grand_total: DF.Currency
is_return: DF.Check
items: DF.Table[SalesInvoiceItem]
posting_date: DF.Date
status: DF.Literal["Draft", "Submitted", "Paid", "Cancelled"]
# end: auto-generated typesKey Behaviors
- Trigger: Runs on DocType save (schema update)
- Location: Inserts into the controller
.pyfile between# begin: auto-generated typesand# end: auto-generated types - Idempotent: Replaces existing block on re-export; adds after class definition if block is absent
- Validation: Parses generated code with
ast.parse()before writing -- NEVER writes invalid Python - Indentation: Auto-detects tabs vs spaces from the controller file
Nullable vs Non-Nullable
Fields that are NOT nullable (NEVER | None):
Check,Currency,Float,Int,Percent,Rating-- numeric defaults to 0Select-- defaults to first optionTable,Table MultiSelect-- defaults to empty list- Fields with
reqd=1ornot_nullable=1
All other fields include | None in their type annotation.
Table Field Parameterization
Table fields include the child DocType's controller class as a generic parameter:
if TYPE_CHECKING:
from erpnext.stock.doctype.sales_invoice_item.sales_invoice_item import SalesInvoiceItem
items: DF.Table[SalesInvoiceItem]Select Field Parameterization
Select fields use DF.Literal with the options list:
status: DF.Literal["Draft", "Submitted", "Paid", "Cancelled"]The TYPE_CHECKING Guard Pattern
ALWAYS use TYPE_CHECKING to prevent runtime import overhead:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DFThis block is NEVER executed at runtime -- it only runs during static analysis (mypy, pyright, IDE indexing). This avoids circular imports and keeps controller startup fast.
Filter Types
The frappe.types.filter module provides type-safe filter construction:
from frappe.types import Filters, FilterTuple, FilterSignature
# FilterTuple -- single filter condition
f = FilterTuple(doctype="Sales Invoice", fieldname="status", operator="=", value="Draft")
# Filters -- collection of FilterTuple objects
filters = Filters([
("Sales Invoice", "status", "=", "Draft"),
("Sales Invoice", "docstatus", "=", 1),
])
# Filters.optimize() -- consolidates equality filters into "in" operator
filters.optimize()FilterSignature Type Alias
FilterSignature accepts multiple input formats:
# All valid FilterSignature inputs:
filters: FilterSignature = Filters([...]) # Filters object
filters: FilterSignature = [("doctype", "field", "=", "v")] # List of tuples
filters: FilterSignature = {"field": "value"} # Dict mapping
filters: FilterSignature = ("field", "=", "value") # Single tupleThe _dict Class
frappe._dict (re-exported from frappe.types) enables attribute-style access on dicts:
from frappe import _dict
d = _dict(name="INV-001", status="Draft")
print(d.name) # "INV-001" -- attribute access
print(d["name"]) # "INV-001" -- dict access
d.update(total=500) # returns self (chainable)__getattr__delegates todict.get(returnsNonefor missing keys, never raisesAttributeError)update()returnsselffor method chaining- NEVER use
hasattr()to check key existence on_dict-- it always returnsTrue. Usekey in dinstead.
IDE Integration
mypy Configuration
# mypy.ini or pyproject.toml [tool.mypy]
[mypy]
plugins = []
ignore_missing_imports = truemypy recognizes the TYPE_CHECKING guard and processes DF.* annotations during analysis.
pyright / Pylance (VS Code)
pyright natively understands TYPE_CHECKING blocks. The auto-generated stubs provide:
- Field name autocompletion on
self.fieldname - Type checking on assignments (
self.grand_total = "wrong"flags as error) - Go-to-definition on child table types
Limitations
- Type stubs are generated per-DocType, NOT globally. Custom DocTypes need a schema save to generate stubs.
frappe.get_doc()returnsDocumentby default -- callers outside the controller do NOT get typed fields unless explicitly annotated.- Customizations (Custom Fields, Property Setters) are NOT reflected in auto-generated stubs.
Typing Patterns for Custom Code
Pattern 1: Typed Controller Method
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
class MyDocType(Document):
# auto-generated types here...
def validate(self):
# self.fieldname is now typed
if self.status == "Draft":
self.grand_total = sum(row.amount for row in self.items)Pattern 2: External Code with Explicit Annotation
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from myapp.doctype.my_doctype.my_doctype import MyDocType
def process_doc(name: str) -> None:
doc: "MyDocType" = frappe.get_doc("My DocType", name) # type: ignore
print(doc.grand_total) # IDE knows this is DF.Currency (float)Pattern 3: Whitelisted API with Return Type
import frappe
from frappe import _dict
@frappe.whitelist()
def get_summary(doctype: str, name: str) -> _dict:
doc = frappe.get_doc(doctype, name)
return _dict(
name=doc.name,
status=doc.status,
total=doc.grand_total,
)Critical Rules
1. NEVER modify code between # begin: auto-generated types and # end: auto-generated types -- it will be overwritten on next DocType save. 2. ALWAYS use the TYPE_CHECKING guard for DF imports -- importing at runtime wastes startup time. 3. NEVER assume frappe.get_doc() returns a typed controller outside the controller file itself -- annotate explicitly. 4. ALWAYS re-save the DocType in the UI (or run bench migrate) after adding fields to regenerate type stubs. 5. NEVER use hasattr() on frappe._dict to check for keys -- use key in d or d.get(key).
Source Files
| File | Purpose |
|---|---|
frappe/types/__init__.py | Package exports: Filters, FilterSignature, FilterTuple, _dict |
frappe/types/DF.py | 32 DocField type aliases |
frappe/types/filter.py | FilterTuple, Filters, FilterSignature types |
frappe/types/frappedict.py | _dict class with attribute access |
frappe/types/exporter.py | TypeExporter -- auto-generates stubs in controllers |
frappe/types/lazytranslatedstring.py | _LazyTranslate for deferred i18n |