About Experience Engineering Projects Infrastructure Blog Contact
ERPNext & Frappe 🧩 9-Part Course · Part 4: DocTypes & Database

ERPNext DocTypes & Database Relationships

Published: Aug 30, 2026

πŸ—„οΈ DocType vs Database Table vs Document

Part 1's Database Architecture section introduced the core chain β€” a DocType is metadata, the framework generates a MariaDB table from it, and each saved record is one row. This article assumes that chain and goes underneath it: not just that DocTypes become tables, but exactly how, and what "metadata-driven" means literally rather than as a slogan.

DocTypedefined via the DocType editor / Customize Form
↓
tabDocType row + tabDocField rowsthe definition is itself stored as documents
↓
Generated MariaDB tabletab<DocType Name>, one column per field
↓
Document Recordone row = one saved document

The phrase "the schema is data" in Frappe is not a metaphor β€” it is a literal description of the storage model. A DocType's own definition (its name, module, permissions, and settings) is one row in tabDocType, and every field it has β€” label, fieldtype, options, mandatory flag, and so on β€” is a row in tabDocField with parent pointing back at that DocType. In other words, tabDocType and tabDocField are themselves ordinary Frappe tables generated from a DocType (DocType is a DocType), which is why a fresh Frappe install can bootstrap its own schema system from nothing but a handful of seed JSON files.

This has a concrete, mechanical consequence when you add a field through Customize Form in the UI:

Add field in Customize Form
↓
Property Setter or Custom Field record writtenstored in the database, not in source code
↓
Framework recalculates the DocType's effective schema
↓
Next migrate/refresh: ALTER TABLEthe underlying tab<DocType> table gains the column

A brand-new field added to an existing DocType typically writes a Custom Field record; a change to an existing field's label, mandatory flag, or read-only state typically writes a Property Setter record. Either way, on the next site migration (or an immediate refresh in developer mode) the framework compares the DocType's full field list against the live table structure and issues the necessary ALTER TABLE ... ADD COLUMN statements β€” you never hand-write SQL to add a column that a DocType field already describes.

πŸ’‘ Verified vs illustrative in this article. Following the same convention as Part 1's Version & Accuracy Note: the naming rule, the DocType/DocField storage model, and the Property Setter/migrate mechanism described here are stable, long-standing Frappe behavior. Specific table lists and diagrams are illustrative teaching aids β€” verify exact names against your installed version.

🏷️ The tab<DocType> Naming Convention

This is verified, stable Frappe behavior, unchanged across recent versions: every non-child, non-virtual DocType's table name is the literal string tab concatenated with the DocType's name β€” spaces preserved, not converted to underscores or camelCase.

DocType nameGenerated table name
CustomertabCustomer
ItemtabItem
Sales OrdertabSales Order
Sales InvoicetabSales Invoice
GL EntrytabGL Entry
Stock Ledger EntrytabStock Ledger Entry

