top of page
need-an-expert-background.png

CFML Tree Structure for Hierarchical Data from Flat Tables

need-an-expert-background.png

CFML Tree Structure for Hierarchical Data from Flat Tables

Mar 24, 2025
9 min read

Flat database rows are easy to store, but they are awkward to render when the UI wants a nested menu, category tree, org chart, or threaded comment list.


Most hierarchical data is stored with a simple relationship:


  • Each row has its own `id`

  • Each row may have a `parentID`

  • Rows with no parent are top-level items

  • Rows with a parent belong under another row


That storage model works well in SQL. It keeps the table simple and avoids nesting data inside the database. But once those rows reach CFML, you often need a real tree, where each node contains a `children` array.


The clean way to build that tree is a two-pass lookup pattern. You create every node first, store those nodes in a struct keyed by ID, then link each node to its parent. This gives you predictable linear performance and avoids repeated scans through the full dataset.


Top-down view of labeled wooden blocks arranged as a small hierarchy.
A tree starts as separate records before code links them together.

What you are building


The goal is to turn flat records like this:


id

parentID

name

1


Products

2

1

Cameras

3

1

Lenses

4

2

Mirrorless Cameras

5

2

DSLR Cameras

6


Support

7

6

Documentation


Into a nested structure like this:


```json

[

{

"id": 1,

"name": "Products",

"children": [

{

"id": 2,

"name": "Cameras",

"children": [

{

"id": 4,

"name": "Mirrorless Cameras",

"children": []

},

{

"id": 5,

"name": "DSLR Cameras",

"children": []

}

]

},

{

"id": 3,

"name": "Lenses",

"children": []

}

]

},

{

"id": 6,

"name": "Support",

"children": [

{

"id": 7,

"name": "Documentation",

"children": []

}

]

}

]

```


That nested array is much easier to use when rendering navigation, building API responses, or passing data to a front-end component.


This is the core pattern for a CFML tree structure for hierarchical data from a flat table.


Start with the right flat data model


Before writing the CFML, make sure the source data has the basic fields the tree builder needs.


At minimum, each record needs:


  • A unique ID, usually `id`

  • A parent reference, usually `parentID`

  • Any display or payload fields, such as `name`, `slug`, `sortOrder`, or `url`


For example:


```cfml

records = [

{ id = 1, parentID = "", name = "Products" },

{ id = 2, parentID = 1, name = "Cameras" },

{ id = 3, parentID = 1, name = "Lenses" },

{ id = 4, parentID = 2, name = "Mirrorless Cameras" },

{ id = 5, parentID = 2, name = "DSLR Cameras" },

{ id = 6, parentID = "", name = "Support" },

{ id = 7, parentID = 6, name = "Documentation" }

];

```


Rows with an empty `parentID` become roots. If your database uses `NULL`, `0`, or another value to represent the top level, you can adjust the root check later.


The important rule is simple: every non-root record should point to another record by ID.


Close-up view of paper rows showing id and parentID values.
The parentID column is the link that turns flat rows into a hierarchy.

Avoid the recursive search trap


A common first version looks something like this:


```cfml

function findChildren(required array records, required any parentID) {

var children = [];


for (var record in arguments.records) {

if (record.parentID == arguments.parentID) {

record.children = findChildren(arguments.records, record.id);

arrayAppend(children, record);

}

}


return children;

}

```


This works for small lists. The problem is that each recursive call loops over the full array again.


If you have 20 rows, that is probably fine. If you have hundreds or thousands of rows, the repeated scanning becomes expensive. A large product catalog, comment thread, or organization chart can expose the weakness fast.


The issue is not recursion by itself. The issue is the full-list search inside each recursive step.


A better pattern avoids searching for children over and over. Instead, it builds a direct lookup table once.


Build a lookup struct for every node


The first pass creates one node for each row and stores it in a struct.


The key is the record ID. The value is the node that will later appear in the tree.


```cfml

nodesById = {};


for (var record in records) {

var id = record.id;


nodesById[id] = duplicate(record);

nodesById[id].children = [];

}

```


After this pass, `nodesById` acts like an index:


```cfml

nodesById[1] // Products node

nodesById[2] // Cameras node

nodesById[6] // Support node

```


