Grandchild Table Fields
Render editable tables inside child table rows by storing the grandchild data as JSON.
On this page
Frappe doesn't natively support tables within child table rows (grandchild tables). This pattern works around the limitation using JSON storage and virtual table rendering.
The Problem
You have a parent document with a child table (e.g., Shipping Package → Item Capacity rows). Each child row needs its own editable sub-table of data (e.g., multiple UOM/quantity pairs per item capacity slot).
Frappe's child table (Table fieldtype) only supports one level of nesting.
The Solution
Store grandchild data as JSON in a field on the child doctype
Render a virtual editable table when the child row's edit form opens
Sync changes back to the JSON field on edit
Doctype Setup
Parent: Shipping Package
Has a standard Table field:
{
"fieldname": "item_capacity",
"fieldtype": "Table",
"options": "Shipping Package Item Capacity"
}Child: Shipping Package Item Capacity
Has a JSON field for grandchild data and a Data field for display summary:
{
"fields": [
{
"fieldname": "capacity_sets",
"fieldtype": "JSON",
"label": "Capacity Sets"
},
{
"fieldname": "description",
"fieldtype": "Data",
"label": "Description",
"in_list_view": 1
}
],
"istable": 1
}The description field shows a human-readable summary in the grid (e.g., "Pound × 5 | Ounce × 10").
JavaScript Implementation
Hook into the child table's form_render event to replace the JSON field with a virtual table:
frappe.ui.form.on("Shipping Package Item Capacity", {
form_render: function (frm, cdt, cdn) {
let child_row = locals[cdt][cdn];
let dialog = frm.fields_dict.item_capacity.grid.open_grid_row;
if (!dialog) return;
// Get the wrapper for the capacity_sets field
let wrapper = dialog.fields_dict.capacity_sets.wrapper;
wrapper.replaceChildren();
// Create an in-memory FieldGroup with a virtual Table field
let field_group = new frappe.ui.FieldGroup({
fields: [
{
fieldtype: "Table",
fieldname: "capacity_table",
in_place_edit: true,
data: JSON.parse(child_row.capacity_sets || "[]"),
fields: [
{
fieldname: "uom",
label: "UOM",
fieldtype: "Link",
options: "UOM",
in_list_view: 1,
reqd: 1,
},
{
fieldname: "qty",
label: "Quantity",
fieldtype: "Int",
in_list_view: 1,
reqd: 1,
},
],
get_data: () => field_group.get_value("capacity_table"),
},
],
body: wrapper,
});
field_group.make();
// Connect virtual grid to parent document
// (required for Link fields to resolve properly)
field_group.fields_dict.capacity_table.grid.doc = frm.doc;
// Sync changes back to the JSON field
field_group.fields_dict.capacity_table.grid.wrapper.on(
"change",
() => {
let data = field_group.get_value("capacity_table");
// Sort for consistent display
data.sort((a, b) => (a.uom || "").localeCompare(b.uom || ""));
// Store as JSON
child_row.capacity_sets = JSON.stringify(data);
// Update the summary description for the grid view
child_row.description = data
.filter((row) => row.qty && row.uom)
.map((row) => `${row.uom} × ${row.qty}`)
.join(" | ");
frm.dirty();
}
);
},
});How It Works
form_renderevent fires when a child row's inline form opens (clicking the row or the expand button).Access the child row data via
locals[cdt][cdn]— this is the standard Frappe pattern for accessing child document data in form events.Get the grid dialog via
frm.fields_dict.item_capacity.grid.open_grid_row— this is the currently open row's form container.Replace the JSON field's wrapper —
wrapper.replaceChildren()clears the default JSON textarea.Create a
frappe.ui.FieldGroupwith aTablefieldtype — this renders a full editable grid inside the child form. Key properties:in_place_edit: true— Enables direct cell editingdata— Initial values parsed from the JSON fieldfields— Column definitions (Link, Int, Float, etc.)get_data— Callback for the grid to fetch current data
Connect to parent document —
field_group.fields_dict.capacity_table.grid.doc = frm.docensures Link fields can access the parent document's context.Sync on change — The grid wrapper's
changeevent serializes data back to JSON, updates the display summary, and marks the form dirty.
Data Flow
Database: capacity_sets = '[{"uom":"Pound","qty":5},{"uom":"Ounce","qty":10}]'
↓ parse
Virtual table: | UOM | Qty |
| Pound | 5 |
| Ounce | 10 |
↓ serialize on change
JSON field: '[{"uom":"Ounce","qty":10},{"uom":"Pound","qty":5}]' (sorted)
Summary: "Ounce × 10 | Pound × 5"Considerations
No server-side validation of grandchild structure — The JSON field stores arbitrary data. Add Python-side validation in the parent's
validate()method if needed.No query access — You can't filter or search by grandchild data using Frappe's query builder. Use
JSON_EXTRACTin raw SQL if needed.Performance — This pattern works well for small amounts of grandchild data (< 50 rows). For larger datasets, consider a proper child doctype with a Link back to the parent's child row.
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.