---
title: "Remove Unnecessary needs: in GitHub Actions | StarSling"
description: "A needs: edge with no artifact, output, or side effect it depends on only serializes jobs. How to find and remove unnecessary GitHub Actions job dependencies."
url: https://starsling.dev/github-actions/optimizations/remove-unnecessary-job-dependencies
canonicalUrl: https://starsling.dev/github-actions/optimizations/remove-unnecessary-job-dependencies
---

# Remove needs: dependencies that do not exist

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Remove needs: dependencies that do not exist

- [How StarSling works](https://starsling.dev/)

Rule: ci.parallel.job-dependencies. Detection mode: static. Last updated: 2026-08-21

A `needs:` edge in GitHub Actions is only real when the downstream job consumes something the upstream job produced; every other `needs:` just forces two jobs that could run at the same time to run one after another, turning available parallel runner capacity into unused wall-clock time.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Is the needs: edge real, or just ordering](#is-the-edge-real)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

GitHub Actions schedules a job the moment every job named in its needs: list finishes, regardless of whether the runner sat idle waiting. When a needs: edge was added for readability, to mirror a copy-pasted job, or just because someone assumed lint should come before test, the two jobs still run one after another even though the runner pool has capacity to run them at the same time. Total wall-clock time becomes the sum of both jobs' durations instead of the longer of the two, and nothing in the workflow file signals that the wait was avoidable, because the YAML is valid and the run finishes green either way.

_.github/workflows/ci.yml_

```yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  unit-tests:
    runs-on: ubuntu-latest
    # No needs:. lint and unit-tests each check out and install independently,
    # so they start together instead of one waiting on the other.
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  build:
    runs-on: ubuntu-latest
    # Also independent of lint and unit-tests; it reads only the checkout.
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/ci.yml_

```yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  unit-tests:
    runs-on: ubuntu-latest
    needs: lint   # lint produces nothing unit-tests reads; pure ordering
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  build:
    runs-on: ubuntu-latest
    needs: [lint, unit-tests]   # same problem, now serialized twice over
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
```

<a id="how-to-detect-it"></a>

## How to detect it

1. List every needs: edge in the workflow: `grep -rn 'needs:' .github/workflows/` (both the single-job `needs: build` form and the list `needs: [lint, build]` form).
2. For each edge, open the downstream job's steps and check for `actions/download-artifact` (or `actions/cache/restore`) pulling something the upstream job uploaded with `actions/upload-artifact`. If it downloads nothing from that job, the edge is not artifact-backed.
3. Check for `needs.<job>.outputs.*` referenced anywhere in the downstream job's `with:`, `env:`, or `if:` blocks. If the job name from needs: never appears inside `needs.<job-name>.outputs`, no output is being consumed.
4. Check for a genuine side-effect dependency: does the downstream job deploy to an environment, install a package, or read a resource that only exists once the upstream job (a publish, a migration, an infrastructure apply) has completed. This is real even with no artifact or output.
5. Anything left after those three checks come back empty is an edge kept only for ordering or readability, and can be removed.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- A gate job that is meant to fail fast before burning runner minutes on a doomed run is a genuine reason to keep an edge even with no artifact or output changing hands: needs: lint on an expensive integration-test job can be intentional cost control, not an accident. Keep it, and say in a comment that it is a deliberate gate.
- A deploy job must not start before the jobs that verify the build are done; removing needs: from a deploy job to save wall-clock time turns a safety check into a race and is a regression, not an optimization.
- Removing an edge changes what a partial failure looks like: with needs:, a failed upstream job skips the downstream one; without it, downstream jobs run and may report their own failures on top of the real one, which can be noisier to triage even though it finishes faster.
- If two jobs happen to be independent today but a future change will make one produce something the other needs, removing the edge now means someone has to remember to add it back; leaving a short comment explaining why there is no needs: helps the next author not reintroduce it out of caution.

<a id="is-the-edge-real"></a>

## Is the needs: edge real, or just ordering

A needs: edge is genuine only when the downstream job consumes something the upstream job produced. There are exactly three ways that happens: the downstream job runs actions/download-artifact for a file the upstream job uploaded with actions/upload-artifact; the downstream job reads needs.<job>.outputs.* set via a step's outputs and the job's own outputs: map; or the downstream job depends on a side effect the upstream job performed that only exists after it runs, a package published to a registry, an environment deployed, an image pushed. If a downstream job's steps do not do any of these three things with the specific job named in needs:, the edge exists only to make one job start after another, and that ordering can be had for free by running both at once instead.

<a id="verify"></a>

## Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR:

```
Audit this repository's GitHub Actions workflows for unnecessary needs: dependencies and remove the ones that only enforce ordering.

1. grep .github/workflows/ for needs: and list every edge, including both the
   single-job form (needs: build) and the list form (needs: [lint, build]).
2. For each edge, open the downstream job and check three things: does it run
   actions/download-artifact (or actions/cache/restore) for something the
   upstream job uploaded; does it reference needs.<job>.outputs anywhere in
   with:, env:, or if:; does it depend on a side effect the upstream job
   performed (a deploy, a publish, a migration). If none of the three hold,
   the edge is ordering-only and is a candidate for removal.
3. Before removing an edge, check whether the upstream job is a deliberate
   fail-fast gate (an expensive job intentionally kept behind a cheap one to
   save runner minutes on a doomed run) or precedes a deploy that must not
   run before checks pass. Leave those edges in place.
4. Read the GitHub Actions needs context and workflow syntax docs linked on
   this page before editing.
5. Show the full diff and open a pull request rather than applying changes
   blindly. In the PR body, list each removed edge with file and line, state
   why it was not artifact-, output-, or side-effect-backed, and describe how
   to verify: check the run's workflow graph for the jobs starting together
   and compare total wall-clock time before and after.
```

Confirm the change landed:

1. Open the run's visualization graph (the Actions tab's workflow graph, or the run summary page) and confirm the jobs that no longer have needs: show as starting at the same time instead of one waiting on the other.
2. Compare the run's total wall-clock duration before and after: with the edge removed, total time should approach the longer of the two jobs rather than their sum.
3. Re-run the workflow and confirm every job still passes; removing an edge changes scheduling only, so no job's inputs should have changed.

<a id="related-pages"></a>

## Related pages

- [Run independent GitHub Actions jobs in parallel](https://starsling.dev/github-actions/optimizations/parallelize-independent-jobs)
- [Build once and reuse it across GitHub Actions jobs](https://starsling.dev/github-actions/optimizations/avoid-duplicate-compilation)
- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)

<a id="sources"></a>

## Sources

- [GitHub Actions: needs context](https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context)
- [GitHub Actions: workflow syntax (jobs.<job_id>.needs)](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idneeds)
- [GitHub Actions: passing data between jobs in a workflow](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/pass-job-outputs)
