# How to create a custom DocType in ERPNext

Canonical: https://erpfly.com/blog/create-custom-doctype-erpnext/
Last updated: September 12, 2026

Published February 10, 2026 in ERPNext. Step by step: developer mode, bench new-app, fields, naming, permissions, controller methods and fixtures, plus the DocType mistakes we see most.

To create a custom DocType in ERPNext properly, turn on developer mode, create a Frappe app with `bench new-app`, install it on your site, then create the DocType in the desk with its **Module** set to your app's module and **Custom** left unticked. Frappe writes the JSON definition and a Python controller into your app folder, and you commit those files to Git. Skip the app and the DocType lives only in one site's database, which is where most of the pain starts.

Below is the full sequence as we do it on v15 and v16 benches, with the mistakes that cost teams the most time.

### Step 1: Turn on developer mode

Without developer mode, Frappe won't write DocType files to disk. Everything you build stays in the database, and you can't move it to another site except by hand.

```bash
bench --site dev.localhost set-config developer_mode 1
bench --site dev.localhost clear-cache
```

If every site on the bench is a development site, set it globally instead with `bench set-config -g developer_mode 1`. Don't do this on production. A production site should receive DocTypes through `bench migrate`, never create them.

### Step 2: Create and install an app

A DocType has to belong to a module, and a module has to belong to an app. You could put it in an existing module like Selling, but that writes your files into the `erpnext` app, and the next update wipes them out. Make your own app.

```bash
cd ~/frappe-bench
bench new-app library_management
bench --site dev.localhost install-app library_management
```

`bench new-app` asks a few questions (title, publisher, license and so on). It also creates a module named after the app title, listed in `modules.txt`. That's the module you'll pick in the next step. If your app will hold several unrelated areas, add more lines to `modules.txt` and matching folders, but one module is fine for most apps.

Initialise Git in `apps/library_management` right away. You want the first DocType commit to be a clean diff.

### Step 3: Create the DocType in the desk

Go to **/app/doctype/new**. Fill in:

- **Name**: singular and in Title Case, like `Library Member`. Frappe turns it into a `library_member` folder and a `tabLibrary Member` table. Renaming later is possible but messy, so take a minute over it.
- **Module**: `Library Management`.
- **Custom?**: leave it unticked. Ticked means "store in the database only", which defeats the point of having an app.
- **Is Submittable** if the document gets submitted and cancelled like a Sales Invoice. **Is Child Table** if it only ever lives inside another document. **Is Single** for settings pages with one record.

#### Desk or code?

You can write the JSON by hand, but we don't recommend it for creating a DocType. The desk validates field options, generates a correct JSON structure and creates the controller, JS and test stubs in one go. Where code wins is everything after: controller logic, form scripts, tests and reviewing JSON diffs in pull requests. So create and edit the schema in the desk, write behaviour in your editor.

After saving, look at the app:

```text
apps/library_management/library_management/library_management/doctype/library_member/
├── __init__.py
├── library_member.js
├── library_member.json
├── library_member.py
└── test_library_member.py
```

If those files aren't there, developer mode isn't on for that site, or Custom was ticked.

### Step 4: Add fields that will survive real data

Fields go in the **Fields** table. A few habits save a lot of trouble later:

- Use **Link** fields for anything that points to another record (Customer, Item, Employee). Don't store a customer name in a Data field and hope it stays in sync.
- Tick **Mandatory** only for things that truly must exist on day one. Adding a mandatory field to a DocType that already has records is fine for new saves, but every old record will fail validation the next time someone edits it.
- Pick **Fieldnames** carefully. The label can change any time; the fieldname is a database column and gets referenced in code, reports and print formats.
- Use **Section Break**, **Column Break** and **Tab Break** for layout. They don't create columns in the table.
- Set **In List View** and **In Standard Filter** on the three or four fields people actually search by.

For line items, create a second DocType with **Is Child Table** ticked, then add a **Table** field on the parent pointing to it.

### Step 5: Choose a naming rule

The **Naming Rule** field decides the document `name`, which is its primary key. The common choices:

- **By "Naming Series" field**: add a Select field called `naming_series` with options like `LIB-MEM-.YYYY.-`. Users can pick a series, and it matches how ERPNext names its own documents.
- **Expression**: set **Auto Name** to something like `format:LIB-{YYYY}-{#####}`. No extra field needed.
- **By fieldname**: `field:member_email`. Only use this when the value is truly unique and never changes. If it can change, you'll be renaming documents.
- **Autoincrement**: integer IDs. Good for high-volume log-style records people rarely refer to by name.
- **By script**: define `autoname(self)` in the controller when the name depends on other fields.

We default to a naming series for anything a human will quote on the phone, and autoincrement for everything else.

### Step 6: Set permissions

The **Permissions** table holds one row per role and permission level. Each row ticks Read, Write, Create, Delete, and for submittable DocTypes, Submit, Cancel and Amend. **If Owner** limits the row to documents the user created.

A few rules we follow:

- Give **System Manager** full access so admins aren't locked out.
- Create specific roles (`Librarian`, `Library Member`) rather than reusing broad ones like `Stock User`. If you add custom roles, ship them as fixtures (see step 8).
- Use **permission levels** to hide sensitive fields. Put fields at level 1 and only give some roles a level 1 row.
- For "users only see records for their own branch", use **User Permissions** on the Link field first. Reach for `permission_query_conditions` in `hooks.py` only when User Permissions can't express the rule.