Because MariaDB identifiers with spaces require backtick-quoting, raw SQL against these tables looks like SELECT * FROM \`tabSales Invoice\` WHERE customer = 'CUST-00001' β€” one more reason the Frappe ORM (frappe.get_all, frappe.get_doc) is preferred over hand-written queries, beyond the permission and validation concerns covered in Common Database-Design Mistakes below.

Naming Series & autoname: how the primary key is generated

Every Frappe document's primary key is its name field β€” not an auto-incrementing integer id. How that name value is produced is controlled by the DocType's autoname setting, which takes one of a few forms:

autoname styleHow the name is builtExample
Naming seriesA pattern with a counter and optional date tokens, chosen at save time if more than one series is configuredSAL-ORD-.YYYY.-.##### β†’ SAL-ORD-2026-00042
Field valuefield:fieldname β€” the name is copied straight from another field on the same documentfield:item_code for Item
PromptThe user types the name directly when creating the documentused for some master/setup DocTypes
Custom hookAn autoname() method on the DocType's controller class computes the name in codecomposite or business-rule-derived names

This matters beyond cosmetics: whatever value ends up in name is exactly what every Link field pointing at that DocType stores as its foreign key. A Sales Order Item.parent value of "SAL-ORD-2026-00042", or a Sales Invoice.customer value of "CUST-00001", is not a numeric surrogate key looked up through a separate id column β€” it is the primary key, and it is a human-readable string by design.

πŸ“š Master Tables vs Transaction Tables (Expanded)

Part 1's table map section introduced this split briefly; here is the fuller comparison, plus the "why" behind it.

Master DocTypeTypeTypical lifecycle
CustomerMasterCreated once, edited occasionally, disabled rather than deleted once referenced by transactions
SupplierMasterCreated once, edited occasionally, disabled not deleted
ItemMasterCreated once per SKU, price/attributes updated over time, disabled when discontinued
WarehouseMasterCreated per physical/logical stock location, rarely changes once in use
AccountMasterSet up as part of the Chart of Accounts, structurally stable, "frozen" rather than deleted
EmployeeMasterCreated at hire, updated through employment, marked "Left" rather than deleted
Cost CenterMasterSet up per department/company, edited rarely
Price ListMasterCreated per price tier/currency, items and rates updated periodically
UOMMasterSet up once (Nos, Kg, Box…), essentially static
TerritoryMasterSet up as part of sales geography, rarely changes
Transaction DocTypeTypeTypical lifecycle
Sales OrderTransactionDraft β†’ Submit β†’ possibly Cancel/Amend; rarely edited after submit
Purchase OrderTransactionDraft β†’ Submit β†’ possibly Cancel/Amend
Sales InvoiceTransactionDraft β†’ Submit β†’ paid/returned; posts to GL Entry on submit
Purchase InvoiceTransactionDraft β†’ Submit β†’ paid; posts to GL Entry on submit
Delivery NoteTransactionDraft β†’ Submit; posts Stock Ledger Entry movements
Purchase ReceiptTransactionDraft β†’ Submit; posts Stock Ledger Entry movements
Payment EntryTransactionDraft β†’ Submit; settles outstanding invoice amounts
Journal EntryTransactionDraft β†’ Submit; direct GL posting for non-invoice accounting events
Stock EntryTransactionDraft β†’ Submit; manual/internal stock movement
Work OrderTransactionDraft β†’ Submit β†’ In Process β†’ Completed; drives manufacturing Stock Entries

Master data exists so that a fact is stored exactly once and every transaction merely references it via a Link field, rather than copying it. A customer's territory, for example, lives once on the Customer record β€” thousands of Sales Orders reference it by customer Link rather than each carrying its own copy of the territory string. This is the normalized-database version of "don't duplicate the fact, reference it," and it's why correcting a customer's address once instantly reflects correctly everywhere that customer is referenced, instead of requiring a bulk update across every historical transaction.

🧬 Child Tables in Depth

Part 1's Child Tables Explained section covered the four framework-managed fields β€” parent, parenttype, parentfield, idx β€” using a Sales Order / Sales Order Item example. The mechanism is identical everywhere it's used; here's a different concrete instance, Purchase Invoice and its line items, to make the pattern recognizable rather than memorized from one example:

tabPurchase Invoice
       β”‚
       β”‚ parent (1) ──── (N) rows
       β–Ό
tabPurchase Invoice Item
  parent      = "ACC-PINV-2026-00017"
  parenttype  = "Purchase Invoice"
  parentfield = "items"
  idx         = 1
  item_code   = "RM-CTRL-BOARD"
  qty         = 50
  rate        = 12.40

Three properties of child tables are worth stating explicitly, because they don't hold for normal DocTypes:

  • "Is Child Table" is a checkbox on the DocType definition itself. It changes how the framework treats every document of that DocType β€” it has no independent List View in the Desk UI, because a child row without its parent's context isn't a meaningful unit to browse.
  • Child rows are saved as one batch with the parent, not as separate document saves. When a Purchase Invoice with 50 line items is saved, the framework issues one coordinated INSERT/UPDATE/DELETE batch against tabPurchase Invoice Item alongside the single write to tabPurchase Invoice β€” it is not 50 independent document-save transactions.
  • This is why child table edits don't appear as separately-numbered documents in version history. The parent document's version log records the whole document's state at each save, including its child rows, rather than treating each child row as its own versioned entity with its own history.

πŸ—ΊοΈ Expanded Table Map by Module

As in Part 1's Illustrative Table Map, these are representative, long-stable core DocTypes grouped by module β€” not an exhaustive schema dump. Exact table names, and which of these exist at all, can vary by ERPNext version and by which apps are installed on a site; confirm against your installed site (bench --site SITE console, or the DocType list in Developer Mode) before relying on them in code.

ModuleRepresentative tables
CoretabUser, tabRole, tabDocType, tabDocField, tabWorkflow, tabFile, tabCommunication, tabToDo, tabProperty Setter, tabCustom Field
CRM / SellingtabLead, tabOpportunity, tabCustomer, tabQuotation, tabSales Order, tabSales Order Item, tabDelivery Note, tabSales Invoice, tabSales Invoice Item
BuyingtabSupplier, tabMaterial Request, tabPurchase Order, tabPurchase Order Item, tabPurchase Receipt, tabPurchase Invoice, tabPurchase Invoice Item
StocktabItem, tabItem Group, tabWarehouse, tabBin, tabStock Entry, tabStock Ledger Entry, tabSerial No, tabBatch, tabStock Reconciliation, tabItem Price
AccountingtabAccount, tabGL Entry, tabPayment Entry, tabPayment Entry Reference, tabJournal Entry, tabJournal Entry Account, tabCost Center, tabCompany, tabAccounting Dimension
ManufacturingtabBOM, tabBOM Item, tabWork Order, tabJob Card
HR / PayrolltabEmployee, tabDepartment, tabAttendance, tabLeave Application, tabLeave Allocation, tabExpense Claim, tabSalary Slip, tabPayroll Entry
AssetstabAsset, tabAsset Category, tabAsset Movement
QualitytabQuality Inspection, tabQuality Procedure, tabQuality Goal
SupporttabIssue, tabService Level Agreement

πŸ”€ ER Diagram: Customer (Expanded)

Part 1's ER Diagrams section showed this relationship as a single collapsed row. Expanded, each referencing DocType carries its own illustrative fields:

Customer

  • name (PK)
  • customer_name
  • territory
↓referenced by (1 : N)

Contact

  • link_doctype
  • link_name

Address

  • link_doctype
  • link_name

Opportunity

  • party_name (Link)

Quotation

  • party_name (Link)

Sales Order

  • customer (Link)

Delivery Note

  • customer (Link)

Sales Invoice

  • customer (Link)
  • grand_total

Contact and Address deserve a specific callout as the canonical real-world example of Dynamic Link introduced above: they are not linked to Customer via a plain fixed Link field. A single Contact or Address DocType instance can belong to a Customer, a Supplier, an Employee, or any other party-like DocType β€” which one is decided per-record by the link_doctype field, with link_name then holding that target's name. This is precisely why Frappe needs the Dynamic Link field type at all: an ordinary Link can only ever point at one hardcoded target DocType, and a shared "address book" concept spanning several unrelated party types can't be modeled with a fixed Link.

πŸ”€ ER Diagram: Item (Expanded)

Item

  • name (PK)
  • item_group
  • stock_uom
↓referenced by

Item Group

Price List

Warehouse

  • via Bin

BOM

Sales Order Item

Purchase Order Item

Delivery Note Item

Purchase Receipt Item

Stock Ledger Entry

Bin is the table worth understanding specifically here: it holds the current on-hand quantity (and reserved/ordered/planned quantities) for one Item in one Warehouse, kept in sync as a derived cache from every posting to Stock Ledger Entry. The relationship is the same "current balance vs full ledger" distinction covered generally for financial balances in the accounting curriculum's reconciliation-flavored level: Bin is a fast-to-read snapshot of current state (one row per Item Γ— Warehouse), while Stock Ledger Entry is the full, append-only movement history that snapshot is derived from β€” if the two ever disagree, the Stock Ledger Entry history is the source of truth and Bin gets recalculated (a Stock Reconciliation), not the other way around.

πŸ”€ ER Diagram: Accounting (Expanded)

Company

↓owns

Account

Cost Center

Journal Entry

Payment Entry

↓ ↓ ↓ ↓all post to

GL Entry

  • account (Link)
  • debit / credit
  • voucher_type
  • voucher_no

GL Entry is ERPNext's concrete implementation of the abstract "General Ledger" concept described more generally in Invoice vs Payment: The Complete ERP Accounting Architecture's relationship map. Every GL Entry row carries separate debit and credit amount columns β€” following standard double-entry convention rather than a single signed amount β€” and links back to whichever document generated it via a voucher_type / voucher_no pair (e.g. voucher_type = "Sales Invoice", voucher_no = "ACC-SINV-2026-00088"). That pair is the direct ERPNext equivalent of the generic source_type / source_id columns described in that article's journal_entry schema β€” a polymorphic pointer back to whatever business document was the cause of this ledger posting, expressed with real Frappe field names instead of a generic schema sketch.

🧭 Document Flow & Linked Documents

ERPNext documents chain together through ordinary reference fields β€” a Sales Invoice stores which Sales Order and which Delivery Note it was raised against, a Payment Entry stores which invoice(s) it settles. Because Frappe tracks Link fields generically, opening any document also renders a Connections panel showing every other document that links to it, in both directions β€” you don't have to know in advance which DocTypes reference a given record to find them.

Sales Order
β†’
Delivery Notereferences Sales Order
β†’
Sales Invoicereferences both
β†’
Payment Entryreferences Sales Invoice

Two status mechanisms are worth calling out precisely, because they're easy to conflate:

  • outstanding_amount β€” a computed field on Sales Invoice (and similarly on Purchase Invoice) that tracks the unpaid balance as Payment Entries are submitted against it. This is the ERPNext-specific implementation of the generic "Outstanding Balance" formula described in the invoice lifecycle article β€” invoice total minus allocated payments minus credit notes, kept current as a stored, recalculated field rather than computed fresh on every read.
  • docstatus vs status β€” docstatus is a core, verified Frappe convention present on every submittable DocType: 0 = Draft, 1 = Submitted, 2 = Cancelled, and nothing else. Layered on top of it, most transactional DocTypes also carry a separate, human-readable status field β€” values like "Overdue", "Paid", or "Return" β€” that is computed from docstatus plus business logic (due dates, outstanding amount, linked return documents), not stored as the source of truth itself. This mirrors the multi-dimensional status modeling idea from the ERP accounting status matrix: one raw lifecycle flag plus several derived, human-facing labels layered on top, rather than one field trying to capture every dimension of a document's state at once.

⚠️ Common Database-Design Mistakes When Extending ERPNext

MistakeWhy it causes problemsDo this instead
Storing a denormalized copy of a linked document's field by handThe copy silently goes stale when the source document changes, and nothing keeps the two in syncUse fetch_from for point-in-time convenience copies, or follow the Link and read the live value at query time when currency matters
Making a DocType a child table when it needs its own List View and lifecycleChild tables have no independent List View, no independent version history, and can't be submitted/cancelled on their ownMake it a normal DocType with a Link field back to the "parent" instead of a Table field
Ignoring naming series collisions across companies/fiscal yearsTwo records can silently receive the same generated name if the series pattern doesn't include enough distinguishing tokensInclude company/fiscal-year tokens in the naming series pattern, and reset counters deliberately rather than by accident
Querying tab* tables directly with raw SQL instead of the Frappe ORMBypasses permission checks, validation hooks, and doc-event triggers that would otherwise runUse frappe.get_all / frappe.get_doc / frappe.qb so permissions and hooks still apply
Not indexing custom Link fields used heavily in filters/reportsEvery list/report filter on that field falls back to a full table scan as data volume growsMark the custom field as "In Standard Filter" / set it indexed, or add a database index explicitly for high-volume lookups

πŸ—ΊοΈ What's Next

This article expanded on Part 1's Database Architecture, Child Tables, Table Map, and ER Diagrams sections β€” the same underlying model, in the depth needed to design real customizations against it safely.

From here, Part 5 (Module Integration: Accounting & Stock) shows how these same tables interact live during a real transaction β€” what actually gets written to GL Entry and Stock Ledger Entry the moment a Sales Invoice is submitted. Part 6 (The Customization Guide) covers how to safely add fields and DocTypes of your own on top of everything described here, without hitting the mistakes listed above.

↑