What are inferred targets in Nx, and how do they change project configuration?

Inferred targets, the core of what the Nx team called Project Crystal, move project configuration out of per-project files and into plugins. You register a plugin in the plugins array of nx.json. While Nx builds the project graph, each plugin declares a glob pattern, such as **/go.mod or **/pyproject.toml. For every matching file, Nx calls the plugin's createNodes function, which returns project configuration, including targets, for the directory that file lives in.

The benefit is consistency. Without inference, fifty Go services carry fifty nearly identical project.json files, and they drift apart over time. With inference, one function derives build, test, and lint from files that already exist, so a new service gets correct targets as soon as its manifest is committed.

The graph is constructed in this order:

nx.json plugins → glob match → createNodes → merge with project.json → resolved project graph

Explicit configuration still applies. A project.json in the same root is merged on top of the inferred result, and its values win where they overlap. That makes it the escape hatch for one-off cases: a service that needs an extra environment variable or a different output path overrides one field, not the whole target.

Merge order causes surprises when several plugins match the same root. Plugins apply in the order they are listed in nx.json. If two plugins contribute a target with the same name, the later one takes precedence. That is easy to miss when you add a generic plugin after a specific one. Run nx show project <name> to see the final resolved configuration and check which plugin each part came from.

+--------------------------------------+
| nx.json  plugins: [ A, B ]           |
+--------------------------------------+
                   |
                   v
+--------------------------------------+
| glob match per plugin                |
| A: **/go.mod   B: **/pyproject.toml  |
+--------------------------------------+
          |                    |
          v                    v
+------------------+  +------------------+
| A.createNodes    |  | B.createNodes    |
| -> project cfg   |  | -> project cfg   |
+------------------+  +------------------+
          |                    |
          +--------+-----------+
                   v
+--------------------------------------+
| merge per project root               |
|  1. A inferred                       |
|  2. B inferred (same name: B wins)   |
|  3. project.json (its values win)    |
+--------------------------------------+
                   |
                   v
+--------------------------------------+
| resolved project graph               |
|  check: nx show project <name>       |
+--------------------------------------+

Nx runs each plugin registered in nx.json against its glob, calls createNodes for every matching file, merges the inferred configuration in plugin order with project.json applied last, and produces the resolved project graph.

How does inference work from graph computation to task execution?

Inference has two phases. The first builds the graph, and the second runs tasks against it.

Graph phase. When a command needs the project graph, Nx reads nx.json and loads each plugin module listed in plugins. It collects the workspace file list, skipping files ignored by .gitignore and .nxignore. It then matches each plugin's glob against that list, applying the registration's include and exclude filters. Nx passes the matching paths to createNodes, along with the options from that plugin entry and a context object. The returned configurations are merged per project root, with project.json applied last. Nx then calls any createDependencies exports to add edges between projects. The Nx daemon, when enabled, keeps this graph in memory and recomputes it after workspace files change. That is why plugin speed affects every command, not only the first one.

Task phase. When you run nx run payments:build or nx affected -t test, the task runner reads the resolved targets. It orders tasks using dependsOn and the graph edges, then computes a hash for each task from its declared inputs: file sets, runtime command output, and environment variables. If a cached result with that hash exists, Nx restores the terminal output and the declared outputs instead of running the command. Otherwise it runs the command, typically through nx:run-commands with the cwd your plugin set, and stores the result.

The plugin's influence ends after the graph phase. It does not run during task execution. It only decides which targets exist, which commands they run, and what goes into their hashes. That is why the rest of this article concentrates on the correctness of those three things.

Who should write a custom inferred-target plugin, and when should you avoid it?

A custom plugin pays off when the same shape repeats. If you have a dozen Go services that each need build, test, and lint, or many Python packages that all run the same test and type-check commands, a plugin turns one detection rule into consistent targets for every project. It only works if the tool's config file tells you enough to compute cache inputs. A go.mod or pyproject.toml usually does. A Makefile full of ad hoc shell usually does not.

Avoid a plugin when you have a few backend projects, or when each one is configured differently. The costs are real:

  • Hidden configuration. Targets that appear in no file are hard for newcomers to find, so they need to learn nx show project.
  • Upgrade burden. The createNodes API has changed across major Nx versions, and the plugin is code you must migrate.
  • Graph-time cost. The plugin runs whenever Nx computes the project graph, not only when you run a task.
  • Overlapping caches. Go's build cache already skips unchanged packages, so Nx caching adds less for go build than it does for slow linters or code generators.
Approach Setup effort Maintenance Discoverability Cache control
Hand-written project.json Low per project, high in total Drifts as projects multiply High Full, per project
targetDefaults in nx.json Low Low Medium Shared, coarse
Tool-native workspaces Low Owned by the tool High for tool users Tool's own cache only
Custom createNodes plugin High Code you own across upgrades Low without docs Full, computed

