# GitHub Actions unexpected symbol in a ${{ }} expression

> Fix GitHub Actions unexpected symbol in a ${{ }} expression: what the parser rejects, where the position points, and the quoting rule behind it.

Source: https://latchkey.dev/learn/github-actions/github-actions-unexpected-symbol-expression  
Updated: 2026-09-20

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.

```Invalid workflow file, quoted from SciKit-Surgery/sustainable-pkg-stats#24
The workflow is not valid. .github/workflows/tests.yml (Line: 33, Col: 27):
Unexpected symbol: '-'. Located at position 7 within expression: '{{ ' -
```

## Common 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

1. Find the token the message printed in quotes and locate it in the expression text on the same line.
2. Replace the surrounding double quotes with single quotes, and double a literal single quote to escape it.
3. Leave YAML quoting alone: this is about quotes inside the expression, not around the whole value.

```.github/workflows/ci.yml (illustrative)
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.

```.github/workflows/ci.yml (illustrative)
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.

## 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 with `format()` rather than by concatenation.
- Move anything that needs arithmetic or branching into a `run:` step and read the result as a step output.
- Run `actionlint` in a pre-commit hook, so an expression it rejects never reaches the default branch.

## 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.

```.github/workflows/ci.yml (illustrative)
name: ci
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: deploy
        if: ${{ github.ref == "refs/heads/main" }}
        run: ./deploy.sh
```

## 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.

```.github/workflows/ci.yml, corrected (illustrative)
- name: deploy
        if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
        run: ./deploy.sh
```

## The 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.

> If the token in quotes is a context name rather than punctuation, the message says named-value instead: [GitHub Actions unrecognized named-value](/learn/github-actions/gha-bad-expression-context-access).

## 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.

## FAQ

### Why does GitHub say Unexpected symbol in my if condition?

Because the expression parser met a character that is not part of the grammar and stopped there. The quoted token in the message is that character, and the number after it is a one-based index into the expression text printed at the end of the same line. A double-quoted string is the usual answer, and an unbalanced brace is the second.

### Can I use double quotes in a GitHub Actions expression?

No. The expressions reference is explicit: strings inside an expression use single quotes, and "Wrapping with double quotes (`"`) 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?

It is a one-based character index into the expression text that follows it on the same line, not into your file. The line and column earlier in the message point at the YAML key. When the expression printed at the end does not match what you wrote, the delimiters are wrong and the position is pointing inside a fragment.

### Why does an if that starts with ! break the workflow?

Because `!` 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.

## References

- [GitHub Actions: expressions, literals and operators](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions)
- [actions/runner: ParseException.cs, the message the parser assembles](https://github.com/actions/runner/blob/main/src/Sdk/DTExpressions2/Expressions2/ParseException.cs)
- [SciKit-Surgery/sustainable-pkg-stats#24: the full invalid-workflow annotation](https://github.com/SciKit-Surgery/sustainable-pkg-stats/issues/24)
- [GitHub Actions: workflow syntax, jobs.<job_id>.steps[*].if](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsif)

---

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
