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
createNodesAPI 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 buildthan 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.jsonper project. Each Go or Python project declares its ownnx:run-commandstargets. 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. targetDefaultsinnx.json. You still need aproject.jsonto declare that a target exists. However, you can set sharedinputs,outputs,cacheanddependsOnonce 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 atesttarget 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.workwithgo build ./...andgo test ./..., or a uv workspace withpytest, 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.
- No per-project boilerplate. A new Go service or Python package gets
build,testandlintas soon as itsgo.modorpyproject.tomllands. Nobody copies aproject.jsonfrom a neighbor and edits paths. - One-place changes. Adding
-raceto every Go test, or addinguv.lockas 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. - 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.
- Smaller CI runs. Accurate graph edges let
nx affectedskip 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.modinside 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.jsonorproject.jsongets 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 listorpoetry showfrom insidecreateNodesturns a fast file scan into process startup, module resolution, and sometimes network access. Infer everything from files on disk. - Use simple readers. Extract the
moduleline andreplacedirectives fromgo.modwith a line-based parser, and readpyproject.tomlwith a TOML library. You don't need full semantic resolution to name targets. - Keep globs narrow. Match
**/go.modor**/pyproject.toml, not every.goor.pyfile. 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.