What are the alternatives to a custom createNodes plugin?

The table above compares the options at a glance. Here is when each one fits.

  • Hand-written project.json per project. Each Go or Python project declares its own nx:run-commands targets. This is explicit and easy to read, and nothing is hidden. It works well for a handful of projects, but every change to a command, input or output has to be repeated in every file. That is where drift starts.
  • targetDefaults in nx.json. You still need a project.json to declare that a target exists. However, you can set shared inputs, outputs, cache and dependsOn once per target name. This removes most of the duplication without writing plugin code. Its limit is that it cannot compute anything from the project's files. Every project with a test target gets the same defaults.
  • Generators that write project.json. An Nx generator can scaffold the configuration when a project is created. The result is explicit and reviewable, but it is a one-time copy. Later changes to the template do not reach existing projects unless you also write a migration.
  • An existing plugin for the language. Before writing your own, check whether a maintained Nx plugin for Go or Python already infers the targets you need. Adopting one moves the upgrade burden to its maintainers. In exchange, you accept their target names and input choices or override them.
  • The language's own workspace tooling, without Nx orchestration. go.work with go build ./... and go test ./..., or a uv workspace with pytest, already give you multi-module builds and Go's native build and test caches. If you don't need cross-language task ordering, nx affected, or a shared remote cache, this may be enough, and it adds no new layer.
  • Makefiles or shell scripts called from a single Nx target. This is a quick way to bring backend projects into a mixed workspace. However, Nx sees one opaque command and cannot hash precise inputs, so caching is either coarse or disabled.

A custom createNodes plugin makes sense when you want the explicit control of project.json, the zero-duplication of targetDefaults, and configuration that is computed from each project's real manifest, and you are willing to own the code.

How can a createNodes plugin save engineering time?

The savings come from four places.

  1. No per-project boilerplate. A new Go service or Python package gets build, test and lint as soon as its go.mod or pyproject.toml lands. Nobody copies a project.json from a neighbor and edits paths.
  2. One-place changes. Adding -race to every Go test, or adding uv.lock as a cache input, is a single change to the plugin or its options. Without a plugin, it is a change to every project file, plus a review of each one.
  3. Correct caching by default. Because inputs and outputs come from one function, every project gets the same reviewed inputs. That cuts down on unnecessary rebuilds caused by missing outputs and on time spent debugging stale results.
  4. Smaller CI runs. Accurate graph edges let nx affected skip backend projects a change cannot reach, and cache hits skip unchanged work.

Illustrative estimate (assumed inputs, not a measurement): Assume a workspace with 40 backend projects. Assume a workspace-wide change to target configuration, such as a new input or flag, happens 6 times a year. Assume each manual per-project edit, including review, takes 5 minutes. Without a plugin, that is 40 × 6 × 5 = 1,200 minutes, or about 20 hours a year, spent on repeated edits. With a plugin, assume each change takes 60 minutes to make once and verify with nx show project, which is 6 hours a year. Against that, assume 8 hours to write the plugin and 4 hours a year to keep it working across Nx upgrades. In this example, the first year roughly breaks even, and later years save around 10 hours. The estimate changes completely with your inputs. With 5 projects, the plugin costs more than it saves. That matches the guidance above to avoid it for small or irregular workspaces.

CI savings depend on hit rates and task durations specific to your workspace, so measure them by comparing run times before and after rollout rather than assuming them.

How does a createNodes export work?

A createNodes export pairs two things: a glob that identifies marker files, and a function that turns the matched files into project configuration. Nx runs the glob against the workspace file list, then calls your function with the matched paths, the options a user set for the plugin in nx.json, and a context object that includes the workspace root. The function returns a map keyed by project root. Each entry holds that project's targets, plus any metadata you want to attach. Nx merges these results with every other plugin's output to build the project graph.

The exact signatures have changed between Nx releases. Earlier APIs processed one file per call, and newer ones batch the matched files. The context fields and return shape have also shifted. Check the type definitions shipped in your installed @nx/devkit and the API docs for that version before writing code. The skeleton that follows this section is version-neutral and shows the structure only. Do not copy it as-is.

The marker file defines your project boundary, so choose it with care. go.mod and pyproject.toml are natural choices because the backend tool already treats them as module roots. A few cases need explicit handling:

  • Nested markers. A go.mod inside a test fixture or vendored directory creates a project you did not intend. Exclude these paths explicitly.
  • Shared roots. A directory that already has a package.json or project.json gets its configuration merged with your output. Use target names that won't collide with existing ones.
  • Broad globs. A glob that matches too much costs time on every graph computation. Each extra match means more parsing and more projects to hash.