### Step 7: Write the controller

The Python file next to the JSON is the controller. Frappe calls methods on it at points in the document lifecycle. The ones you'll use most:

- `validate`: runs before every insert and save. Put business rules here.
- `before_save` / `before_insert`: set computed values.
- `after_insert` and `on_update`: side effects after the write, like creating a linked record.
- `on_submit` / `on_cancel`: for submittable DocTypes, where ledger-style postings belong.
- `on_trash`: block or clean up on delete.

```python
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import add_days, date_diff, getdate


class LibraryMembership(Document):
    def validate(self):
        if getdate(self.to_date) < getdate(self.from_date):
            frappe.throw(_("To Date can't be before From Date"))
        self.check_overlap()
        self.total_days = date_diff(self.to_date, self.from_date) + 1

    def check_overlap(self):
        clash = frappe.db.exists("Library Membership", {
            "library_member": self.library_member,
            "docstatus": 1,
            "name": ("!=", self.name),
            "from_date": ("<=", self.to_date),
            "to_date": (">=", self.from_date),
        })
        if clash:
            frappe.throw(_("Member already has an active membership: {0}").format(clash))

    @frappe.whitelist()
    def extend_by_days(self, days):
        # callable from the form with frm.call("extend_by_days", {days: 30})
        self.to_date = add_days(self.to_date, int(days))
        self.save()
```

Newer app templates export type annotations, so you may see an auto-generated block of field types at the top of the class. Leave it alone; Frappe rewrites it when the DocType changes.

Keep validation in Python. A rule enforced only in the form's JavaScript is skipped by anyone who uses the REST API or Data Import.

### Step 8: Export customisations as fixtures

The DocType itself is already in your app as JSON. What isn't are things you did to **standard** DocTypes: custom fields on Sales Invoice, property setters from Customize Form, custom roles. Those live in the database until you export them.

Set the Module on each Custom Field and Property Setter to your app's module, then declare fixtures in `hooks.py`:

```python
fixtures = [
    {"dt": "Custom Field", "filters": [["module", "=", "Library Management"]]},
    {"dt": "Property Setter", "filters": [["module", "=", "Library Management"]]},
    {"dt": "Role", "filters": [["name", "in", ["Librarian", "Library Member"]]]},
]
```

```bash
bench --site dev.localhost export-fixtures --app library_management
```

This writes JSON files into `library_management/fixtures/`. They get imported when the app is installed and on every `bench migrate`. Always filter. An unfiltered Custom Field fixture drags in every custom field on the site, including ones from other apps, and you'll fight over them forever.

### Step 9: Deploy to another site

Push the app, then on the target bench:

```bash
bench get-app https://github.com/your-org/library_management
bench --site erp.example.com install-app library_management
# later, after pulling new commits:
bench --site erp.example.com migrate
```

`migrate` syncs the DocType JSON into the database, runs patches and imports fixtures. If you changed a field type or renamed something, test the migration on a copy of production first.

### Mistakes we keep seeing

**Building DocTypes directly on production.** Developer mode is off, so they're created as custom DocTypes in the database. Six months later there are forty of them, no Git history and no way to reproduce the site. Moving them into an app after the fact is doable, but it's a project on its own.

**Picking a standard module.** Setting the Module to `Stock` or `HR` writes files into the ERPNext or HRMS app folder. They work until the next `bench update`, then vanish. We covered how to recover from this in our post on [keeping customisations through an ERPNext upgrade](https://erpfly.com/blog/erpnext-upgrade-keep-customizations/).

**Renaming fieldnames after go-live.** The column changes, but reports, print formats, Server Scripts and saved filters still reference the old name. Change the label, keep the fieldname.

**Editing JSON while the desk is open.** Save the DocType in the browser and your hand edits are overwritten. Pick one place per change.

**Putting logic in Server Scripts instead of the controller.** Fine for a quick patch, bad for anything you need to test. Our [Server Scripts vs custom app comparison](https://erpfly.com/blog/server-scripts-vs-custom-app-erpnext/) goes into where that line sits.

**Forgetting that a child table has no permissions of its own.** Access follows the parent. If you want to restrict who sees line items, restrict the parent or use permission levels.

### If you'd rather skip the boilerplate

Once you've done this a few times, the DocType JSON, permissions and fixtures feel like typing, not thinking. That's the part erpfly generates. You describe the module in plain English and get a Frappe app with DocTypes, controllers and tests as a pull request you can review. See [how ERPNext custom module development works with erpfly](https://erpfly.com/erpnext-custom-module-development/), or if you only need fields and forms tweaked, our [ERPNext customization service](https://erpfly.com/erpnext-customization/). For examples of what a finished module looks like, the [CRM module](https://erpfly.com/modules/crm/) is a good place to start.

### Sources

- [Create a DocType, Frappe Framework tutorial](https://docs.frappe.io/framework/user/en/tutorial/create-a-doctype)
- [Naming, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/basics/doctypes/naming)
- [Controllers, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/basics/doctypes/controllers)
- [Users and Permissions, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/basics/users-and-permissions)
- [Hooks, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/python-api/hooks)