[object Object]

Formula columns in Dataverse evaluate at read time, but they have a hidden dependency graph that the platform validates at create time. If you accidentally create a cycle — column A references B, B references C, C references A — you get a 0x80048300 error with no guidance on where the cycle lives. The platform refuses the save and you are left grepping solutions for clues.

Why cycles appear

Formula columns are Power Fx expressions stored against a column’s FormulaDefinition. They can reference:

  • Other columns on the same table.
  • Columns on a related table via a lookup.
  • Other formula columns, including ones on related tables.

The transitive closure of those references is where cycles hide. A two-hop cycle is obvious. A four-hop cycle across three tables is not.

What the error actually means

The formula creates a circular reference is what you see in the maker portal. In the Web API response, you get:

{
  "error": {
    "code": "0x80048300",
    "message": "Dependent attribute set contains a cycle for formula column..."
  }
}

The platform stops at detection — it does not tell you the cycle path. So you have to walk it yourself.

Pull the dependency graph

The dependency metadata is exposed via RetrieveDependenciesForCreate and RetrieveDependenciesForDelete. For a static audit, query the dependency metadata table directly with a small script. Here is a Node snippet against the Web API:

const baseUrl = 'https://yourorg.crm.dynamics.com/api/data/v9.2';

async function getFormulaColumns(token) {
  const url = `${baseUrl}/EntityDefinitions?$select=LogicalName` +
    `&$expand=Attributes($select=LogicalName,SourceType,FormulaDefinition;` +
    `$filter=SourceType eq 2)`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` }
  });
  const data = await res.json();
  return data.value
    .flatMap(e => e.Attributes.map(a => ({
      table: e.LogicalName,
      column: a.LogicalName,
      formula: a.FormulaDefinition
    })))
    .filter(c => c.formula);
}

function extractRefs(formula) {
  // Power Fx column refs look like ThisRecord.column or 'table'.column
  const direct = [...formula.matchAll(/ThisRecord\.(\w+)/g)].map(m => m[1]);
  const lookup = [...formula.matchAll(/(\w+)\.(\w+)/g)]
    .filter(m => m[1] !== 'ThisRecord')
    .map(m => `${m[1]}.${m[2]}`);
  return { direct, lookup };
}

That gives you nodes. The edges are the references in extractRefs. Run Tarjan’s SCC algorithm over the graph and any strongly connected component with more than one node is a cycle.

A minimal cycle finder

function findCycles(graph) {
  const stack = [], onStack = new Set(), index = {}, lowlink = {};
  const sccs = []; let i = 0;

  function strongconnect(v) {
    index[v] = i; lowlink[v] = i; i++;
    stack.push(v); onStack.add(v);
    for (const w of graph[v] || []) {
      if (index[w] === undefined) {
        strongconnect(w);
        lowlink[v] = Math.min(lowlink[v], lowlink[w]);
      } else if (onStack.has(w)) {
        lowlink[v] = Math.min(lowlink[v], index[w]);
      }
    }
    if (lowlink[v] === index[v]) {
      const scc = []; let w;
      do { w = stack.pop(); onStack.delete(w); scc.push(w); }
      while (w !== v);
      if (scc.length > 1) sccs.push(scc);
    }
  }
  for (const v of Object.keys(graph)) {
    if (index[v] === undefined) strongconnect(v);
  }
  return sccs;
}

Feed it { "account.formulaA": ["contact.formulaB"], "contact.formulaB": ["account.formulaA"] } and it returns the cycle.

Run it in CI

The point of the script is not local debugging — it is gate-keeping. Wire it into your solution export pipeline. The check runs after solution export and before push to the next environment. If it finds an SCC, fail the pipeline with the offending node list. We catch about one cycle per quarter this way, always introduced by a well-meaning developer adding a “small” formula reference.

Common cycle shapes

  • Mirror columns: account.totalContacts references contact.accountTotal, which references account.totalContacts.
  • Bidirectional lookups: parent-child where both sides expose formulas referencing each other.
  • Cross-table aggregation impersonating rollups: when developers fake rollups with formulas because rollups are async.

Why platform-side detection is shallow

Dataverse only detects cycles when both columns already exist. If you add the second leg of a cycle to a new column via solution import, the import succeeds on a per-component basis but the solution import as a whole fails partway through, leaving you with orphaned columns. Pipeline-time detection beats import-time.

What about rollup columns

Rollup columns have their own cycle detection — but only within a single table. A rollup on account that aggregates contact, where contact has a formula that references the rollup, creates an undetected cycle that re-evaluates on every recalc job. We covered the broader tradeoffs in Calculated, rollup, and formula columns compared — though the elastic tables piece is tangentially related, the rollup vs formula split is the upstream decision.

Pixel notes

When you surface a cycle to a maker, show the path, not the names. We render a small directed graph in the dev tool using a sparse SVG with arrowheads. Three nodes, two edges, glance-readable. Names alone are worse than useless because makers do not have the topology in their head.

Key takeaways

  • Formula cycles fail with a generic error and zero diagnostics.
  • Walk the dependency graph yourself; the metadata is in Attributes.FormulaDefinition.
  • Tarjan’s SCC algorithm finds the cycle in linear time.
  • Gate cycles in CI, not at solution import.
  • The riskiest cycles are cross-table — they evade single-table validation.
[object Object]
Share