ORM Seed Script Alternative: One Data Shape for Every Stack
Every ORM gives you a way to fill a dev database: Prisma's db seed
command, TypeORM seeders, Rails' db/seeds.rb, Django fixtures, and
Laravel seeders and factories. For a single-language app that's the
right tool. It gets harder when the same fake users and orders are needed
by a Node API, a Python worker, a mobile team's mock server, and a
Playwright suite. That's four copies of the same data in four languages,
and they slowly stop matching. This post describes an ORM seed script
alternative for that situation. You define the data shape once, fetch it
as JSON over HTTP from any stack, and let each stack's own ORM or SQL do
the insert.
This post doesn't cover what to seed, how many rows, or in what table order; seeding a local dev database covers that. The question here is where the seed logic lives. It can live inside each framework's seed file, or it can live in one definition that every stack reads from. One point holds throughout: JsonFabrica never connects to your database. It returns JSON, and your code does the writes.
How framework seed scripts work in Prisma, TypeORM, Rails, Django, and Laravel
The common tools all follow the same pattern: seed code lives in the repo and is written in the app's language against its own models.
- Prisma: you point the seed command at a script, through
migrations.seedinprisma.config.ts(Prisma 7), or theprisma.seedkey inpackage.jsonon Prisma 6 and earlier. Thennpx prisma db seedruns it. The script usually callsprisma.<model>.createorcreateMany. - TypeORM: TypeORM has no built-in seed command. Teams usually add a
community package such as
typeorm-extension, which provides seeder classes and factories. - Rails:
db/seeds.rbis plain Ruby with full access to your ActiveRecord models. It runs withbin/rails db:seed. - Django: fixtures are JSON, YAML, or XML files in Django's
serialization format, loaded with
python manage.py loaddata. Many teams also write a custom management command that builds rows in code. - Laravel: seeder classes, usually backed by model factories that use
Faker, run with
php artisan db:seed.
All of these are free, they live next to the code they seed, and most are checked against your models by the type system or the ORM. In a monolith, that combination is hard to beat.
Where ORM-bound seed data breaks down: four stacks, four copies
Problems start once a second language needs the same entities. Picture a product with a Node API on Prisma, a Python worker that processes orders, a mobile team running a mock server, and an end-to-end suite in Playwright. Each needs "a realistic user with a few orders," and each writes its own version:
prisma/seed.tsbuilds users with a TypeScript faker library.- The worker's Django command builds them with Python's
Faker. - The mobile team hand-edits a
users.jsonfixture for the mock server. - The Playwright suite has its own factory function in a test helper.
On day one these describe the same user. Six months later the API has
added a role field and the worker still assumes every user is a
member. The mock server's orders use total while the real API returns
totalCents. The Playwright factory makes emails that fail the signup
form's validation. None of these problems comes from the ORM. They come
from having four separate definitions of the same record with nothing
keeping them in sync.
This is a narrower problem than contract-test fixtures across services, which is about payloads inside consumer and provider checks. Here the concern is the generation logic in seed scripts. Each ORM-specific seed file holds its own logic for what a user looks like, written in a language the other stacks can't reuse.
An ORM seed script alternative: define the shape once, insert with your own ORM
The alternative splits seeding into two steps that ORM seeders normally combine:
- Generate: produce realistic records in a known shape. This is the step that can be shared.
- Insert: write those records to a specific database with a specific ORM. This step stays in each stack, where it belongs.
With JsonFabrica, step 1 is a template, which is a JSON document with
placeholders, stored once and callable over HTTP. Here's a users
template:
<createSeq('userId', 'uuid')><createSeq('userNo', 'number', 1)><setVar('first', getRandomName())><setVar('last', getRandomSurname())>{
"id": "<getSeq('userId')>",
"name": "<getVar('first')> <getVar('last')>",
"email": "<toLowerCase(getVar('first'))>.<toLowerCase(getVar('last'))>.<getSeq('userNo')>@example.com",
"role": "<getRandomElement('member', 'member', 'member', 'admin')>",
"createdAt": "<getRandomDate('2025-01-01T00:00:00Z', '2026-09-01T00:00:00Z')>"
}
The email is built from the same first and last name as name, so the
two fields agree, and the getSeq counter
keeps every email unique for tables with a unique constraint on that
column. The id comes from a
createSeq sequence of type uuid. That
isn't a random v4 UUID: the counter is formatted into the last 12 hex
digits, as in 00000000-0000-4000-8000-00000000002a, which is a valid
value for a Postgres uuid column, Prisma, or Django's UUIDField. Your
ORM inserts it as an explicit primary key, which lets child records
point at it before anything touches the database.
The orders template doesn't declare a userId, because the batch
request fills it in:
<createSeq('orderId', 'uuid')>{
"id": "<getSeq('orderId')>",
"status": "<getRandomElement('pending', 'paid', 'paid', 'paid', 'shipped', 'refunded')>",
"totalCents": <getRandomNumber(500, 25000)>,
"placedAt": "<getRandomDate('2025-06-01T00:00:00Z', '2026-09-01T00:00:00Z')>"
}
The two templates are joined by a batch spec, a small JSON file that every stack sends as-is. Commit it to a shared repo, or copy it; it's a few lines:
{
"seed": 20260925,
"documents": [
{ "templateId": "tpl_seed_user", "alias": "users", "count": 200 },
{
"templateId": "tpl_seed_order",
"alias": "orders",
"count": 800,
"relations": { "userId": { "from": "users.id", "strategy": "round-robin" } }
}
]
}
The batch API generates users first,
then writes a real users.id into each order's userId. The only
strategy is round-robin: it cycles through the users in order, so with
200 users and 800 orders, each user gets exactly four orders. The fixed
seed makes the random values repeat across runs, and the response
echoes it back so you can log it. Two caveats apply. First, sequence
values like the id and the email counter are durable and keep counting
up between runs. That keeps them unique, but they aren't replayed.
Second, small batches return their results directly with a 200, while
larger ones return 202 with a batchId that you poll.
Prisma seed alternative: feed createMany from the batch
On the Node side, npx prisma db seed stays the entry point. The seed
file now fetches its data instead of building it. First, a small client
that works for both the 200 and the 202 response:
// prisma/jsonfabrica.ts
const API = 'https://api.jsonfabrica.com/v1';
const headers = {
Authorization: `Bearer ${process.env.JSONFABRICA_API_KEY}`,
'content-type': 'application/json',
};
export async function generateBatch(spec: unknown) {
const res = await fetch(`${API}/batches`, {
method: 'POST',
headers,
body: JSON.stringify(spec),
});
if (!res.ok) throw new Error(`batch submit failed: ${res.status} ${await res.text()}`);
let batch = await res.json();
// Small batches complete synchronously (200); larger ones are queued (202).
while (batch.status === 'queued' || batch.status === 'running') {
await new Promise((r) => setTimeout(r, 1000));
const poll = await fetch(`${API}/batches/${batch.batchId}`, { headers });
if (!poll.ok) throw new Error(`batch poll failed: ${poll.status} ${await poll.text()}`);
batch = await poll.json();
}
if (batch.status !== 'completed') throw new Error(`batch failed: ${JSON.stringify(batch.error)}`);
console.log(`[seed] batch=${batch.batchId} seed=${batch.seed}`);
return batch.results; // generated documents, grouped by alias
}
Then the seed script itself, where Prisma does every write:
// prisma/seed.ts (Prisma 7: generated client plus a driver adapter)
import { readFileSync } from 'node:fs';
import { PrismaClient } from '../generated/prisma/client'; // your generator's `output` path
import { PrismaPg } from '@prisma/adapter-pg';
import { generateBatch } from './jsonfabrica';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
async function main() {
const spec = JSON.parse(readFileSync('seed/batch.json', 'utf8'));
const { users, orders } = await generateBatch(spec);
await prisma.user.createMany({ data: users });
await prisma.order.createMany({ data: orders });
}
main().finally(() => prisma.$disconnect());
The template's keys match the Prisma model's field names, so the JSON
passes straight through. The cost is type safety. A Faker-based seed file
is checked by tsc, while users here is untyped JSON until it reaches
Prisma's runtime validation. If you want an error before the insert, run
the arrays through a schema validator such as Zod, or type the response
by hand. Either way, Prisma still owns the connection, the transaction
semantics, and the insert.
Same template in Python: Django bulk_create from the same JSON
The Python worker sends the same batch spec and gets records of the same
shape with the same seeded values: the same names, roles, dates,
statuses, and totals. The ids and email counters are fresh, because
each run is a new batch and sequences keep advancing. If two stacks need
identical rows, have one of them fetch a batch the other already made
with GET /v1/batches/{batchId}, or share the saved batch response,
batch-result.json, from the shell example below (not the
seed/batch.json spec, which only produces new rows). Django's ORM does
the insert, with a thin mapping from the template's camelCase keys to the
model's snake_case fields:
# worker/management/commands/seed_dev.py
import json, os, time
import requests
from django.core.management.base import BaseCommand
from django.db import transaction
from worker.models import User, Order
API = "https://api.jsonfabrica.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['JSONFABRICA_API_KEY']}"}
def generate_batch(spec):
res = requests.post(f"{API}/batches", json=spec, headers=HEADERS, timeout=30)
res.raise_for_status()
batch = res.json()
while batch["status"] in ("queued", "running"):
time.sleep(1)
poll = requests.get(f"{API}/batches/{batch['batchId']}", headers=HEADERS, timeout=30)
poll.raise_for_status()
batch = poll.json()
if batch["status"] != "completed":
raise RuntimeError(f"batch failed: {batch.get('error')}")
print(f"[seed] batch={batch['batchId']} seed={batch['seed']}")
return batch["results"]
class Command(BaseCommand):
help = "Seed the dev database from the shared JsonFabrica batch spec"
def handle(self, *args, **options):
with open("seed/batch.json") as f:
results = generate_batch(json.load(f))
with transaction.atomic():
User.objects.bulk_create(
User(id=u["id"], name=u["name"], email=u["email"],
role=u["role"], created_at=u["createdAt"])
for u in results["users"]
)
Order.objects.bulk_create(
Order(id=o["id"], user_id=o["userId"], status=o["status"],
total_cents=o["totalCents"], placed_at=o["placedAt"])
for o in results["orders"]
)
The worker's seed command now contains no generation logic of its own.
When the API team adds a field to the users template, the worker gets
it on the next run and only has to decide whether to map it. The same
approach works in db/seeds.rb with ActiveRecord's insert_all, or in a
Laravel seeder that passes the JSON to DB::table()->insert().
Language-agnostic seed data with no ORM: curl, jq, and psql
Some consumers have no ORM, such as a mock-server database, a CI job, or a one-off local reset. Plain shell is enough for these:
API=https://api.jsonfabrica.com/v1
AUTH="Authorization: Bearer $JSONFABRICA_API_KEY"
BATCH_ID=$(curl -fsS -X POST "$API/batches" -H "$AUTH" \
-H 'content-type: application/json' -d @seed/batch.json | jq -r .batchId)
[ -n "$BATCH_ID" ] && [ "$BATCH_ID" != null ] || exit 1
while :; do
curl -fsS "$API/batches/$BATCH_ID" -H "$AUTH" > batch-result.json || exit 1
jq -e '.status == "queued" or .status == "running"' batch-result.json > /dev/null || break
sleep 1
done
jq -e '.status == "completed"' batch-result.json > /dev/null || { cat batch-result.json; exit 1; }
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-v users="$(jq -c '.results.users' batch-result.json)" \
-v orders="$(jq -c '.results.orders' batch-result.json)" <<'SQL'
INSERT INTO users (id, name, email, role, created_at)
SELECT id, name, email, role, "createdAt"
FROM jsonb_to_recordset(:'users'::jsonb)
AS u(id uuid, name text, email text, role text, "createdAt" timestamptz);
INSERT INTO orders (id, user_id, status, total_cents, placed_at)
SELECT id, "userId", status, "totalCents", "placedAt"
FROM jsonb_to_recordset(:'orders'::jsonb)
AS o(id uuid, "userId" uuid, status text, "totalCents" int, "placedAt" timestamptz);
SQL
Postgres does the insert and the column mapping. The mobile team's mock
server can read batch-result.json directly. A Playwright global setup can call
the same generateBatch helper as the Prisma seed. All four consumers
now start from one definition of a user. That means the same shape and
the same seeded values everywhere, not byte-identical rows, since each
run gets fresh sequence-backed ids and emails.
Framework seed script vs API test data: the honest trade-offs
Moving data generation out of the ORM changes several things, not all of them for the better:
- Cost: framework seeders are free. Generation calls to JsonFabrica count toward your plan's usage.
- Network: a framework seeder runs offline. The API approach needs network access and an API key wherever seeding runs.
- Type safety: Prisma seeds and typed factories fail at compile time when the schema changes. API-generated JSON fails at insert time unless you validate it first.
- Where the shape lives: a framework seeder keeps it in the repo, next to the models. With the API it lives in a template, and a batch spec in the repo points at it.
- Sharing: a framework seeder serves one language. The API serves anything that can make an HTTP request.
- Inserts: this doesn't change. Your ORM or SQL does the writes in both cases, and JsonFabrica never touches the database.
When to keep your framework seed script
Don't adopt this approach just because it exists. Keep the framework seeder, and skip the API, in these cases:
- A single-language monolith. If one Rails app or one Laravel app is the only consumer of the data, there's nothing to keep in sync. A seeder with factories is free, lives in the repo, and your team already knows it.
- The seed rows are reference data. Roles, subscription plans, countries, feature flags, and the admin account you log in with are hand-picked values that must be exact, and they often change alongside migrations. Keep them in code.
- Seeding has to run your app's own logic. If a user only counts as
valid after your auth library hashes the password, or seeding an
organization should go through
createOrganizationWithOwner()to exercise the real code path, a seeder that calls your models and services is the right tool. Bulk inserts also skip most model hooks: Django'sbulk_createdoesn't callsave()or send signals, and Rails'insert_allskips validations and callbacks. That's true whether the data came from an API or not. - The seed must work offline. Air-gapped CI, locked-down build networks, and working on a plane all rule out a call to an external API.
- You want compile-time guarantees. If catching schema drift in
tscmatters more than sharing data across stacks, a typed Prisma seed does that and an HTTP response doesn't. - Per-test factories.
factory_bot, Laravel factories inside PHPUnit, and similar tools create exactly the rows one test needs, in-process and fast. That's a different job from bulk seeding. Keep them.
Most teams that adopt the API end up with a hybrid. The framework seed command stays the entry point, reference data stays in code, and only the bulk fake entities come from a shared template, because those are the records that drift when four stacks define them separately. It's usually time to switch when a second language needs the same records, or when a bug turns out to come from two seed files disagreeing about a field.
FAQ
What is an alternative to an ORM seed script? Instead of writing the fake data inside an ORM-specific seed file, you define the data shape once as a template in a generation service, fetch the records as JSON over HTTP, and hand that JSON to your existing ORM or SQL for the insert. Your framework's seed command can stay the entry point. Only the source of the data changes, and every language in the stack gets it from the same place.
Is there an alternative to Prisma db seed for sharing data with other languages?
You can keep npx prisma db seed and change what the seed file does.
Instead of building users with Faker calls in TypeScript, the seed file
fetches generated JSON from a JsonFabrica batch and passes it to
prisma.user.createMany. A Python service or a psql script can send
the same batch spec and insert records of the same shape with its own
tools. With a fixed seed they get the same names and values, but fresh
sequence-backed ids and emails; for identical rows, fetch the existing
batch by its batchId instead.
Can JsonFabrica insert seed data directly into Postgres or MySQL? No. JsonFabrica has no database connector and never writes to a database. Its API returns generated JSON over HTTP from templates and batches. Your own code does the insert, whether that's Prisma, Django's ORM, ActiveRecord, or a raw SQL statement.
How do I keep foreign keys valid when seed data comes from an API?
Generate the parent and child records in one batch request and declare a
relation on the child, for example from: "users.id" with the
round-robin strategy. The batch copies a real parent id into each child
record, cycling through the parents in order. Then insert the parent
table first and the child table second with your own ORM or SQL.
Is seed data generated through an API reproducible?
Yes, if you pass a fixed seed value on the request. The batch response
echoes the seed it used, and a single-template generate response echoes
it in meta.seed, so the same seed gives the same random names, dates,
and choices on every run. Values drawn from sequences are the exception:
sequences are durable counters that keep advancing between runs, which
keeps IDs unique.
When should I keep using my framework's seed script? Keep it when the app is a single-language monolith with nothing to share, when the seed rows are hand-picked reference data like roles or plans, when seeding has to run your application's own logic such as password hashing or model callbacks, when the seed must work offline, or when you want the data type-checked at compile time. Many teams keep the framework seeder for reference data and fetch only the bulk fake records from a shared template.
If your users and orders are defined in three languages and have started
to disagree, move their definition into one template and keep the inserts
where they are. JsonFabrica's batch API
generates related records as JSON, with round-robin relations and a
reproducible seed, for any stack that can make an HTTP request.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.