Twisp
Restructuring a Chart of Accounts
How to safely delete, empty, and detach account sets when reorganizing a live chart of accounts — and why the ledger enforces the order it does.
A chart of accounts is rarely static. Businesses reorganize: a product line is wound down, two departments merge, a rollup is split in two. This guide shows how to tear down and reshape an existing account-set hierarchy without ever producing a wrong balance, and explains the rules the ledger enforces so the reorganization is safe even while transactions are being posted concurrently.
- Understand why account sets are append-structured while in use
- Soft-delete a set with the
deleteAccountSetmutation - Empty a set with
removeFromAccountSet - Detach a deleted, empty set from its parents
- Tear down a multi-level hierarchy bottom-up
Prerequisites
You should already be comfortable creating account sets and adding members to them. If not, start with Organizing with Account Sets. For background on how account sets roll balances up a hierarchy, see Chart of Accounts.
The one rule that shapes everything
An account set's balance is the rolled-up sum of every account reachable through its members. The ledger computes those rollups continuously and concurrently with transaction posting. That concurrency is the reason for the single rule that governs restructuring:
A set's structure is append-only while the set is in use. Once a set has ever held a member, it is frozen: it cannot be moved to a different parent, and it cannot be detached from its current parents — while it is active.
Concretely, has_members on a set is a one-way latch. It flips from false to true the first time you add any member, and it never flips back — not even after you remove every member. A set that has never been populated is still free to attach and detach; a set that has ever been populated is structurally fixed.
This is not an arbitrary restriction. Moving or detaching a populated set while transactions are in flight is precisely the operation that can strand a balance in the wrong place — a posting that resolved the old structure commits its contribution through an edge you just removed, and the parent is left holding money with no path to explain it. Rather than take heavyweight locks on every affected rollup, the ledger makes the structural question append-only, which is the shape it can answer correctly without locks.
So you never move a populated set. You tear it down and rebuild. The rest of this guide is the safe teardown sequence.
Step 1 — Soft-delete the set
Deleting an account set is a mark-only, soft delete. It always succeeds, and it does exactly one thing: it marks the set as DELETED.
mutation {
deleteAccountSet(id: "acct-set-to-remove") {
accountSetId
status
}
}
What deleteAccountSet does not do is just as important:
- It does not remove the set's members.
- It does not detach the set from its parents.
- It does not change any balance. A deleted set that still sits under a parent keeps contributing to that parent until you remove its members.
What the mark does do is close the set off from further structural growth, immediately:
- A deleted set can no longer be added to any other set.
- A deleted set can no longer accept new members.
- The mark is permanent — there is no un-delete.
Think of the delete as "retire this set." It stops the set from being reused or extended, which is exactly what makes the later detach safe (see Why the order matters).
Step 2 — Empty the set
A set contributes to its parents only through the accounts reachable beneath it. To stop that contribution, remove the members. Account members can be removed at any time, including from a deleted set:
mutation {
removeFromAccountSet(
accountSetId: "acct-set-to-remove"
memberId: "account-123"
memberType: ACCOUNT
) {
accountSetId
}
}
Remove members until the set is physically empty. Each removal is balance-exact: the account's contribution is reversed out of the set and every ancestor at the moment you remove it.
If the set contains sub-sets, empty and tear those down first — teardown is bottom-up (see Step 4).
Step 3 — Detach the empty, deleted set from its parents
Once a set is deleted and physically empty, it may be removed from each parent it belongs to:
mutation {
removeFromAccountSet(
accountSetId: "parent-set"
memberId: "acct-set-to-remove"
memberType: ACCOUNT_SET
) {
accountSetId
}
}
There is a short drain window to be aware of. Immediately after you delete a set, transactions that were already in flight might still be resolving it as a live member. The ledger waits until those have provably finished before it lets you detach — the same drain window used elsewhere in the ledger, on the order of a minute.
If you attempt the detach too soon, it does not fail permanently. You get a retriable error carrying a retryAfter timestamp — the exact instant the drain window closes:
{
"errors": [
{
"message": "deleted account set ... is still within its drain horizon; retry after it clears",
"extensions": {
"retriableError": true,
"retryAfter": "2026-07-08T18:42:10Z"
}
}
]
}
Wait until retryAfter and retry; the detach then succeeds. If instead you get a non-retriable error saying the set still has members, it is not yet empty — return to Step 2.
Step 4 — Tearing down a multi-level hierarchy
To dismantle a whole subtree, work bottom-up. For each set, from the leaves toward the root:
- Delete it (mark it
DELETED). - Empty it — remove account members, and detach any child sets that are themselves already deleted, empty, and drained.
- Detach it from its parents (waiting out the drain window if needed).
Doing this leaf-first guarantees that whenever you detach a set, it is already empty: its children were emptied and detached in earlier rounds, so nothing is left to strand in the parent.
To reshape rather than fully remove a hierarchy — for example, to re-home a populated sub-set under a different parent — the pattern is create-new, not move:
- Create the new set in its new location and add the members there.
- Tear down the old set with the delete → empty → detach sequence above.
Because a populated set is frozen, "move" is always expressed as "build the new shape, then retire the old one."
Why the order matters
The delete → empty → detach order is not bureaucracy; each step removes a specific way the reorganization could otherwise corrupt a balance:
- Delete first closes the set to new members and new parents. This bounds the set of transactions that could still be posting into it to a finite, shrinking population — the ones already in flight — with no new entrants.
- Empty next drives the set's contribution to zero, so detaching it reverses nothing.
- The drain window before detach waits out every in-flight transaction that could still be resolving the (now deleted) set as live. Once they have all committed, no straggler can seed a contribution through the edge you are about to remove.
With all three in place, detaching a deleted, empty, drained set is a pure structural edge removal that touches no balance — safe to do even under a live posting workload. Skip any one of them and you reintroduce exactly the race the append-only rule exists to prevent.
Further reading
- Organizing with Account Sets — building hierarchies in the first place.
- Account Sets — the full account-set operation reference.
- Chart of Accounts — how and why Twisp models a chart of accounts with sets.