GitHub Actions unexpected symbol in a ${{ }} expression
GitHub Actions unexpected symbol in a ${{ }} expression means the expression parser reached a character it has no rule for and stopped. The YAML is fine, the workflow never starts, and the message hands you the offending token, its position and the expression it came from, which is usually enough to fix it without running anything.

What this error means
The workflow does not run at all. There is no job, no log and no annotation on a step, because the file was rejected before any of that existed; GitHub shows it as an invalid workflow file against the line in the editor and in the commit status. The message has four parts, and each one is doing work. It names the file and the line and column of the YAML key that holds the expression. It names the kind of problem. It gives you the raw token it choked on, in quotes. Then it gives you a position inside the expression and the expression text itself, so you can count across to the character it means. The position is an index into the expression, not into the file, which is the part that sends people to the wrong place first.
The workflow is not valid. .github/workflows/tests.yml (Line: 33, Col: 27):
Unexpected symbol: '-'. Located at position 7 within expression: '{{ ' -A minimal workflow that produces it
This file is written for this page and has never been run. The condition looks like ordinary code from almost any other language, and that is the trap: string literals inside an Actions expression take single quotes, and the double quote has no meaning the parser can use.
name: ci
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: deploy
if: ${{ github.ref == "refs/heads/main" }}
run: ./deploy.shCommon causes
A string literal in double quotes
The single most common one, because every other language in the repository accepts them. The parser reads the opening double quote as a symbol with no rule attached and reports it immediately, usually with the quote and the first word of the string as the raw token.
An operator the expression language does not have
Arithmetic, a single & or |, a bare =, or a ternary. The operator table has twelve entries and none of those are in it. In our experience this arrives when logic that belongs in a run: step is being squeezed into a condition.
A stray or mismatched brace
A ${{ opened twice, a }} that closes the wrong one, or a quoted YAML scalar that swallowed part of the delimiter. The parser then receives a fragment, and the token it reports is whatever punctuation was left dangling, which is why the position often points somewhere that looks fine.
Literal text glued to an expression
Concatenating by writing text next to ${{ ... }} inside the same expression rather than outside it. The parser reads the text as a symbol. The format() function exists for this, and it keeps the quoting in one place instead of three.
How to fix it
Swap the double quotes for single quotes
- Find the token the message printed in quotes and locate it in the expression text on the same line.
- Replace the surrounding double quotes with single quotes, and double a literal single quote to escape it.
- Leave YAML quoting alone: this is about quotes inside the expression, not around the whole value.
if: ${{ github.event.head_commit.message == 'it''s fine' }}Rewrite the logic with operators that exist
Use && and ||, and reach for contains(), startsWith() or format() rather than inventing syntax. Anything that needs arithmetic or a ternary belongs in a step, where the shell can do it and the result can come back as a step output.
if: ${{ startsWith(github.ref, 'refs/tags/') && github.event_name != 'pull_request' }}Count the delimiters before you count the characters
When the expression printed at the end of the message does not look like the one you wrote, the problem is the delimiters rather than the contents. Check that each ${{ has one }}, and that a value wrapped in YAML quotes has not absorbed part of either.
Lint the expressions, not just the YAML
A YAML validator will pass a file that the expression parser rejects, because the file is valid YAML with a bad string in it. actionlint parses the expression grammar as well as the YAML, so it catches this on commit instead of on push, which is the difference between a five second fix and a red default branch.
The quoting rule is a rule, not a preference
The expressions reference states it for the string data type in one place: "You don't need to enclose strings in ${{ and }}. However, if you do, you must use single quotes (') around the string. To use a literal single quote, escape the literal single quote using an additional single quote (''). Wrapping with double quotes (") will throw an error."
That last clause is the error you are reading. The parser has a list of operators and it is short: grouping, index, property dereference, not, the four relational comparisons, equal, not equal, and, or. Anything outside that list and the literal forms is a symbol it cannot place, which covers the arithmetic people reach for, the single ampersand, the single pipe, and a bare assignment.
How to read the position it gives you
The shape of the message comes from the parser itself. In ParseException.cs in actions/runner it is assembled as {description}: '{RawToken}'. then Located at position {TokenIndex + 1} then within expression: {Expression}, where the description is one of a small set that includes Unexpected symbol, Unrecognized function and Unrecognized named-value.
So the number is a one-based index into the expression text printed at the end of the same line, and the quoted token is the first thing the parser could not use. In the example at the top of this page the expression the parser saw was '{{ ' -, which tells you more than the position does: the file had a stray brace sequence, so the parser was handed a fragment rather than the expression the author meant to write.
- name: deploy
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
run: ./deploy.shThe two neighbors of this error
An unclosed expression is reported differently and is worth recognizing, because the fix is at the other end of the line. The template strings in actions/runner carry it as "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."
The other one is YAML rather than Actions. Because ! is reserved notation in YAML, the docs say you "must always use the ${{ }} expression syntax or escape with '', "", or ()" when the expression starts with it. An if: !cancelled() written bare never reaches the expression parser at all, so you get a YAML complaint instead of this one.
Why there is no recorded run on this page
The file never becomes a run, so there is no job log to record and nothing for a runner to retry or repair. The message at the top is quoted from a public issue, attributed there, and the workflow above is illustrative rather than reproduced. A linter that parses the expression grammar rather than the YAML alone is the closest thing to a local reproduction, and it belongs in a pre-commit hook rather than in a job.
How to prevent it
- Single-quote every string literal inside an expression, and double the quote to escape one.
- Keep one complete expression per
${{ }}and build strings withformat()rather than by concatenation. - Move anything that needs arithmetic or branching into a
run:step and read the result as a step output. - Run
actionlintin a pre-commit hook, so an expression it rejects never reaches the default branch.
Frequently asked questions
Why does GitHub say Unexpected symbol in my if condition?
Can I use double quotes in a GitHub Actions expression?
") will throw an error." To include a literal single quote, write it twice. YAML quoting around the whole value is a separate question and is not what this error is about.What does "Located at position 7 within expression" mean?
Why does an if that starts with ! break the workflow?
! is reserved notation in YAML, so the value never reaches the expression parser. The docs require the ${{ }} form or an escape with '', "" or () whenever an expression starts with it. Writing if: ${{ !cancelled() }} is the form the documentation itself recommends.