GitHub Actions exceeded max expression length, and what counts
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.
Exceeded max expression length 21000Where 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 |
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.
- id: read
run: echo "targets=$(jq -c . ci/targets.json)" >> "$GITHUB_OUTPUT"Split a long condition into a decision and a comparison
- Move the logic into a
runstep that writes a short value to the file named by GITHUB_OUTPUT. - Give that step an id and read
steps.<id>.outputs.<name>in theif. - 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.
- 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.
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.
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.
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.
Frequently asked questions
What exactly does the 21000 character limit apply to?
Why does this message have no line or column?
Does minifying my inline JSON fix it?
Is there a separate limit on how deeply an expression nests?
format or a long chain of conditionals can reach it without being anywhere near 21,000 characters.