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:

  1. Upstream: a db-schema or migrations project owns the migration files.
  2. The seed project: db-seeds owns 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 through implicitDependencies in its project configuration.
  3. Downstream: API and integration-test projects declare db-seeds as a dependency.
  4. 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 affected maps the changed file to the migrations project, then walks dependents to db-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:

  • seedFixtures covers {projectRoot}/fixtures/**/*.
  • seedSchema covers the migrations or schema files.
  • seedRunner covers {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 IDENTITY inside one transaction, then load.
  • Use INSERT ... ON CONFLICT DO UPDATE keyed 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 .env by 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.