// VERSION-NEUTRAL SKELETON: structure only. Check the createNodes types
// in your installed @nx/devkit for the real signature and return shape.
import { dirname } from 'node:path';

interface GoPluginOptions {
  testTargetName?: string; // set by the user in nx.json plugin options
}

// Paths that contain a go.mod but are not real projects.
const EXCLUDED = [/(^|\/)vendor\//, /(^|\/)testdata\//, /(^|\/)fixtures\//];

export const createNodes = [
  // Marker glob: keep it narrow so graph computation stays cheap.
  '**/go.mod',
  // Batched form: receives every matched file, the options and a context.
  async (files: string[], options: GoPluginOptions = {}, context: { workspaceRoot: string }) => {
    const testTarget = options.testTargetName ?? 'go-test'; // avoid clashing with an existing 'test'
    const projects: Record<string, { targets: Record<string, unknown> }> = {};

    for (const file of files) {
      if (EXCLUDED.some((re) => re.test(file))) continue; // skip nested markers
      const root = dirname(file); // project root = directory holding go.mod
      projects[root] = {
        targets: {
          [testTarget]: {
            command: 'go test ./...',
            options: { cwd: root },
            cache: true,
            inputs: ['{projectRoot}/**/*.go', '{projectRoot}/go.mod', '{projectRoot}/go.sum'],
          },
        },
      };
    }
    // Real APIs wrap this map differently per version; check your typings.
    return projects;
  },
] as const;

A version-neutral createNodes skeleton that globs go.mod, skips vendored and fixture markers, and maps each project root to an inferred, non-colliding test target.

How do you generate targets that wrap the real backend tool?

Each inferred target should be a thin shell around the command your engineers already run by hand. Set the target's command field (the nx:run-commands shorthand) to go build ./..., go test ./..., or pytest, and set options.cwd to {projectRoot}. Nx interpolates that token per project, so the command runs from the directory where go.mod or pyproject.toml lives. That is where the tool expects to resolve modules, read its own config, and write artifacts. Do not reimplement flag parsing or test discovery. When Go or pytest changes behavior, your plugin inherits the change instead of drifting from it.

Run-commands forwards extra arguments, so nx test payments -- -run TestRefund reaches go test unchanged. Developers keep the tool's native interface and only gain caching and orchestration.

Expose the opinionated parts as plugin options in the nx.json plugins entry. At minimum, make the target names configurable, such as buildTargetName and testTargetName, plus a list of extra arguments that is appended to each command. Configurable names let teams avoid collisions with targets they already have. Default arguments like -race or -count=1 then become a workspace decision instead of a hardcoded one.

Scope each registration with include and exclude globs. These globs filter which matched config files the plugin processes. For example, they keep generated or vendored modules out of the graph. Because each registration is independent, you can register the same plugin twice. One entry might cover services/** with integration-test arguments, and another might cover libs/** with plain unit-test settings, each with its own target names and options.

{
  "plugins": [
    {
      "plugin": "./tools/nx-go/plugin.ts",
      "include": ["services/**/go.mod"],
      "exclude": ["services/**/vendor/**", "services/generated/**"],
      "options": {
        "buildTargetName": "build",
        "testTargetName": "integration-test",
        "testArgs": ["-race", "-count=1", "-tags=integration"]
      }
    },
    {
      "plugin": "./tools/nx-go/plugin.ts",
      "include": ["libs/**/go.mod"],
      "exclude": ["libs/**/testdata/**"],
      "options": {
        "buildTargetName": "go-build",
        "testTargetName": "test",
        "testArgs": ["-count=1"]
      }
    }
  ]
}

An nx.json that registers the same local createNodes plugin twice. Each registration has its own include/exclude globs. The services entry gets integration-test targets with race detection, and the libs entry gets plain unit-test targets under different target names.

How do you declare cache inputs and outputs correctly?

Whatever options a plugin entry receives, the inputs it emits decide whether Nx can be trusted. The failure modes are not symmetric. If you under-declare, a change Nx cannot see produces a cache hit that replays old logs and artifacts with a green status. Nobody notices until production behaves differently. If you over-declare, the only cost is a lower hit rate. When in doubt, add the input.

Build inputs from source globs scoped with {projectRoot}, such as {projectRoot}/**/*.go, and from the files the tool treats as authoritative for dependency resolution. Put these into named inputs like goSources or pythonSources so users can override them in nx.json without forking the plugin. Use {workspaceRoot} for shared files, such as a root go.work or a workspace-level uv.lock.