This gives you constant-time access to any parent node by ID. You no longer need to scan the full list to find a record.


The `duplicate()` call keeps the tree nodes separate from the original row data. That is helpful if you do not want to mutate the original records. If mutation is fine in your use case, you can skip `duplicate()`, but using it makes the function safer and easier to reuse.


Each node gets an empty `children` array right away. That means every node has the same shape, even leaf nodes.


That consistency matters when rendering. Your view code can safely check `node.children` without wondering whether the key exists.


Link each node to its parent


The second pass attaches each node to its parent.


For each record:


  1. Get its current node from `nodesById`

  2. Check whether it has a parent

  3. If it has no parent, add it to the top-level `tree` array

  4. If it has a parent, find the parent in `nodesById`

  5. Append the current node to the parent’s `children` array


Here is the basic version:


```cfml

tree = [];


for (var record in records) {

var id = record.id;

var parentID = record.parentID;

var node = nodesById[id];


if (!len(trim(parentID & ""))) {

arrayAppend(tree, node);

} else if (structKeyExists(nodesById, parentID)) {

arrayAppend(nodesById[parentID].children, node);

}

}

```


This is the key move. You are not searching for children. You are directly finding each parent and adding the child to it.


Build all nodes first, then link them. That one choice prevents ordering bugs and keeps the algorithm fast.

The order of the source rows no longer matters. A child can appear before its parent in the original query because the first pass creates every node before the second pass links anything.


Eye-level view of interlocking wooden pieces forming a branching tree shape.
The second pass connects existing nodes instead of searching the list again.

Put the pattern in a reusable CFML function


A reusable function keeps the tree-building logic out of your controllers, views, and service methods.


This version accepts an array of structs and lets you choose the ID field and parent field names.


```cfml

public array function buildTree(

required array rows,

string idKey = "id",

string parentKey = "parentID"

) {

var nodesById = {};

var tree = [];


// First pass: create every node.

for (var row in arguments.rows) {

var id = row[arguments.idKey];


nodesById[id] = duplicate(row);

nodesById[id].children = [];

}


// Second pass: attach each node to its parent.

for (var row in arguments.rows) {

var id = row[arguments.idKey];

var parentID = row[arguments.parentKey];

var node = nodesById[id];


if (!len(trim(parentID & ""))) {

arrayAppend(tree, node);

} else if (structKeyExists(nodesById, parentID)) {

arrayAppend(nodesById[parentID].children, node);

} else {

// Orphan handling.

// Treat records with missing parents as top-level nodes.

arrayAppend(tree, node);

}

}


return tree;

}

```


Use it like this:


```cfml

tree = buildTree(records);

```


Or with different column names:


```cfml

tree = buildTree(

rows = records,

idKey = "categoryID",

parentKey = "parentCategoryID"

);

```


The function treats orphaned records as top-level nodes. An orphan is a record with a `parentID` that does not exist in the input data.


That fallback is often the safest behavior for display code. It prevents data problems from making the whole tree disappear. In stricter systems, you may prefer to throw an error or collect orphan records separately.


For example:


```cfml

} else {

throw(

type = "TreeBuilder.MissingParent",

message = "Parent ID #parentID# was not found for node #id#."

);

}

```


Choose the behavior that fits the job. For admin navigation, showing the orphan may be fine. For accounting categories or permission trees, failing loudly may be better.


Use query data without complicating the tree builder


Many CFML apps start with a query rather than an array of structs.


You can either adapt the function to loop over a query, or convert the query into an array first. Keeping the tree builder focused on arrays often makes it easier to test.


Example query:


```cfml

qCategories = queryExecute("

SELECT

categoryID,

parentCategoryID,

categoryName,

slug

FROM categories

ORDER BY sortOrder, categoryName

");

```


Convert that query to an array:


```cfml

records = [];


for (var row in qCategories) {

arrayAppend(records, {

categoryID = row.categoryID,

parentCategoryID = row.parentCategoryID,

categoryName = row.categoryName,

slug = row.slug

});

}


categoryTree = buildTree(

rows = records,

idKey = "categoryID",

parentKey = "parentCategoryID"

);

```


Now your tree builder does not care where the data came from. It can work with query results, API data, cached JSON, or test fixtures.


