2025, Oct 18 16:00
Python RecursionError from shared class attribute: how to build an indentation-based chart-of-accounts tree correctly
See why a shared class-level children list triggers Python RecursionError in indentation-based chart-of-accounts parsing, and fix it with per-instance lists.
Building a nested tree from a chart of accounts sounds straightforward until the structure starts duplicating itself and the stack overflows. A classic pitfall in Python is subtle enough to pass code review yet brutal enough to trigger a RecursionError in production. Below is a minimal walkthrough of why that happens when you construct a tree of accounts by indentation depth and how to fix it cleanly.
Problem setup
The source data represents a chart of accounts where indentation defines hierarchy. Every level is indented by three spaces. The goal is to parse each line, detect its depth, and nest it under the appropriate parent node.
Here is a trimmed class and driver code capturing the approach:
class ChartItem:
    ancestor = None
    acct_no: int | None
    label: str
    raw_line: str
    kind: str | None
    subtype: str | None
    is_header_flag: bool = False
    children: list = list()  # intended to hold per-instance children
    def __init__(self, line_in: str, ancestor=None, kind: str = None, subtype: str = None):
        self.raw_line = line_in
        self.ancestor = ancestor
        self.kind = kind
        self.subtype = subtype
        cleaned = line_in.strip()
        ...  # code to derive self.acct_no and self.label from cleaned
    def is_header(self) -> bool:
        return self.is_header_flag
    def nesting_level(self) -> int:
        return (len(self.raw_line) - len(self.raw_line.strip())) / 3
    def add_child(self, line_in, type_hint: str = None, detail_hint: str = None):
        if len(self.children) > 0:
            if calc_level(line_in) > self.children[-1].nesting_level():
                self.children[-1].add_child(line_in, type_hint=type_hint, detail_hint=detail_hint)
            else:
                self.children.append(ChartItem(
                    line_in,
                    ancestor=self,
                    kind=self.kind if type_hint is None else type_hint,
                    subtype=self.subtype if detail_hint is None else detail_hint
                ))
        else:
            self.children.append(ChartItem(
                line_in,
                ancestor=self,
                kind=self.kind if type_hint is None else type_hint,
                subtype=self.subtype if detail_hint is None else detail_hint
            ))
        return
if __name__ == "__main__":
    root_nodes: list[ChartItem] = list()
    for (row_text, type_hint, subtype_hint) in supply_rows():
        lvl = (len(row_text) - len(row_text.lstrip())) / 3
        if lvl == 0:
            root_nodes.append(ChartItem(row_text, ancestor=None, kind=type_hint, subtype=subtype_hint))
        else:
            if type_hint is not None:
                root_nodes[-1].add_child(row_text, type_hint=type_hint, detail_hint=subtype_hint)
            else:
                root_nodes[-1].add_child(row_text)
Why it blows up
The recursion comes from the branch that delegates to the last child when the incoming line is deeper than that child’s nesting level. On the surface, this matches the intended tree-building logic. The problem is that the list intended to store per-node children is declared on the class, not on the instance. In Python, mutable attributes defined on the class are shared by all instances. That means every ChartItem points at the same children list.
With a shared list, the condition to recurse into the last child can keep handing control to a node that, from the perspective of the algorithm, never becomes a sibling but remains the same logical slot across instances. The method keeps calling itself, the call stack grows, and the interpreter eventually raises RecursionError. The observed symptom of a node seemingly adding itself under itself again and again is the natural consequence of one shared children container for all instances.
The fix
Make the children container an instance attribute. Initialize it in the constructor so every node owns its own list. The rest of the logic can stay the same.
class ChartItem:
    ancestor = None
    acct_no: int | None
    label: str
    raw_line: str
    kind: str | None
    subtype: str | None
    is_header_flag: bool = False
    def __init__(self, line_in: str, ancestor=None, kind: str = None, subtype: str = None):
        self.raw_line = line_in
        self.ancestor = ancestor
        self.kind = kind
        self.subtype = subtype
        self.children: list[ChartItem] = []  # per-instance children container
        cleaned = line_in.strip()
        ...  # code to derive self.acct_no and self.label from cleaned
    def is_header(self) -> bool:
        return self.is_header_flag
    def nesting_level(self) -> int:
        return (len(self.raw_line) - len(self.raw_line.strip())) / 3
    def add_child(self, line_in, type_hint: str = None, detail_hint: str = None):
        if len(self.children) > 0:
            if calc_level(line_in) > self.children[-1].nesting_level():
                self.children[-1].add_child(line_in, type_hint=type_hint, detail_hint=detail_hint)
            else:
                self.children.append(ChartItem(
                    line_in,
                    ancestor=self,
                    kind=self.kind if type_hint is None else type_hint,
                    subtype=self.subtype if detail_hint is None else detail_hint
                ))
        else:
            self.children.append(ChartItem(
                line_in,
                ancestor=self,
                kind=self.kind if type_hint is None else type_hint,
                subtype=self.subtype if detail_hint is None else detail_hint
            ))
        return
With children no longer shared across instances, the last-child delegation works as intended: deeper lines are routed down the current branch, and sibling lines append at the current node.
Why this matters
Class-level mutability is a subtle source of cross-instance coupling. In tree structures, it can quietly corrupt topology or trigger infinite recursion during insert. Even seasoned Python developers occasionally forget that lists and dicts declared on the class are singletons for all instances unless explicitly replaced in the constructor.
This pattern is not specific to parsing a chart of accounts. Any algorithm that incrementally builds nested structures based on ordering or indentation will rely on per-node containers. Getting that boundary right is the difference between a clean hierarchy and a stack trace.
Wrap-up
When a recursive insert unexpectedly loops forever, check two things early. First, verify that the branch condition truly changes between calls. Second, confirm that any containers meant to be per-instance are initialized in the constructor and not on the class. In Python, lists and dicts declared at class scope are shared across all instances and will sabotage stateful logic like tree building. Move them into __init__, keep the insertion rules simple, and the hierarchy will take shape exactly as the data intends.
The article is based on a question from StackOverflow by In Hoc Signo and an answer by John Gordon.