ποΈ 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.
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:
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.
π·οΈ 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 name | Generated table name |
|---|---|
Customer | tabCustomer |
Item | tabItem |
Sales Order | tabSales Order |
Sales Invoice | tabSales Invoice |
GL Entry | tabGL Entry |
Stock Ledger Entry | tabStock 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 style | How the name is built | Example |
|---|---|---|
| Naming series | A pattern with a counter and optional date tokens, chosen at save time if more than one series is configured | SAL-ORD-.YYYY.-.##### β SAL-ORD-2026-00042 |
| Field value | field:fieldname β the name is copied straight from another field on the same document | field:item_code for Item |
| Prompt | The user types the name directly when creating the document | used for some master/setup DocTypes |
| Custom hook | An autoname() method on the DocType's controller class computes the name in code | composite 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 DocType | Type | Typical lifecycle |
|---|---|---|
| Customer | Master | Created once, edited occasionally, disabled rather than deleted once referenced by transactions |
| Supplier | Master | Created once, edited occasionally, disabled not deleted |
| Item | Master | Created once per SKU, price/attributes updated over time, disabled when discontinued |
| Warehouse | Master | Created per physical/logical stock location, rarely changes once in use |
| Account | Master | Set up as part of the Chart of Accounts, structurally stable, "frozen" rather than deleted |
| Employee | Master | Created at hire, updated through employment, marked "Left" rather than deleted |
| Cost Center | Master | Set up per department/company, edited rarely |
| Price List | Master | Created per price tier/currency, items and rates updated periodically |
| UOM | Master | Set up once (Nos, Kg, Boxβ¦), essentially static |
| Territory | Master | Set up as part of sales geography, rarely changes |
| Transaction DocType | Type | Typical lifecycle |
|---|---|---|
| Sales Order | Transaction | Draft β Submit β possibly Cancel/Amend; rarely edited after submit |
| Purchase Order | Transaction | Draft β Submit β possibly Cancel/Amend |
| Sales Invoice | Transaction | Draft β Submit β paid/returned; posts to GL Entry on submit |
| Purchase Invoice | Transaction | Draft β Submit β paid; posts to GL Entry on submit |
| Delivery Note | Transaction | Draft β Submit; posts Stock Ledger Entry movements |
| Purchase Receipt | Transaction | Draft β Submit; posts Stock Ledger Entry movements |
| Payment Entry | Transaction | Draft β Submit; settles outstanding invoice amounts |
| Journal Entry | Transaction | Draft β Submit; direct GL posting for non-invoice accounting events |
| Stock Entry | Transaction | Draft β Submit; manual/internal stock movement |
| Work Order | Transaction | Draft β 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 Itemalongside the single write totabPurchase 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.
| Module | Representative tables |
|---|---|
| Core | tabUser, tabRole, tabDocType, tabDocField, tabWorkflow, tabFile, tabCommunication, tabToDo, tabProperty Setter, tabCustom Field |
| CRM / Selling | tabLead, tabOpportunity, tabCustomer, tabQuotation, tabSales Order, tabSales Order Item, tabDelivery Note, tabSales Invoice, tabSales Invoice Item |
| Buying | tabSupplier, tabMaterial Request, tabPurchase Order, tabPurchase Order Item, tabPurchase Receipt, tabPurchase Invoice, tabPurchase Invoice Item |
| Stock | tabItem, tabItem Group, tabWarehouse, tabBin, tabStock Entry, tabStock Ledger Entry, tabSerial No, tabBatch, tabStock Reconciliation, tabItem Price |
| Accounting | tabAccount, tabGL Entry, tabPayment Entry, tabPayment Entry Reference, tabJournal Entry, tabJournal Entry Account, tabCost Center, tabCompany, tabAccounting Dimension |
| Manufacturing | tabBOM, tabBOM Item, tabWork Order, tabJob Card |
| HR / Payroll | tabEmployee, tabDepartment, tabAttendance, tabLeave Application, tabLeave Allocation, tabExpense Claim, tabSalary Slip, tabPayroll Entry |
| Assets | tabAsset, tabAsset Category, tabAsset Movement |
| Quality | tabQuality Inspection, tabQuality Procedure, tabQuality Goal |
| Support | tabIssue, tabService Level Agreement |
π Link vs Dynamic Link vs Table vs Table MultiSelect
These four field types are how DocTypes reference other DocTypes β each solves a different relationship shape:
| Field type | What it stores | Example |
|---|---|---|
| Link | A foreign-key-style reference to one fixed target DocType, stored as that document's name | Sales Order Item.item_code β Item |
| Dynamic Link | A reference whose target DocType is chosen by a sibling field, rather than being fixed at field-definition time (polymorphic reference) | Address.link_doctype + link_name can point at a Customer, a Supplier, or any other DocType |
| Table | Embeds the rows of one child DocType directly inside the parent document | Sales Order.items β Sales Order Item |
| Table MultiSelect | A many-to-many-style multi-select of another DocType's existing records, without embedding new independent rows the way a child table does | tagging a document with multiple Sales Persons |
Two related, UI-level field behaviors are easy to mistake for database constraints, so it's worth being precise about them: fetch_from auto-populates one field's value from a field on the linked document the moment the Link is set (e.g. a Sales Invoice's customer_name field can be configured to fetch from the linked Customer's customer_name field) β it's a convenience copy made at data-entry time, not a live join, so it can go stale if the source document changes afterward. read_only and depends_on similarly control how a field behaves in the form (whether it's editable, whether it's shown at all) purely at the UI layer β neither adds or removes an actual database constraint on the underlying column.
π 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
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
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
Account
Cost Center
Journal Entry
Payment Entry
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.
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 β
docstatusis 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 fromdocstatusplus 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
| Mistake | Why it causes problems | Do this instead |
|---|---|---|
| Storing a denormalized copy of a linked document's field by hand | The copy silently goes stale when the source document changes, and nothing keeps the two in sync | Use 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 lifecycle | Child tables have no independent List View, no independent version history, and can't be submitted/cancelled on their own | Make it a normal DocType with a Link field back to the "parent" instead of a Table field |
| Ignoring naming series collisions across companies/fiscal years | Two records can silently receive the same generated name if the series pattern doesn't include enough distinguishing tokens | Include 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 ORM | Bypasses permission checks, validation hooks, and doc-event triggers that would otherwise run | Use frappe.get_all / frappe.get_doc / frappe.qb so permissions and hooks still apply |
| Not indexing custom Link fields used heavily in filters/reports | Every list/report filter on that field falls back to a full table scan as data volume grows | Mark 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.