What nx run-many and nx affected actually do
nx run-many -t <target> runs a target across every project that defines it, or across a subset you name with --projects. It doesn't look at git. The selection is fixed by what you ask for, so two runs on the same commit always pick the same projects. Any speedup comes from the computation cache, not from skipping work.
nx affected -t <target> starts with a diff between a base and a head commit. It maps each changed file to the project that owns it, then walks the project graph to add every project that depends on those projects. If you change a shared library, its consumers get rebuilt and retested. The result depends on two things: the base you chose and how accurate the graph is.
Once the projects are selected, both commands work the same way. Nx expands the selection into a task graph using each target's dependsOn, so upstream builds run first. It then schedules independent tasks up to the --parallel limit. The only difference between the two commands is which projects enter that graph.
| Aspect | run-many |
affected |
|---|---|---|
| Selection logic | All projects with the target, or an explicit --projects list |
Projects owning changed files, plus their dependents |
| Inputs it trusts | The project list and target configuration | The git diff between base and head, plus the project graph |
| Failure mode | Slow and wasteful, but complete | Silently misses projects when the base is wrong or a dependency is undeclared |
| Typical use | Main, nightly and release builds | Pull request builds |
| Who benefits most | Platform engineers who need a full, reproducible signal | Backend engineers waiting on PR feedback in a large monorepo |
#!/usr/bin/env bash
set -euo pipefail
npx nx run-many -t build test --parallel=3
npx nx run-many -t test --projects=api,web --parallel=3
git fetch origin main --depth=1
BASE=origin/main # a wrong base silently changes which projects are selected
HEAD_SHA=HEAD
npx nx show projects --affected --base="$BASE" --head="$HEAD_SHA"
npx nx affected -t lint test build --base="$BASE" --head="$HEAD_SHA" --parallel=3
Both commands use the same task graph and --parallel scheduling: run-many selects projects by name or target, and affected selects them from a base/head diff expanded through the project graph.
How it works, step by step
Both commands go through the same pipeline. Only the first stage differs.
- Build the project graph. Nx reads each project's configuration and analyzes source files and package manifests to infer which projects depend on which. Edges declared with
implicitDependenciesare added on top. - Select projects.
run-manytakes every project that defines the requested target, or the--projectslist.affectedtakes the files changed between--baseand--head, maps them to owning projects, and adds every transitive dependent from the graph. - Expand into a task graph. Each selected project and target becomes a task.
dependsOnin the target configuration pulls in prerequisite tasks, such as building a library before testing the app that imports it. - Hash and check the cache. For each task, Nx computes a hash from its declared inputs. If a matching result exists in the local or remote cache, Nx restores the outputs and logs instead of running the task.
- Schedule and run. Remaining tasks run in dependency order, with up to
--parallelindependent tasks at once.
Selection decides which work is considered. The cache decides which of that work actually executes. affected reduces work at step 2 and run-many relies on step 4, which is why the two have different failure modes. The next section covers step 2 for affected in detail.
Who should use each command
nx affected suits teams with a monorepo large enough that running every target on every pull request is slow, and whose project graph reflects real dependencies. In practice that means the people who own CI for an Nx workspace with many projects, and the engineers whose PR feedback time depends on it. It pays off most when most changes touch a small part of the repository, so the affected set is usually much smaller than the whole workspace.
nx run-many suits anyone who needs a complete, reproducible answer that doesn't depend on git history: whoever owns release and deploy pipelines, scheduled full builds, and debugging sessions where you want to rule out selection as a cause. It is also the simpler default for small workspaces, where a full run is already fast enough that the extra logic of base tracking isn't worth maintaining.
Most teams with a large workspace end up using both, with the choice made per pipeline rather than per team.
When should it be used
Use nx affected when the question is "did this change break anything it reaches?":
- Pull request builds, with the base set to the merge-base of the PR head and the target branch.
- Main branch builds, with the base set to the last commit on main whose pipeline passed, so nothing from a failed or cancelled run is skipped.
- Local pre-push checks, where a fast, approximate answer is useful and a full CI run follows anyway.
Use nx run-many when the question is "is everything in this set correct at this commit?":
- Release and deploy builds, where the set of projects is defined by what you ship, not by what changed.
- Nightly or scheduled full runs, ideally with the cache skipped, to catch coupling the graph doesn't know about.
- Fallback runs, whenever a trustworthy base SHA can't be resolved.
- Small workspaces, where the full run is cheap and the simplicity is worth more than the minutes saved.
How affected computes its change set
Nx starts with a list of changed files. With both --base and --head, that list is a git diff between the two commits. With only --base, Nx compares the base against your working tree, so uncommitted and untracked files count too. That suits local runs, but in CI it means stray generated files can widen the result.
Each changed file is mapped to the project whose root contains it. Nx then walks the project graph backwards: every project that depends on a touched project, directly or through other projects, becomes affected. The walk goes this way because a change to a shared library can break any consumer, even if the consumer's own code didn't change.
Files outside any project root are the expensive case. The root package manifest, the lockfile and workspace configuration such as nx.json or a base TypeScript config can affect many projects or all of them. Nx narrows this where it can attribute the change, for example to the specific packages that changed in the manifest. When it can't attribute the change, it assumes the worst.
You can add edges the code doesn't show. implicitDependencies declares relationships that static analysis misses, such as an app that reads a schema file from another project. Named inputs act later, in the task hash. They control whether an affected project's task can be served from cache.
changed files -> owning projects -> + dependents (graph walk) -> affected set -> targets x projects -> task graph
--base + --head --base only
git diff base..head base vs working tree, incl.
uncommitted + untracked files
| |
+---------------+----------------+
v
+-------------------+
| changed files |
+-------------------+
| |
inside project | | outside any project root:
root v v package.json, lockfile, nx.json,
+----------------+ +-----------------------+ base tsconfig
| owning | | attribute if possible |
| projects |<-| else: all projects |
+----------------+ +-----------------------+
|
| walk graph backwards
v (code imports + implicitDependencies)
+-------------------------------+
| affected set: owners plus all |
| transitive dependents |
+-------------------------------+
|
v targets x affected projects
+-------------------------------+
| task graph |
+-------------------------------+
| named inputs -> task hash -> cache hit or run
Nx maps changed files to their owning projects and resolves global files to specific projects or to all projects. It then walks the project graph backwards to collect the affected set and expands it into a task graph, where named inputs decide whether each task's result comes from cache.
Choosing the base SHA for PR and main builds
For a pull request, the question nx affected must answer is "what does this branch change relative to where it forked?" That is the merge-base of the PR head and the target branch, not the target branch's current tip. Diffing against the tip would pull in every commit merged to main since the branch was cut, which inflates the affected set. Nx resolves --base through a merge-base calculation, but only if git can see both histories. A default shallow clone often contains just the head commit, so the merge-base lookup fails or lands on the wrong commit. Fetch full history, or explicitly fetch the target branch with enough depth to reach the fork point.
On main, HEAD~1 looks reasonable but fails silently. Suppose pipeline N fails or is cancelled and pipeline N+1 passes. N+1 diffed only against N, so the projects that N changed and never verified are skipped, and nothing reports it. Use the last commit on main whose pipeline succeeded instead. You can track it with a moving git tag, a value your CI system exposes, or a SHA stored in an artifact or key-value store. Update it only after a fully green run.
If no recorded SHA exists, or the recorded one is no longer reachable from HEAD, run run-many across all projects. The second case happens after a force-push to main or a rewritten history. Rebased PR branches carry the same risk, because the old base can vanish. Log the resolved base SHA on every run so a reviewer can check that the base was correct.
#!/usr/bin/env bash
set -euo pipefail
TARGET_BRANCH="${TARGET_BRANCH:-main}" # placeholder: set from your CI's PR metadata
IS_PR="${IS_PR:-false}" # placeholder: "true" for pull request pipelines
TAG="nx-last-green" # moving tag = last fully green commit on main
if [ "$IS_PR" = "true" ]; then
# A shallow clone hides the fork point, so get full history and the target branch.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --unshallow --no-tags origin
fi
git fetch --no-tags origin "+refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"
# Diff against where the branch forked, not the current tip of main.
BASE=$(git merge-base "origin/${TARGET_BRANCH}" HEAD || true)
else
# Not HEAD~1: that skips commits from failed or cancelled pipelines.
git fetch --no-tags --force origin "+refs/tags/${TAG}:refs/tags/${TAG}" || true
BASE=$(git rev-parse --verify --quiet "refs/tags/${TAG}^{commit}" || true)
fi
if [ -n "$BASE" ] && git merge-base --is-ancestor "$BASE" HEAD; then
echo "nx base=${BASE} head=$(git rev-parse HEAD)" # logged so reviewers can verify
npx nx affected -t lint test build --base="$BASE" --head=HEAD
else
echo "nx base=none-or-unreachable head=$(git rev-parse HEAD); running all projects"
npx nx run-many -t lint test build
fi
if [ "$IS_PR" != "true" ]; then
git tag -f "$TAG" HEAD
git push -f origin "refs/tags/${TAG}" # requires a CI token with push rights
fi
A CI shell script that uses the merge-base for PRs and a last-green tag for main, verifies the base is still an ancestor of HEAD, logs it, and falls back to nx run-many when no usable base exists.
A recommended CI layout for PRs, main, nightly and releases
Each pipeline answers a different question, so each needs a different base and scope.
| Pipeline | Command | Base / scope | Question it answers |
|---|---|---|---|
| Pull request | nx affected |
Merge-base with the target branch | Does this change break anything it touches? |
| Main | nx affected |
Last successful main SHA | Is main still green after everything merged since then? |
| Nightly | nx run-many --all |
Whole workspace, optionally --skip-nx-cache |
Has anything drifted outside the graph? |
| Release / deploy | nx run-many --projects=... |
Exactly the published projects | Is what we ship built from this commit? |
On main, using the last successful SHA instead of HEAD~1 matters. If three merges land and one pipeline fails, the next run still covers all three.
The nightly full run catches problems that affected cannot see. These include changes to a base container image, an expired certificate in a test fixture, or a cache entry restored from bad inputs. Skipping the cache makes the run slower, but it is the only way to confirm that cached results still reproduce.
Do not scope deploys with affected. Suppose a shared library changes and only the services the graph flags get redeployed. Any service with a missed or implicit dependency keeps running the old code. The environment is then inconsistent, and no single commit describes it. A release should name its projects explicitly and build all of them.
Run lint, test and build as separate steps or jobs. When they run together in one invocation, a red check only tells you that something failed. Separate jobs show which target failed, and you can rerun just that job.
#!/usr/bin/env bash
set -euo pipefail
PIPELINE="$1" # pr | main | nightly | release
TARGET="$2" # lint | test | build
case "$PIPELINE" in
pr)
# Compare against where the branch forked, not the current tip of main.
git fetch --no-tags origin main
BASE=$(git merge-base origin/main HEAD)
npx nx affected -t "$TARGET" --base="$BASE" --head=HEAD
;;
main)
# LAST_GREEN_SHA is recorded by the CI system after each successful main run.
# Using it instead of HEAD~1 means a failed run's merges are re-covered next time.
: "${LAST_GREEN_SHA:?LAST_GREEN_SHA must be set}"
npx nx affected -t "$TARGET" --base="$LAST_GREEN_SHA" --head=HEAD
;;
nightly)
# Full workspace, no cache: confirms cached results still reproduce
# and catches drift the project graph cannot see.
npx nx run-many -t "$TARGET" --all --skip-nx-cache
;;
release)
# Name every published project explicitly; never scope a deploy with affected.
RELEASE_PROJECTS="api,web,worker"
npx nx run-many -t "$TARGET" --projects="$RELEASE_PROJECTS"
;;
*)
echo "unknown pipeline: $PIPELINE" >&2
exit 1
;;
esac
A single CI entry script that picks the Nx command and base for each pipeline type and runs one target per job, so failures can be traced to lint, test or build.
When should it be avoided
Avoid nx affected in these situations:
- Release and deploy pipelines. What you ship should be defined by an explicit project list, not by a diff. Scoping a deploy with
affectedcan leave services running code built from different commits. - When the base can't be resolved reliably. If the clone is shallow and can't be deepened, history was force-pushed, or no last-green SHA has been recorded, the selection is unpredictable. Fall back to
run-many. - When the graph is known to be incomplete. Runtime-read files, generated code, cross-language dependencies and config outside project roots that haven't been declared as inputs or
implicitDependencieswill be missed. Fix the graph first, and run full builds until you have. - Small workspaces. If a full cached
run-manyfinishes quickly, the base-tracking logic adds maintenance and a silent failure mode for little gain. - Changes that touch global files. A lockfile or root config change often marks most or all projects affected anyway.
affectedstill works, but you shouldn't count on it for speed in those runs.
Avoid nx run-many --all on every pull request in a large workspace. Even with a warm cache, it computes and restores far more than the change needs, and it relies entirely on cache correctness to stay fast. It also gives no extra protection against undeclared inputs, as the next section explains.
When affected is the wrong tool and what the alternatives are
Affected is only as accurate as the project graph. It selects projects from declared dependencies and static imports, so any coupling the graph cannot see is invisible to it. Common gaps include dynamic imports with computed paths, files read at runtime such as fixtures, SQL migrations or templates, shared config that lives outside every project root, and cross-language dependencies, for example a Go service consuming types generated from a TypeScript schema. When one of these changes, affected skips the consumers, and the PR goes green without testing what broke.
Remote caching does not solve this, because it has the same weakness. The cache key is computed from declared inputs. If a runtime-read file is not listed as an input, changing it produces the same hash, and run-many --all replays the old, passing result. That is a false cache hit, and it looks exactly like a real pass.
| Approach | Catches undeclared coupling? | Cost | Main risk |
|---|---|---|---|
run-many --all with remote cache |
No | Low to moderate | False cache hits on undeclared inputs |
run-many --projects with an explicit list |
Only if the list is right | Low | The list goes stale as the repo changes |
| Full run with caching disabled | Yes | High | Slow feedback, so teams run it rarely |
A nightly full uncached run is a reasonable backstop. It finds drift late, though. By the time it fails, a day of merges sits between the cause and the failure, and you have to bisect to find it. Treat a nightly failure as a sign the graph is wrong, and fix the graph by declaring the missing inputs or dependencies. Running the nightly more often does not fix the graph.
Making affected trustworthy and debugging what it selected
Affected is only as accurate as the project graph. Nx infers dependencies from imports and package manifests. It cannot see a service reading a YAML file at startup, a codegen step that consumes a schema, or a root-level config that changes how every project builds. Those edges have to be declared.
Declare what Nx cannot infer
Use implicitDependencies in a project's configuration when it depends on something it never imports. Typical cases are a shared protobuf or OpenAPI definition, a generated client, or a runtime fixture directory. Without the edge, a change to the schema marks only the schema project as affected. The consumers that break at runtime are left out.
Use namedInputs in nx.json for files that should invalidate many projects at once. Put .env templates, root tsconfig bases, lint and test runner configs, and toolchain version files in a shared input. Then reference that input from the default inputs. This makes a tooling change count as a change for every project that depends on it. The same declarations also feed the computation cache, so getting them right protects cache correctness as well as affected selection.
Check the selection before trusting green
When a PR passes suspiciously fast, run nx show projects --affected with the exact --base and --head the CI job used. Different SHAs give a different answer, and the comparison is meaningless. To see why a project was or wasn't pulled in, open the project graph visualization filtered to affected projects and trace the edges.
Audit the graph on a schedule
Periodically run the full target set on a commit that an affected run already marked green. Any project that fails in the full run but was skipped in the affected run points to a missing input or dependency. Each one you find is a gap that would have reached main unnoticed.
Impact on CI cost, parallelism and engineering time
Illustrative estimate (assumed numbers, not a benchmark)
Assume 120 tasks averaging 2 minutes each, --parallel=4, 30 seconds of graph computation, 5 seconds to restore a cache hit, an 80% hit rate on full cached runs, and a typical PR that affects 12 tasks, all of which miss cache.
Wall time ≈ graph + (misses × task time + hits × restore time) ÷ parallel
| Strategy | Misses | Hits | Wall time |
|---|---|---|---|
| Uncached full run | 120 | 0 | 30 + (14,400) ÷ 4 ≈ 60.5 min |
run-many --all with cache |
24 | 96 | 30 + (2,880 + 480) ÷ 4 ≈ 14.5 min |
affected |
12 | 0 | 30 + (1,440) ÷ 4 ≈ 6.5 min |
Caching narrows the gap but does not close it. The graph is still computed, and every hit still pays restore time. Tasks that embed timestamps or depend on unpinned tools miss every time. The average also hides spikes. A lockfile bump can mark nearly every project affected, so a cheap PR becomes a full run. Plan runner capacity for those peaks, not for the mean.
Set --parallel from the runner's cores and memory, not cores alone. Two concurrent TypeScript builds or Jest suites can exhaust RAM well before the CPUs are saturated. Past that limit, failures appear as OOM kills or timeouts, not as clean slowdowns, and they are easy to misread as flaky tests.
Tracking the last successful SHA on main adds pipeline logic that someone has to maintain. When it breaks, the result is a silent over- or under-selection of projects. That ongoing cost is worth paying when it consistently saves minutes on every PR across the team.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx run-many vs affected, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.