Line items are everywhere in ERPNext. The items on a Sales Invoice, the taxes below them, the payment schedule, the components on a BOM. Each of those grids is a child table, and each has its own DocType with Is Child Table ticked (the flag is stored as istable).
How a row knows where it belongs
A child DocType gets its own database table like any other, tabSales Order Item for example. What makes it different is four columns that tie every row to a document:
parent: the name of the parent document, likeSAL-ORD-2026-00012parenttype: the parent’s DocType, likeSales Orderparentfield: the Table field on the parent that holds the row, likeitemsidx: the row’s position, starting at 1
On the parent side, you add a field of type Table and set its options to the child DocType. One child DocType can sit under several parents, and parenttype keeps their rows apart. The Table MultiSelect field type also stores its values in a child DocType.
Reading and adding rows in Python
When you load a parent, its rows come with it as a list on the Table field’s name:
import frappe
so = frappe.get_doc("Sales Order", "SAL-ORD-2026-00012") # a draft order
for row in so.items:
print(row.idx, row.item_code, row.qty)
so.append("items", {
"item_code": "BOLT-M8",
"qty": 200,
"delivery_date": so.delivery_date,
})
so.save()
Saving the parent saves the rows. It also deletes any row in the database that’s no longer in the list, so removing an entry from so.items and saving is how you delete a line. When the parent is submitted or cancelled, Frappe copies its docstatus onto every row.
What a child table doesn’t get
Frappe strips permission rules from a child DocType. Access is always checked against the parent, which is why a direct frappe.get_list on a child DocType needs a parent_doctype argument before the permission check will pass. A child DocType can’t be imported on its own through Data Import, and when you create one in developer mode Frappe generates the Python controller but skips the JS form script and the test file.
In List View means something different here. On a child table field it controls which columns appear in the grid on the parent form.
Where people trip up
Editing rows behind the parent’s back. frappe.db.set_value on a child row writes straight to the table and skips the parent’s validate. Totals and taxes don’t get recalculated. Load the parent, change the row, save the parent.
Putting row checks in the wrong place. We put row validation in the parent controller’s validate, where every row and the header are available together, such as rejecting duplicate item codes.
Changing standard child tables in core files. Need an extra column on Sales Order Item? Add a custom field to the child DocType from your own app rather than editing ERPNext’s JSON. That’s how erpfly handles it: the field is exported as a fixture and ships in a pull request against your app. The ERPNext customization page covers how those changes survive upgrades, and the custom DocType walkthrough shows creating a child table from scratch.