That separation also makes unit testing much simpler. You can pass a small array into `buildTree()` and assert that the output matches the expected nested shape.


Keep sibling order predictable


The lookup pattern preserves the order in which children are appended.


That means sibling order depends on the order of your input data. If your menu, category tree, or comment thread needs a specific order, sort before building the tree.


For database-backed records, the easiest place is usually SQL:


```sql

ORDER BY sortOrder, name

```


If you sort after building the tree, you need to sort each `children` array at every level. That is possible, but it adds extra work and more code.


For most apps, sort the flat data first, then build the tree.


Handle root values clearly


Different systems represent root nodes in different ways.


Common root values include:


Root style

Example

Empty string

`parentID = ""`

Null value

`parentID IS NULL`

Zero

`parentID = 0`

Fixed root record

`parentID = 1`


The sample function treats empty strings and null-like blank values as roots because this line converts the value to a string before trimming it:


```cfml

if (!len(trim(parentID & ""))) {

```


If your app uses `0` as the root marker, adjust the condition:


```cfml

if (!len(trim(parentID & "")) || parentID == 0) {

arrayAppend(tree, node);

}

```


If your app has a conceptual root record, such as `parentID = 1`, you may want to collect records under that root as top-level nodes instead of including the root itself.


```cfml

rootParentID = 1;


if (parentID == rootParentID) {

arrayAppend(tree, node);

}

```


The best choice depends on what you want to return. Some APIs include a synthetic root node. Others return only the visible top-level children.


Know why the two-pass approach scales better


The naive recursive-search version tends toward O(n²) behavior because each node may trigger a scan through the full list.


The lookup version is O(n):


  • One pass to create nodes

  • One pass to link nodes

  • Direct parent lookups through a struct


That difference becomes real once the list grows beyond a small menu. A category tree with thousands of products, a documentation site with many sections, or a threaded discussion with many replies will benefit from the lookup approach.


The code is also easier to reason about. First, everything exists. Then, everything gets linked.


Wide-angle view of a handmade paper tree with many branches labeled as records.
Large trees benefit most from direct lookups instead of repeated scans.

Watch out for interleaved linking


One tempting variation is to create each node and attach it to its parent in the same loop.


That can fail when records arrive in an unexpected order.


For example:


```cfml

records = [

{ id = 4, parentID = 2, name = "Mirrorless Cameras" },

{ id = 2, parentID = 1, name = "Cameras" },

{ id = 1, parentID = "", name = "Products" }

];

```


If you try to attach node `4` to node `2` during the same pass where you create node `4`, node `2` does not exist yet.


You could add extra checks and delayed processing, but that makes the code more complex than it needs to be.


The two-pass pattern avoids this entire class of bugs. It does not matter whether the parent appears before or after the child. By the time linking starts, every node is already available in `nodesById`.


Render the finished tree


Once you have the nested array, rendering becomes straightforward.


Here is a simple recursive function that renders nested HTML lists:


```cfml

public string function renderTree(required array nodes) {

var html = "<ul>";


for (var node in arguments.nodes) {

html &= "<li>";

html &= encodeForHtml(node.name);


if (arrayLen(node.children)) {

html &= renderTree(node.children);

}


html &= "</li>";

}


html &= "</ul>";


return html;

}

```


Use it like this:


```cfml

writeOutput(renderTree(tree));

```


For JSON APIs, you can return the tree directly:


```cfml

cfcontent(type = "application/json");

writeOutput(serializeJSON(tree));

```


The same tree can serve many outputs:


  • Nested navigation menus

  • Category pickers

  • Comment threads

  • Sidebar documentation trees

  • Organization charts

  • Permission groups


The tree builder only handles data shape. Rendering stays separate.


What success looks like


You have the right solution when your flat records become a nested array where:


  • Every node has a `children` array

  • Top-level records appear in the root array

  • Child records appear under the correct parent

  • Source row order does not break parent-child linking

  • Large datasets do not require repeated full-list scans


The practical pattern is simple: create every node first, then link every node second.


That small shift turns flat table data into a reliable CFML tree structure without expensive recursive searching. Keep the builder generic, sort your input before building, and decide how your app should handle orphan records. Once those choices are clear, the same function can support menus, catalogs, org charts, comments, and any other hierarchy your CFML app needs.


 
 
bottom of page