What seed data as an Nx project means, and why Nx usually can't see it
Most repositories keep seed data in a db/seeds or fixtures folder near the root, or inside the one backend app that first needed it. Both placements give Nx the wrong picture of the repository.
Nx decides what is affected by mapping changed files to projects, then following the project graph. A file that belongs to no project has no owner. Depending on how your workspace defines inputs, a change to it either invalidates far too much, because it is treated as a global input, or invalidates nothing, because no task declares it as an input. In the first case CI reruns every integration suite after a one-line YAML fix. In the second case a stale cache entry passes a test against fixtures that no longer exist.
Bundling seeds into an app project has a different problem. The app's default inputs cover all of its files, so editing a TypeScript handler reruns the seed task. Editing a seed file triggers the app's build and lint. Other services that load the same fixtures never see the change, because they have no graph edge to that app.
The fix is to give seed data its own project, libs/db-seeds, with explicit inputs (the SQL, JSON and YAML files plus the schema they depend on) and explicit outputs. Consumers then depend on that project. Affected detection and the task cache can then follow real changes to the data, not wherever the files happen to sit.
This approach pays off most for backend and platform teams in three situations:
- several services share one database schema
- seeding takes long enough that you notice it
- integration suites depend heavily on fixtures
How the pieces fit: project graph, namedInputs, dependsOn and the cache
The dependency chain
Model the seed data as four kinds of nodes in the project graph:
- Upstream: a
db-schemaormigrationsproject owns the migration files. - The seed project:
db-seedsowns the fixture files and the runner code that turns them into SQL or a data dump. It declares the migrations project as a dependency, either through an import Nx can detect or throughimplicitDependenciesin its project configuration. - Downstream: API and integration-test projects declare
db-seedsas a dependency. - The live database: this is not modeled as a project. A separate target writes to it.
Two targets, two jobs
Split the seed project's work into two targets.
The first is a deterministic build (or validate) target. It reads migrations and fixtures and writes an artifact, such as a SQL file, into a directory listed in outputs. It is safe to cache because the same inputs always produce the same bytes. Nx can restore that directory on a cache hit instead of rerunning the task.
The second is a load target that applies the artifact to a running database. Mark it non-cacheable. Its effect lives in external state that Nx cannot observe or restore.
How inputs shape the hash
Define a named input, for example seedSources, covering fixtures and runner code. Set the build target's inputs to seedSources plus ^production. The ^ prefix pulls in the matching inputs of dependency projects, which here means the migration files.
Nx hashes those file contents together with the command and relevant configuration. Any byte change produces a new hash and therefore a cache miss.
Following a migration change through the graph
Suppose someone adds a migration:
nx affectedmaps the changed file to the migrations project, then walks dependents todb-seeds, the API, and the integration tests.- The seed build's hash changes because of
^production, so the build reruns. - A
dependsOn: ["^build"]on the test targets ensures the fresh artifact exists before the tests start.
A change confined to the README of an unrelated service touches none of these inputs. The seed artifact comes straight from cache.
new migration file (nx affected maps it to a project)
|
v
+--------------------------+
| migrations project |
| migration files |
+--------------------------+
| implicitDependencies or detected import
v
+--------------------------+ +-------------------------------+
| db-seeds | | build hash inputs: |
| fixtures + runner code |------>| seedSources (fixtures, code) |
| | | ^production (migrations) |
+--------------------------+ | command + config |
| | +-------------------------------+
| | | new hash -> cache miss
| v v
| +---------------------------------------+
| | build / validate (cacheable) |
| | writes SQL artifact to outputs dir |
| +---------------------------------------+
| | artifact
| v
| +---------------------------+ +-----------------+
| | load (non-cacheable) |--->| live database |
| +---------------------------+ | (not a project) |
| +-----------------+
+--------------------+ depend on db-seeds (affected)
v v
+--------------+ +-------------------+
| api project | | integration-tests |
+--------------+ +-------------------+
A new migration marks db-seeds and its dependents as affected and changes the build target's input hash, which forces a cache miss; the cacheable build writes the SQL artifact to its outputs directory, and the non-cacheable load target applies it to the live database.
Implementing the db-seeds project step by step
Start by creating libs/db-seeds with its own project.json. Keep fixtures in fixtures/, the runner in src/, and point at the existing migrations directory rather than copying it. That way schema changes stay owned by the API project.
Named inputs
In nx.json, define three namedInputs:
seedFixturescovers{projectRoot}/fixtures/**/*.seedSchemacovers the migrations or schema files.seedRunnercovers{projectRoot}/src/**/*plus the lockfile.
Splitting them makes cache misses easier to diagnose.
The seed:build target
Reference all three named inputs from the seed:build target's inputs array. Add { "env": "SEED_PROFILE" }, and add a { "runtime": "..." } entry that prints the database engine version. Without these, a dump built for Postgres with the minimal profile could be replayed against a different engine or the full profile.
seed:build validates fixtures against the schema and emits a SQL dump to {projectRoot}/dist. Declare that path in outputs and set cache: true, so a cache hit restores the file instead of rebuilding it.
The seed:load target
seed:load writes into a live database, so set cache: false. Nx cannot know whether the database still matches the dump. Make the load idempotent with one of two patterns:
- Truncate the affected tables with
RESTART IDENTITYinside one transaction, then load. - Use
INSERT ... ON CONFLICT DO UPDATEkeyed on stable natural IDs.
Any generated data must use a fixed random seed. Otherwise the dump's contents change on every build, and so do the hashes of any downstream tasks that consume it.
Wiring tests and dependencies
Give each integration-test target a dependsOn of db-seeds:seed:load. Add db-seeds to implicitDependencies in the API and test projects. This makes nx affected mark them whenever a fixture changes.
{
"name": "db-seeds",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/db-seeds/src",
"targets": {
"seed:build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"seedFixtures",
"seedSchema",
"seedRunner",
{ "env": "SEED_PROFILE" },
{ "runtime": "psql --version" }
],
"outputs": ["{projectRoot}/dist"],
"options": {
"command": "node src/build-seed.js --out dist/seed.sql",
"cwd": "libs/db-seeds"
}
},
"seed:load": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["seed:build"],
"options": {
"command": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 --single-transaction -f dist/seed.sql",
"cwd": "libs/db-seeds"
}
}
}
}
A project.json for libs/db-seeds: a cacheable seed:build that hashes the named inputs, SEED_PROFILE and the database client version and emits dist/seed.sql, plus an uncached seed:load that replays the dump in one transaction.
Running it in CI and verifying which tasks run
Pass both refs explicitly in CI: --base set to the merge-base with the main branch (or the last green commit on main) and --head set to the commit under test, then run nx affected for the seed:load and integration-test targets. Explicit refs keep the result from depending on whatever branch state the runner happens to check out.
Fetch enough history. A shallow clone with depth 1 often lacks the base commit. When that happens, nx affected fails or compares against the wrong commit. Fetch full history, or at least enough commits to reach the merge-base.
Add an empty-database guard before the affected run. A fresh CI container starts with no rows, so "nothing affected" does not mean "nothing to load." Query a sentinel table. If it is empty, run db-seeds:seed:load directly with --skip-nx-cache. A cache hit only replays terminal output and files. It does not insert rows.
Before trusting the setup, run nx graph --affected with the same refs. Confirm that the edges run from the API and test projects to db-seeds.
Then make three test commits and compare:
| Change | Projects affected | seed:load |
integration-test |
|---|---|---|---|
| Edit a fixture file | db-seeds, API, tests |
Runs (cache miss) | Runs |
| Edit a migration | db-seeds, API, tests |
Runs (cache miss) | Runs |
| Edit an unrelated library | That library only | Skipped | Skipped |
| Revert the fixture edit | db-seeds, API, tests |
Cache hit (inputs match an earlier hash) | Cache hit |
If the migration row shows "skipped," the migrations directory is missing from the seed project's namedInputs.
#!/usr/bin/env bash
set -euo pipefail
# Shallow clones (depth 1) usually lack the merge-base commit.
# Fetch full history so --base resolves to the right commit.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --no-tags --unshallow origin
fi
git fetch --no-tags origin main
# Explicit refs: compare the commit under test against its merge-base with main.
BASE=$(git merge-base origin/main HEAD)
HEAD_SHA=$(git rev-parse HEAD)
echo "Comparing ${BASE} -> ${HEAD_SHA}"
# Empty-database guard. A fresh container has no rows, and "nothing affected"
# does not mean "nothing to load". A missing or empty table both count as unseeded.
ROWS=$(psql "$DATABASE_URL" -tAc "SELECT count(*) FROM seed_sentinel" 2>/dev/null || echo 0)
if [ "${ROWS:-0}" -eq 0 ]; then
# Skip the cache: a cache hit only replays logs and output files.
# It never inserts rows into the database.
npx nx run db-seeds:seed:load --skip-nx-cache
fi
# Record which projects are affected, using the same refs as the run below.
# Check that the API and test projects depend on db-seeds.
npx nx graph --affected --base="$BASE" --head="$HEAD_SHA" --file=affected-graph.json
# Run only the affected seed and integration-test tasks.
# seed:load should be idempotent, because the guard above may already have run it.
npx nx affected -t seed:load integration-test --base="$BASE" --head="$HEAD_SHA"
A CI script that fetches enough git history to resolve the merge-base and seeds an empty database even when nothing is affected, then writes the affected graph and runs seed:load and integration-test against explicit base and head refs.
Pitfalls and when to avoid this approach
Every row in that table depends on the inputs being right, and most failures in this setup come from getting them wrong in one of two directions.
| Pitfall | What you see | Why it happens | Mitigation |
|---|---|---|---|
| Inputs too narrow | Stale data after a change | A shared constants module or an SQL \i include isn't listed, so the hash doesn't move |
List every file the seed script reads, including transitive includes |
| Inputs too broad | Every PR seeds and tests everything | A glob like {workspaceRoot}/** changes on any commit |
Scope globs to the seed, migration and loader directories |
| Side effects cached | Tests fail against an empty database | A cache hit replays logs and outputs, but it never writes rows | Keep the write step uncached and cache only fixture generation |
| Non-deterministic data | Flaky tests that pass on rerun | now() or random IDs make outputs vary even when inputs match |
Use fixed seeds and fixed timestamps |
The remote cache needs the same care as any artifact store. Fixtures derived from production, even anonymized ones, end up on every machine that can read the cache. Keep those fixtures out of cached outputs.
Affected detection compares against a base ref. In CI, a shallow clone or the wrong base branch either fails or treats everything as changed. Fetch enough history to reach the merge base.
Tests that quietly read rows seeded by another project are coupling the graph can't see. Declare that dependency explicitly, or the tests will break when nothing looks affected.
If your seeds run in a few seconds and the repo is small, rerunning them every time costs less than maintaining this configuration.
Alternatives to modeling seeds in Nx
Modeling seeds as an Nx project is one option among several. Each alternative moves the cost somewhere else, either into CI minutes, image maintenance, or test design.
| Approach | What triggers a reseed | Main cost | Better fit when |
|---|---|---|---|
| Seed on every CI run | Always | Repeated runtime on every pipeline | Seeding takes seconds and correctness matters more than speed |
| Seeds inside the app project, broad inputs | Any change to the app | Over-invalidation: an unrelated edit reruns fixtures | One app owns the database and seeds are cheap |
| Prebuilt database image or snapshot | Schema or seed change, rebuilt in a separate pipeline | Image registry storage and a second build to maintain | Seeding is slow (large datasets, many migrations) and the schema changes rarely |
| Per-test factories or transactional isolation | Never; each test builds and rolls back its own data | Upfront refactoring of tests away from shared fixtures | Tests currently interfere with each other through shared rows |
| File-hash check in a CI script | Hash of seed files differs from stored value | You build your own caching, with no graph or remote cache | The repo doesn't use Nx, or only one pipeline needs the check |
Factories and transactional isolation do more than speed things up. They remove the ordering dependency between seeding and tests, so the problem the previous section describes no longer exists. That is usually the most robust long-term fix, but it costs more to adopt.
Snapshots pair well with Nx rather than replacing it. Nx can decide when the image needs rebuilding, and tests then start from the image without paying for seeding at all.
Impact on development cost and engineering time
Most of the cost is front-loaded. Someone has to create the project, list every file and environment variable the seeds depend on in inputs, and wire dependsOn into the test targets. After that, the cost is upkeep. When seeds start importing a new shared module or reading a new migration directory, the inputs must change too. If they don't, you get false cache hits: Nx replays a "successful" seed that no longer matches the schema. These are the expensive failures, because they show up as confusing test results instead of an obvious error.
The savings come from runs where seeding and integration tests are skipped because nothing they depend on changed. Developers also get faster local feedback. Ownership becomes clearer: a fixtures project with explicit inputs tells reviewers who should look at a change.
To estimate break-even, use these formulas:
- Monthly engineer-hours saved = CI runs × fraction unaffected × task minutes ÷ 60 × fraction on someone's critical path
- Break-even months = setup hours ÷ (monthly hours saved − monthly upkeep hours)
Illustrative estimate (assumed numbers, not a benchmark). Assume:
- 400 CI runs per month
- 60% of runs don't affect the seeds
- Seeding plus integration tests take 8 minutes
- 25% of runs block a waiting developer
Saved time: 400 × 0.6 × 8 ÷ 60 × 0.25 = 8 engineer-hours/month. Assume upkeep plus amortized debugging of false hits costs 3 hours/month, and setup takes 16 hours. Then break-even is 16 ÷ (8 − 3) ≈ 3.2 months.
If your seeds change in most PRs, the fraction unaffected drops toward zero. In that case the setup never pays back.
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 seed data, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.