What is nx affected?
nx affected is an Nx command that runs a target, such as test, lint, or build, only for the projects in a monorepo that a change could have affected. It does not run the target for every project.
Nx is a build system for monorepos. It knows which projects exist in a workspace and how they depend on each other. nx affected combines that knowledge with a git diff between two commits. A project counts as affected if one of its own files changed, or if it depends, directly or transitively, on a project whose files changed. Nx then runs the requested target for that set and skips the rest.
The alternative is nx run-many, which runs a target for every project that defines it. nx affected replaces "test everything on every push" with "test what this change could have broken." It is only correct if the project graph and the commit range are correct.
Who should use nx affected?
nx affected fits teams that already use Nx, or are adopting it, in a monorepo where running every project's tasks on every change has become too slow. These conditions make it most useful:
- Many projects with a wide graph. Repositories with many services, apps, and leaf libraries gain the most, because a typical change touches a small part of the graph.
- Slow full runs. If a full
testorbuildtakes long enough that developers skip it locally, or long enough to delay PR feedback in CI, narrowing the run pays off. - A graph Nx can infer. JavaScript and TypeScript projects get dependency inference from imports, installed packages, and TypeScript config. Other languages need a plugin or hand-declared edges.
- Someone to maintain it. A person or team has to keep the graph accurate, set the base commit correctly in CI, and keep a scheduled full run.
It is a weaker fit in three cases. In a small repo, a full run is already fast. In a repo where nearly every project depends on one core library, most changes affect most of the graph. In a repo with many dependencies that static analysis can't see, you will declare them all by hand or risk silently skipping tests. The later sections cover these trade-offs in detail.
How it works: how nx affected decides what changed
nx affected answers one question: given the files that differ between two commits, which projects could behave differently? Nx answers it in two steps.
Step one: build the project graph
Nx discovers projects from project.json files and from package.json files listed in your package manager's workspaces configuration. It then infers the dependencies between them. To do this, it analyzes source imports, installed dependencies, and TypeScript configuration such as path aliases. You can declare edges by hand, but most come from static analysis. Nx caches the graph and reanalyzes only the files that changed (Nx mental model).
Step two: map changes and walk the graph
Nx diffs the base commit against the head and assigns each changed file to the project whose root contains it. It then walks the graph in reverse to collect every project that depends on a touched project, both directly and transitively.
This is why a one-line fix in a shared library such as libs/auth can trigger dozens of tasks. Suppose three services import auth and an API gateway imports one of those services. All five are now affected. Their compiled output or runtime behavior can change even though nobody edited their source. Nx cannot prove they are safe, so it includes them.
What the result depends on
The result is only as good as two inputs:
- The project graph. A dependency that Nx can't see, such as a dynamic import or a file read at runtime, means a consumer is silently skipped.
- The base commit. If the base is wrong, the diff is wrong.
The command itself is the easy part. The rest of this article covers getting those two inputs right.
STEP 1: BUILD THE PROJECT GRAPH
┌────────────────────────┐
│ project.json files │──┐
│ workspace package.json │──┤ ┌──────────────────────────────┐
│ source imports │──┼───▶│ Project graph │
│ installed dependencies │──┤ │ (cached, incremental) │
│ tsconfig path aliases │──┘ └──────────────────────────────┘
└────────────────────────┘
STEP 2: MAP CHANGES, THEN WALK THE GRAPH IN REVERSE
┌─────────────┐ ┌───────────────┐ ┌───────────────────┐
│ git diff │──▶│ changed file │──▶│ owning project │
│ base..head │ │ libs/auth/... │ │ (root contains it)│
└─────────────┘ └───────────────┘ └─────────┬─────────┘
│ walk dependents
▼
┌──────────────────────────────────────────┐
│ libs/auth (directly changed) │
└────▲──────────────▲──────────────▲───────┘
│ imports │ imports │ imports
┌────┴─────┐ ┌────┴─────┐ ┌────┴─────┐
│ svc-a │ │ svc-b │ │ svc-c │
└────▲─────┘ └──────────┘ └──────────┘
│ imports
┌────┴─────┐
│ gateway │
└──────────┘
Affected: auth, svc-a, svc-b, svc-c, gateway (5 projects, 1 edited)
Edges Nx cannot see (dynamic imports, runtime file reads) are skipped.
Nx builds a project graph from project and package manifests, source imports, installed dependencies and TypeScript config, maps the base-to-head diff to the owning project, then walks dependents in reverse, so one change to libs/auth affects five projects.
Running the nx affected command locally
Run nx affected -t test from a feature branch and Nx diffs your changes against a base, maps the changed files to projects, follows the project graph to their dependents, and runs test only on that set. Passing several targets, as in nx affected -t lint test build, runs all three across the same affected set. Nx orders the tasks using the task graph, so a library's build finishes before an app that depends on it.
The comparison points are explicit. --base sets the starting commit and --head sets the end. Nx compares from the merge-base of the two, so commits that landed on main after you branched are not counted as your changes. If you omit --head, Nx also counts uncommitted and untracked files, which is usually what you want locally. You set the default base with defaultBase in nx.json. Make it match your trunk branch, for example main or master. Otherwise every developer has to remember to pass --base.
nx run-many -t test ignores git entirely and runs the target for every project that defines it.
nx affected -t test |
nx run-many -t test |
|
|---|---|---|
| Scope | Changed projects and their dependents | All projects with the target |
| Depends on git state | Yes | No |
| Good for | Feature branches, pre-push checks | Trunk builds, after toolchain or config upgrades, debugging graph gaps |
| Risk | Misses work if the graph is incomplete | Slow, and the cost grows with the repo |
The gain is largest in repositories with many backend services and shared libraries, where a full test run takes long enough that people stop running it locally. In a repo with only a few projects, the difference between the two commands is usually small.
nx affected -t test
nx affected -t lint test build
git fetch origin main
nx affected -t test --base=origin/main --head=HEAD
nx show projects --affected --base=origin/main --head=HEAD
nx run-many -t test
Running nx affected locally with the default base and with explicit --base/--head, listing the affected projects, and comparing with nx run-many, which runs the target on every project.
Choosing the base commit in CI
The nx affected command compares two commits and works out which projects the diff touches. Nx can only be as correct as the base you give it, so selecting that commit is most of the work of setting up affected runs in CI.
Pull requests vs. main builds
For a pull request, use the merge base between the branch and main, which you can get with git merge-base origin/main HEAD. That commit is where the branch diverged, so the diff contains only the PR's own changes. Commits that landed on main in the meantime are not included.
For builds on main, use the last commit on main whose pipeline passed. A common shortcut is HEAD~1, but it only looks one commit back. If a push or merge brings in five commits, Nx sees just the last one. Projects changed by the other four never get built or tested, and nothing in the output tells you they were skipped.
Failure modes
- Base equals head. The diff is empty, Nx runs no tasks, and the job reports success.
- Base far behind head. This happens when the last green commit is weeks old. The diff grows until the run is close to a full build.
- Base missing from a shallow clone. Many CI systems check out only one commit by default, so the base SHA is not present locally. Fetch enough history for the base to exist, or fetch the full history with
git fetch --unshallow.
Passing the base in explicitly
Compute the base SHA in its own pipeline step. Then pass both commits to the command with --base=<sha> and --head=<sha> instead of relying on defaults.
Tracking the last green commit adds moving parts. Your CI system has to expose pipeline status for each commit, and that status has to be accurate. If a status is lost or a run is retried and the result isn't recorded, the base points at the wrong commit.
#!/usr/bin/env bash
set -euo pipefail
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --unshallow origin
fi
git fetch origin main
HEAD_SHA="$(git rev-parse HEAD)"
if [ "${IS_PULL_REQUEST:-false}" = "true" ]; then
# PR: diff only against where the branch diverged from main.
BASE_SHA="$(git merge-base origin/main HEAD)"
else
# main: last commit whose pipeline passed. An earlier step must get this from
# the CI system's status API. The result is only as reliable as that status data.
BASE_SHA="${LAST_GREEN_SHA:-}"
fi
if [ -z "$BASE_SHA" ] || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
echo "No usable base SHA; running all projects" >&2
npx nx run-many -t build test
exit 0
fi
if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
echo "Base equals head ($HEAD_SHA); refusing an empty affected run" >&2
exit 1
fi
echo "Affected range: $BASE_SHA..$HEAD_SHA ($(git rev-list --count "$BASE_SHA..$HEAD_SHA") commits)"
npx nx affected -t build test --base="$BASE_SHA" --head="$HEAD_SHA"
A CI step that fetches enough history, uses the merge base for pull requests and the last green SHA for main, guards against a missing base and against base equal to head, and then passes --base and --head to nx affected explicitly.
Verifying and fixing the project graph
Everything nx affected reports depends on the project graph being right, so check it before trusting the results. Run nx graph to open an interactive view of projects and their edges. Select the project you just changed and confirm that the dependents you expect are connected to it. If an edge is missing, Nx will not include that dependent. To see which tasks a project has, and whether each setting came from a plugin, targetDefaults, or the project's own file, run nx show project <name> (Nx mental model).
Dependencies static analysis cannot see
Nx infers edges from imports, installed packages, and TypeScript configuration. It cannot see these:
- HTTP or queue calls between services
- Shared config or
.envfiles - Codegen inputs such as OpenAPI or protobuf schemas
- Dynamic imports built from runtime strings
- Scripts that sit outside any project
- Non-JS tooling such as Dockerfiles, Terraform, or Makefiles
Declare these edges with implicitDependencies in the dependent project's project.json or package.json. This makes the dependent project count as affected whenever the named project changes.
Inputs decide what counts as a change
Task inputs control which files a given task cares about, and Nx applies them both to affected detection and to cache hashing. If a shared schema lives at the workspace root, add it as a {workspaceRoot}/... input. Otherwise editing it won't invalidate the tasks that depend on it. In the other direction, keep test files out of a production named input so that changing a spec file doesn't trigger downstream builds.
Nx does not infer dependencies for Go, Java, or Python services by default. You need a plugin for that language or manually declared edges before Nx can track changes in those services.
// apps/orders-api/project.json
{
"name": "orders-api",
// Edges Nx cannot infer from imports:
// orders-api calls payments-api over HTTP and reads
// generated types from the shared-proto project.
"implicitDependencies": ["payments-api", "shared-proto"],
"targets": {
"build": {
"inputs": [
// This project's non-test source files (named input in nx.json)
"production",
// Production files of everything this project depends on
"^production",
// Root-level schema outside any project: editing it must
// mark this build as affected and invalidate its cache
"{workspaceRoot}/schemas/orders.openapi.yaml",
// Shared env file read at build time
"{workspaceRoot}/.env.shared"
]
}
}
}
// nx.json (excerpt): keep spec files out of "production"
// so editing a test does not trigger downstream builds.
// {
// "namedInputs": {
// "default": ["{projectRoot}/**/*"],
// "production": ["default", "!{projectRoot}/**/*.spec.ts"]
// }
// }
Declaring an HTTP and codegen dependency with implicitDependencies, and adding root-level schema and env files as build inputs so editing them marks the project as affected.
When nx affected misleads you, and when to avoid relying on it
Those missing edges are where nx affected causes real damage. A false negative happens when the project graph lacks a dependency, so Nx never schedules the tests that would have caught the regression and the PR goes green. Nothing in the output warns you. You see a short, fast run, which looks the same as a correct one.
False positives look worse but are safe. Changes to the lockfile, nx.json, or the root tsconfig can mark many or all projects as affected. Nx is behaving correctly here. Those files are inputs to every project, so it cannot prove that any project is unaffected. The cost is CI time that varies a lot from one PR to the next. Plan runner capacity for the full-repo case.
| Situation | Failure mode | Mitigation |
|---|---|---|
| Missing graph edge | Required tests skipped | Declare edges and audit the graph |
| Lockfile or root config change | Everything runs | Accept it and size CI for it |
| Two PRs green alone, broken together | Breakage on main | Run a full build on main after merge |
| Flaky test in a rarely touched project | Flakiness goes unnoticed for months | Run the full suite on a schedule |
| Highly connected graph | Small savings | Measure first, then rely on caching |
Two of these come from reduced visibility. Each PR is validated against its own base, so nobody sees the state of the whole repository unless you run a full build regularly. In repos where most projects depend on a few shared libraries, nearly every change affects most of the graph. In that case nx affected adds complexity and saves little time.
Impact on CI speed, cost, and engineering time
Affected and caching work at different layers. nx affected narrows the set of projects Nx considers, based on which files changed between base and head. Computation caching then decides, task by task, whether to run or replay a stored result, based on a hash of that task's inputs. An affected task can still be a cache hit. A changed file may sit outside the task's configured inputs, such as a docs file excluded from test inputs. The same inputs may also have run before, in an earlier CI attempt or on a teammate's machine with a shared cache.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: serial test time ≈ (changed projects + their dependents) × minutes per project.
Assumed inputs: 40 projects, 2 minutes of serial test time each, no cache hits.
| Scenario | Projects tested | Serial test time |
|---|---|---|
| Full run | 40 | 40 × 2 = 80 min |
| Leaf library with 3 dependents | 4 | 4 × 2 = 8 min |
| Core library with 30 dependents | 31 | 31 × 2 = 62 min |
Most of the saving disappears once a change reaches a widely shared library.
The savings also come with ongoing costs:
- Someone has to keep the graph accurate.
- Implicit dependencies have to be declared by hand when static analysis misses them.
- The base commit has to be tracked correctly in CI.
- A scheduled full run is still needed to catch anything the graph misses.
That is why dependency structure matters more than tuning flags. Splitting a large shared library into smaller, focused ones reduces how many projects each change touches. That lowers the number of affected projects on every PR, which is the multiplier in the formula above.
A recommended pipeline shape and the alternatives
That brings us to the pipeline itself. On pull requests, run nx affected for lint, test, and build with --base set to the merge base between the PR branch and main. The merge base reflects only what the branch introduced, so it ignores commits that landed on main since the branch was cut. On main, set the base to the last commit whose pipeline succeeded rather than HEAD~1. If a run fails, the next run still covers the broken change instead of skipping it. Then add a scheduled or manually triggered nx run-many job across all projects. This catches anything the project graph missed, such as an undeclared dependency on a config file, a generated asset, or a runtime-only import. It runs off the critical path, so a slow full run never blocks a merge.
| Approach | What it costs | Main risk | Fits best |
|---|---|---|---|
| Affected on PR and main, plus a scheduled full run | Some base-commit plumbing | Graph drift between full runs | Medium to large repos with a graph Nx can infer accurately |
Always run-many |
Every task on every push | Slow feedback as the repo grows | Small repos where a full run is already fast |
| Caching only, no affected | Hashing and cache restore for every task | Cache misses on shared inputs cause broad rebuilds | Repos with a reliable remote cache and few cross-project edges |
| Hand-written CI path filters | Ongoing manual upkeep | Filters drift from the real dependencies | Repos with a few unrelated services and no shared libraries |
Choose based on graph shape as well as size. A wide graph with many leaf projects gains the most from affected. A graph where most projects depend on one core library gains little until that library is split, as described above.
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 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.