Factor Example Input type
Go sources and modules **/*.go, go.mod, go.sum Fileset with {projectRoot}
Workspace module file go.work Fileset with {workspaceRoot}
Python project config pyproject.toml, uv.lock, poetry.lock Fileset, wrapped in a named input
Toolchain version go version, python --version Runtime
Build-affecting variables CGO_ENABLED, GOOS, GOARCH, GOFLAGS Env

Outputs need the same care. Declare every artifact a downstream step reads, such as binaries, wheels, coverage files and JUnit reports. Otherwise a cache hit restores the logs but leaves the files missing.

Some targets are better left uncached. go test keeps its own result cache, and mypy keeps an incremental cache. Layering Nx caching over them adds little benefit and can mask the tool's own invalidation logic. For those targets, emit cache: false.

import { CreateNodesV2, createNodesFromFiles } from '@nx/devkit';
import { dirname } from 'node:path';

// Infer Go targets for every directory that contains a go.mod.
export const createNodesV2: CreateNodesV2 = [
  '**/go.mod',
  (configFiles, options, context) =>
    createNodesFromFiles((goModPath) => {
      const projectRoot = dirname(goModPath);
      return {
        projects: {
          [projectRoot]: {
            // Named input so users can override it in nx.json without forking.
            namedInputs: {
              goSources: [
                '{projectRoot}/**/*.go',
                '{projectRoot}/go.mod',
                '{projectRoot}/go.sum',
                '{workspaceRoot}/go.work', // shared module file at the root
              ],
            },
            targets: {
              build: {
                command: 'go build -o dist/ ./...',
                options: { cwd: projectRoot },
                cache: true,
                inputs: [
                  'goSources',
                  { runtime: 'go version' }, // toolchain changes invalidate the cache
                  { env: 'CGO_ENABLED' },
                  { env: 'GOOS' },
                  { env: 'GOARCH' },
                  { env: 'GOFLAGS' },
                ],
                // Restored on a cache hit so downstream steps find the binaries.
                outputs: ['{projectRoot}/dist'],
              },
              // go test has its own result cache; skip Nx caching here.
              test: { command: 'go test ./...', options: { cwd: projectRoot }, cache: false },
            },
          },
        },
      };
    }, configFiles, options, context),
];

A createNodesV2 plugin that infers Go build and test targets. The build target gets a named fileset input, runtime and env inputs, and a declared output directory, while the test target sets cache: false so go test's own result cache handles reuse.

How do you keep project graph computation fast and model cross-project dependencies?

createNodes is not a one-time setup step. Nx calls it whenever it recomputes the project graph, and the daemon recomputes after file changes in the workspace. Every millisecond your plugin spends is added to nx run, nx affected, and editor integrations. Follow these rules:

  • Never spawn the backend tool. Calling go list or poetry show from inside createNodes turns a fast file scan into process startup, module resolution, and sometimes network access. Infer everything from files on disk.
  • Use simple readers. Extract the module line and replace directives from go.mod with a line-based parser, and read pyproject.toml with a TOML library. You don't need full semantic resolution to name targets.
  • Keep globs narrow. Match **/go.mod or **/pyproject.toml, not every .go or .py file. A broad glob multiplies the number of times your function runs.
  • Cache computed results. Hash the matched config file together with your plugin options, and store the generated targets keyed on that hash. If the hash hasn't changed, return the stored result without parsing again.

For ordering, use dependsOn in the targets you generate. For example, ^build makes a project's dependencies build first. That only works if Nx knows the dependencies exist. This is the job of createDependencies. It receives the project map and the changed files, and returns edges between projects. Use it to turn a go.mod replace pointing at a local path, or a Python path dependency, into a static edge attributed to that source file.

Be careful when the tool already models the workspace through go.work or a uv workspace. You now have two sources of truth, and they will drift. Derive edges from one of them, and document which one.

How do you verify, test, and roll out the plugin?

Start by checking what Nx actually resolved. Run nx show project <name> on a Go service and a Python service. The output merges your inferred targets with anything still in project.json, so it is the only place you see the final command, cwd, inputs and outputs together. Then open nx graph and confirm that the cross-project edges match your go.mod or pyproject.toml references, with no extra edges and none missing.

Next, test caching with real behavior rather than by reading config. Run a target twice. The second run should report that it was restored from cache. Then touch go.sum or uv.lock and run it again. If that run is still a cache hit, a dependency change can ship stale artifacts, so fix the inputs before going further.

Unit tests

Test the createNodes function on its own. Build a fixture tree under a temp directory with a few go.mod and pyproject.toml files, including edge cases such as a nested module and a malformed file. Call the function with the matched paths and a context that points at that root. Assert on the returned targets, not on a snapshot of the whole object. This keeps a refactor from passing silently.

Rollout

Register the plugin from a local path in nx.json first. For each project, delete one redundant explicit target at a time and diff nx show project before and after, so any behavior change shows up immediately. Publish it as a package only after every project runs on inferred targets.

Document nx show project and the plugin source location for newcomers. Inferred configuration doesn't appear in any project file, so these two are how people debug it.

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 custom plugin createNodes, 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.