# GitHub Actions exceeded max expression length, and what counts

> GitHub Actions exceeded max expression length caps one expression at 21000 characters, not your file. See what is counted and how to get under it.

Source: https://latchkey.dev/learn/github-actions/github-actions-expression-too-long-21000-chars  
Updated: 2026-09-20

GitHub Actions exceeded max expression length is a cap of 21,000 characters applied to a single expression, measured before the expression is parsed and long before anything is evaluated. It is not a limit on the workflow file, on a matrix, or on the data an expression produces, which is why shortening the wrong thing does not help.

## What this error means

One key in the workflow file is flagged and the run does not start. The key is nearly always the one carrying a long interpolation: a matrix built from a JSON literal written inline, a run command assembled from a chain of concatenations, or a condition that grew a clause at a time across a year. The rest of the file is fine and other, similar keys are fine, which is the clue that the limit is per expression rather than per document.

```Message text from ParseException in actions/runner, ExceededMaxLength
Exceeded max expression length 21000
```

## Common causes

### A JSON document was written inside the expression

A matrix or a list passed through `fromJSON` with the data written inline. Every character of the document is part of the expression, so the cap is reached by the size of the data rather than by any complexity in the expression itself.

### A condition accumulated clauses over time

A long chain of `contains`, `startsWith` and equality tests joined by `&&` and `||`. Each clause was small when it was added and nobody measured the sum, because there is nothing in a review that shows the length of an expression.

### A run command was assembled inside the braces

Building a command line from a long `format` call or from repeated concatenation puts the whole command into one expression. This version usually appears in deployment workflows where the arguments differ per environment.

### A generator produced the workflow

A tool that templates workflow files can inline a data structure that a human would have referenced. In our experience this is the version that appears suddenly and at full size, rather than growing, because the generator's input crossed a threshold.

## How to fix it

### Move the data into a file and read it in a job

Keep the list in the repository as JSON, read it in a small job, and publish it as an output. The expression in the matrix then names the output instead of carrying the data, and the data can grow without touching the workflow again.

```.github/workflows/ci.yml (illustrative)
- id: read
  run: echo "targets=$(jq -c . ci/targets.json)" >> "$GITHUB_OUTPUT"
```

### Split a long condition into a decision and a comparison

1. Move the logic into a `run` step that writes a short value to the file named by GITHUB_OUTPUT.
2. Give that step an id and read `steps.<id>.outputs.<name>` in the `if`.
3. Keep the remaining expression to a single comparison, so the next clause someone adds goes into the script rather than into the template.

### Put a long command in a script file

A command that needs a paragraph of interpolation to assemble is a script wearing a costume. Commit it, call it with the two or three values that actually vary, and the expression shrinks to the arguments. The script also becomes runnable outside CI, which is usually worth more than the fix itself.

```.github/workflows/deploy.yml (illustrative)
- run: ./ci/deploy.sh "${{ matrix.target }}" "${{ github.ref_name }}"
```

### Do not try to fit under the cap by compressing

Minifying the JSON or shortening key names buys a one-off percentage against a number that does not move while your data keeps growing. The same file will fail again after the next few entries, in a change whose diff gives no hint about expressions. Move the data out once instead.

## How to prevent it

- Keep list data in files under version control, never inside an expression.
- Treat any expression that does not fit on a screen as a script waiting to be written.
- Compute conditions in steps and compare short outputs in `if`.
- When a generator writes your workflows, have it emit references rather than inlined data.

## Where the number comes from and what it measures

The limit is a constant in the expression library, declared as 21,000 with a comment explaining the choice: it keeps the string under the .NET large object heap threshold of 85,000 bytes even if the runtime were to switch to four bytes per character. That is the whole derivation, and it is the reason the number is not round.

The check is the first statement in the parse context constructor. Before a lexer exists, before a single token has been read, the constructor compares the length of the expression string against the constant and throws if it is greater. The exception it throws carries no token, and the message for a token-less parse exception is the description alone, which is why this error reads as a bare sentence with no position information while most expression errors name a position and quote the text.

The string being measured is the expression, meaning the characters between the opening and closing braces of one interpolation. The workflow file can be a megabyte. A matrix can expand to hundreds of jobs. A `toJSON` call can produce a far larger string than the expression that produced it. None of that is counted.

| Thing | Counted against 21000 | Where the real limit is |
| --- | --- | --- |
| The characters of one `${{ }}` expression | yes | this cap, in the parser constructor |
| Every expression on the page added together | no | no combined limit exists |
| The text the expression evaluates to | no | the template's own size accounting |
| How deeply the expression nests | no | a separate cap of 50 levels |

> The depth cap is the sibling people meet next. It is declared beside the length cap, checked while parsing rather than before it, and produces its own message naming the maximum depth. A deeply nested `format` or a long chain of conditional operators can hit it while being nowhere near 21,000 characters.

## Why an inline JSON matrix is the usual culprit

The shape that produces this is a matrix whose values come from `fromJSON` applied to a literal written into the workflow. Everything inside the braces is one expression, so the entire JSON document counts, including whitespace, quotes and escaping. A list of a few hundred entries with a handful of fields each reaches 21,000 characters without feeling large in an editor.

The fix is to move the data out of the expression rather than to compress it. A job that reads the file and writes it to its own output gives you an expression of a few dozen characters, and the data travels as an output rather than as source text. The size of that output is governed by the platform's own limits on outputs rather than by the expression cap, which is a different and much larger budget.

Compressing the JSON is a trap worth naming. Removing whitespace buys a proportion of the file once, and the list keeps growing, so the workflow breaks again later in a change that has nothing to do with expressions.

```.github/workflows/ci.yml (illustrative)
jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.read.outputs.targets }}
    steps:
      - uses: actions/checkout@v7
      - id: read
        run: echo "targets=$(jq -c . ci/targets.json)" >> "$GITHUB_OUTPUT"

  build:
    needs: plan
    strategy:
      matrix:
        target: ${{ fromJSON(needs.plan.outputs.targets) }}
    runs-on: ubuntu-latest
    steps:
      - run: ./build.sh "${{ matrix.target }}"
```

## The other shape: one expression that grew

The second common origin is a condition or a command that was extended a clause at a time. Each addition is small and reviewed on its own, and nobody measures the total until the parser does. Because the whole condition is one expression, splitting it is the fix, and there are two ways to split.

For a condition, compute part of it in an earlier step and compare a short output in the `if`. A step that decides whether the branch is a release branch and writes `release=true` reduces a paragraph of string matching to one comparison, and it also becomes testable, because the decision is now a command rather than a template.

For a command, write the script to a file in the repository and call it, passing the few values that vary as arguments. This is the same move as the matrix fix: the long thing stops being source text inside braces and becomes data or code that the expression merely points at.

## Why there is no recorded run on this page

The check happens before the expression is parsed, so nothing is evaluated, no job is created, and there is no runner involved at any point. Recording this would also require committing an expression of more than twenty-one thousand characters to this repository purely as a prop, which is a file nobody should have to read in order to trust a constant that is one line of published source.

The constant, its comment, the constructor that tests it and the exception that formats the message are all in actions/runner, and the TypeScript port used by the editor extension declares the same 21,000 and produces the same sentence. Two independent implementations agreeing is better evidence than one recording.

## FAQ

### What exactly does the 21000 character limit apply to?

To one expression, measured as the characters of that expression before parsing begins. The limit is a constant in the expression library and the test is the first statement in the parse context constructor. Your workflow file, the number of expressions in it and the size of whatever an expression produces are all counted separately or not at all.

### Why does this message have no line or column?

Because the exception is thrown without a token. The parse exception formats a position and quotes the text only when it was given one, and the length check fires before any token exists, so the message is the bare description.

### Does minifying my inline JSON fix it?

Only temporarily. The cap is fixed and the data is what is growing, so removing whitespace delays the failure by however much whitespace you had. Reading the file in a job and passing it through an output removes the limit from the equation entirely.

### Is there a separate limit on how deeply an expression nests?

Yes, a maximum depth of 50, declared beside the length constant and checked while parsing rather than before it. It has its own message naming the maximum. A heavily nested `format` or a long chain of conditionals can reach it without being anywhere near 21,000 characters.

## References

- [actions/runner: ExpressionConstants.cs, MaxLength and MaxDepth](https://github.com/actions/runner/blob/main/src/Sdk/DTExpressions2/Expressions2/ExpressionConstants.cs)
- [actions/runner: ParseException.cs, the ExceededMaxLength description](https://github.com/actions/runner/blob/main/src/Sdk/DTExpressions2/Expressions2/ParseException.cs)
- [GitHub Actions: expressions reference](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions)
- [GitHub Actions: usage limits for workflows](https://docs.github.com/en/actions/reference/limits)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
