How to Fill 5 Databases That Reference Each Other
By Mikhail Shytsko, Founder at Seedfast · · Updated
Where the monolith needed one seed script, your microservices spread across five databases held together by three implicit ID contracts and whatever order someone runs the seeds in.
Microservice database seeding is more than running your seed script five times. You split the monolith into independent services, each team owning its schema and migrations and choosing its own deploy cadence, and on paper the architecture looks clean.
Then someone on the team tries to set up a local development environment. They spin up the user service, seed its database, and start the order service, which immediately throws ERROR: relation "users" does not exist. Of course it does, since that table lives in a different database now. So they seed the order service database too, only to find its orders reference user IDs absent from the user service. The payment service expects order IDs from a different seed run, and the notification service looks up preferences for a user ID that maps to nothing.
You end up with five databases that each work in isolation while none of them agree with one another, which is one of the most underestimated costs of distributed architecture.
One note before we dig in. If your "microservices" share a single PostgreSQL instance and just own different tables, this article is overkill, and Seedfast fills every table for you (see the database seeding guide, or the per-ORM database seeder reference if you're choosing between Prisma, Drizzle, or TypeORM seeders). The strategies here are for teams with genuinely separate databases per service, where nothing auto-coordinates IDs across service boundaries.
In a monolith, seeding is straightforward, and a single seed.sql loads one database in one transaction:
-- seed.sql: everything in one place
INSERT INTO users (id, name, email) VALUES
(1, 'Alice Chen', 'alice@example.com'),
(2, 'Bob Martinez', 'bob@example.com');
INSERT INTO products (id, name, price) VALUES
(101, 'Widget Pro', 29.99),
(102, 'Widget Lite', 9.99);
INSERT INTO orders (id, user_id, product_id, status) VALUES
(1001, 1, 101, 'completed'),
(1002, 2, 102, 'pending');
INSERT INTO payments (id, order_id, amount, status) VALUES
(5001, 1001, 29.99, 'captured'),
(5002, 1002, 9.99, 'authorized');
INSERT INTO notifications (id, user_id, type, message) VALUES
(9001, 1, 'order_shipped', 'Your Widget Pro has shipped'),
(9002, 2, 'payment_pending', 'Complete your payment for Widget Lite');
Foreign keys enforce consistency. One psql command, and the entire application has coherent data; every join works and every API response makes sense.
Now distribute that across five services:
┌──────────────┐ ┌─────────────────┐ ┌───────────────┐
│ User Service │ │ Product Service │ │ Order Service │
│ users_db │ │ products_db │ │ orders_db │
│ │ │ │ │ │
│ users │ │ products │ │ orders │
│ preferences │ │ categories │ │ order_items │
└──────┴───────┘ └────────┴────────┘ └───────┴───────┘
│ │ │
│ ┌────────┴────────┐ │
└────────►│ Payment Service │◄─────────┘
│ payments_db │
│ │
│ payments │
│ refunds │
└────────┴────────┘
│
┌────────▼───────┐
│ Notification │
│ Service │
│ notifs_db │
│ │
│ notifications │
│ templates │
└────────────────┘
There are no foreign keys between these databases. The order service stores a user_id column, but nothing enforces that the user actually exists. Over in the payment service, an order_id sits in a column with no constraint linking it back to the orders database. These are implicit references, contracts that exist in application code, not in database schemas.
Seed one database without the others, and you get orphan records. Seeding them all independently leaves the IDs mismatched, while a wrong order breaks application logic in ways that look like bugs but are actually data inconsistency.
Microservice databases reference each other through several patterns, and each one creates a seeding challenge.
Almost every service stores a user ID. The user service is the source of truth, but every other service has a user_id column pointing back to it. Seed the order service with user IDs 1-100, and the user service with user IDs 500-600, and every order belongs to a nonexistent user.
orders_db.orders.user_id = 42 -> users_db.users.id = ???
payments_db.payments.user_id = 42 -> users_db.users.id = ???
notifs_db.notifications.user_id = 42 -> users_db.users.id = ???
The payment service references not just users but orders too, the notification service references both users and orders, and some services reference products by ID while others use SKU. These implicit contracts form a dependency graph that's invisible to any single service's schema:
notification_service.notifications:
- user_id -> user_service.users.id
- order_id -> order_service.orders.id
- product_sku -> product_service.products.sku
payment_service.payments:
- user_id -> user_service.users.id
- order_id -> order_service.orders.id
order_service.orders:
- user_id -> user_service.users.id
- product_id -> product_service.products.id
Before choosing a seeding strategy, choose an ID type, which shapes every seed scope you write for the rest of the system.
With integer IDs, seed scopes can reference ranges: "orders for user IDs 1–1000". Ranges are human-readable, auditable, and stable across runs as long as the seed order is deterministic. The downside is that services must avoid overlapping ranges, and the moment one service switches to UUIDs, every downstream seed file breaks.
With UUIDs, cross-service collision is impossible by design, but scopes can't be expressed as ranges. The pattern instead seeds the service that owns the ID first, queries the actual values back, then passes them explicitly to the next scope.
# Seed the user service first
SEEDFAST_DSN="$USERS_DB_URL" seedfast seed \
--scope "50 users with profiles and addresses"
# Query the IDs back — works identically for integer or UUID keys
USER_IDS=$(psql "$USERS_DB_URL" -t -A -c \
"SELECT id FROM users ORDER BY created_at DESC LIMIT 50" | paste -sd,)
# Pass them explicitly to the next service's scope
SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \
--scope "200 orders for users with IDs: $USER_IDS"
If you're building a new system and can choose, UUID v7 is the pragmatic default, collision-safe like UUID v4 but time-ordered so range-style reasoning still works.
The cross-service ID contracts doc your team maintains should record the ID type for every entity, since it's the schema contract every seed scope depends on.
First time seeing SEEDFAST_DSN? It's the Postgres connection string Seedfast's CLI reads from the environment, falling back to DATABASE_URL if unset. A dedicated variable keeps Seedfast from clashing with other CI tools that also read DATABASE_URL.
If your services use direct REST or gRPC calls rather than events, skim this section, since the warnings apply only to event-driven architectures.
Some architectures use event sourcing. The order service doesn't call the payment service directly. It emits an OrderCreated event, and the payment service builds its state from that stream. Seeding the payment database directly, without replaying events, produces state that could never exist.
# What actually happens in production:
OrderCreated { order_id: 1001, user_id: 42, total: 29.99 }
-> PaymentService creates payment { order_id: 1001, amount: 29.99, status: "pending" }
-> NotificationService sends "Order received" to user 42
# What happens when you seed databases independently:
payments_db has payment { order_id: 9999 } -- order 9999 doesn't exist
notifs_db has notification for user 7777 -- user 7777 doesn't exist
Inserting straight into the read model leaves the projection version wrong and downstream replays diverging, so the first production-shaped test that reads the event log can't reconstruct the aggregate correctly.
For Postgres-based event stores, the fix is to seed the event log directly and let the projection rebuild:
# Seed the event log, not the read model
SEEDFAST_DSN="$EVENTS_DB_URL" seedfast seed \
--scope "1000 domain events: OrderCreated, PaymentCaptured, OrderShipped,
with realistic aggregate_id grouping and monotonic timestamps"
# Replay events into the read model via your service's projection worker.
# The exact command is service-specific — replace with whatever rebuilds
# your projections (a rake task, a Go CLI subcommand, etc.):
docker compose run --rm order-service project-events --from-genesis
For Kafka-backed stores the same idea applies, but injection happens through a producer script (kafka-console-producer or equivalent); seed the event stream, and let the projection build the read model.
Direct read-model seeding is acceptable only when the read model is rebuilt on every restart (test containers with --rm) and you never need to inspect event history.
When confronted with cross-service seeding, teams typically reach for one of these approaches, each of which looks reasonable at first and then breaks.
The ordering approach seeds users first, then products, then orders, then payments, then notifications, each service after the ones it depends on.
#!/bin/bash
# seed_all.sh -- the script that someone wrote at 2 AM
echo "Seeding user service..."
psql $USERS_DB < seeds/users.sql
echo "Seeding product service..."
psql $PRODUCTS_DB < seeds/products.sql
echo "Seeding order service..."
psql $ORDERS_DB < seeds/orders.sql
echo "Seeding payment service..."
psql $PAYMENTS_DB < seeds/payments.sql
echo "Seeding notification service..."
psql $NOTIFS_DB < seeds/notifications.sql
It works until something shifts underneath it. The product team swaps sequential integer IDs for UUIDs, the user service adds a required tenant_id column, or a new service appears that nobody adds to the script. The seed files reference each other by hardcoded IDs, so any change to one file requires updating all downstream files.
Maintenance cost grows with the square of the number of services.
"Let's just dump 1% of production data from each service."
# "Clever" approach: dump a consistent slice
psql "$USERS_DB_URL" -c "\copy (SELECT * FROM users WHERE id < 1000) TO 'users_slice.sql'"
psql "$ORDERS_DB_URL" -c "\copy (SELECT * FROM orders WHERE user_id < 1000) TO 'orders_slice.sql'"
# The third query needs a cross-database join — already a smell:
psql "$PAYMENTS_DB_URL" -c "\copy (SELECT * FROM payments WHERE order_id IN (...)) TO 'payments_slice.sql'"
You now have real production data sitting in every developer's laptop. GDPR and SOC 2 both restrict where personal data may reside, and developer laptops routinely fail those checks. The subsets are also nearly impossible to keep consistent, since a slice of users from the user service forces a matching slice of their orders, payments, and notifications, a cross-database join across five databases. Someone writes a script that approximates this, and it works 90% of the time. The other 10% produces orphan references that cause subtle, intermittent test failures. For the full rationale on avoiding this pattern, see staging without production data.
A single repository holds JSON or SQL fixtures that every service reads:
test-fixtures/
users.json # { "users": [{ "id": 1, ... }, ...] }
products.json
orders.json # references user IDs from users.json
payments.json # references order IDs from orders.json
This works for small datasets with stable schemas, but it carries the same coupling problem as the ordered script, so changing the users fixture requires updating every fixture that references user IDs. It also forces every service to depend on a shared repository, undermining the independence microservices are supposed to provide.
And the fixtures are always tiny, holding maybe 10 users and 20 orders. Nobody maintains a fixture set with 50,000 users and realistic distributions across five services.
No single tool solves cross-service seeding, but a few strategies hold up better than the anti-patterns above.
Define explicit ID ranges every service agrees on, then seed in dependency order using those ranges.
# seed-config.yaml -- shared convention
id_ranges:
users: 1-10000
products: 100001-110000
orders: 200001-300000
payments: 400001-500000
seeding_order:
- user_service # no dependencies
- product_service # no dependencies
- order_service # depends on users, products
- payment_service # depends on orders, users
- notification_service # depends on users, orders
This works if you enforce the convention and every team respects it. The cost is rigidity, since the ID ranges are arbitrary constraints that don't exist in production and can mask bugs related to ID collision or generation strategy.
Skip direct database inserts and use each service's API to create data, chaining the returned user IDs into order creation and the returned order IDs into payments.
# seed_via_apis.py
import requests
# Create users
users = []
for i in range(100):
resp = requests.post("http://user-service/api/users", json={
"name": f"Test User {i}",
"email": f"user{i}@test.com"
})
users.append(resp.json())
# Create orders using real user IDs
orders = []
for user in users[:50]:
resp = requests.post("http://order-service/api/orders", json={
"user_id": user["id"], # real ID from user service
"product_id": "prod-101",
"quantity": 2
})
orders.append(resp.json())
The approach guarantees consistency, since you're using the actual IDs each service generates, and it triggers events so downstream services (payments, notifications) get their data through the normal event flow.
The downside is that it's slow. Creating 10,000 orders through an API that creates them one at a time takes minutes, and 100,000 takes far too long. You're also limited by API capabilities, and if there's no bulk creation endpoint, you're making N HTTP requests. And if any service is down during seeding, the entire chain breaks.
With plain-language scope descriptions, instead of hardcoding IDs or chaining API calls, you describe what the data should look like and let Seedfast fill in data that's valid and connected.
For each service database, you seed with a scope that describes its role in the broader system:
# Seed user service -- the root of the dependency graph
SEEDFAST_DSN="$USERS_DB_URL" seedfast seed \
--scope "1,000 users with varied profiles, addresses, and preferences"
# Seed product service -- independent root
SEEDFAST_DSN="$PRODUCTS_DB_URL" seedfast seed \
--scope "200 products across 10 categories with pricing tiers"
# Seed order service -- references users and products
SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \
--scope "5,000 orders referencing user IDs 1-1000 and product IDs 1-200,
with realistic status distribution across the last 6 months"
# Seed payment service -- references orders and users
SEEDFAST_DSN="$PAYMENTS_DB_URL" seedfast seed \
--scope "payments for orders with IDs matching the order service,
mix of completed, pending, and refunded statuses"
# Seed notification service -- references everything
SEEDFAST_DSN="$NOTIFS_DB_URL" seedfast seed \
--scope "notifications for users 1-1000, referencing recent orders,
including order confirmations, shipping updates, and payment receipts"
Each database is seeded independently, yet the scope description creates implicit coordination, so user IDs in the order service line up with the user service, and order IDs in the payment service line up with the order service. Within each database Seedfast handles referential integrity, while the cross-service consistency comes from your scope descriptions.
It isn't magic, and you still need to think about which ID ranges overlap. But the maintenance burden drops dramatically; when the user service adds a new column, you don't need to update five fixture files. Re-run the same scope description, and Seedfast adapts to the updated schema.
The scope examples below use integer ranges for readability. For UUID-based systems, compose this with the query-back pattern from the UUID section above.
Let's walk through seeding a complete e-commerce platform with five services, a topology many teams operate.
The Services
- Users (users_db): users, addresses, preferences. No dependencies (root)
- Products (products_db): products, categories, inventory. No dependencies (root)
- Orders (orders_db): orders, order_items. References user_id, product_id
- Payments (payments_db): payments, refunds. References user_id, order_id
- Notifications (notifs_db): notifications, templates. References user_id, order_id
users_service ──┐
├──► order_service ──┬──► payment_service ──┐
product_service ──┘ │ │
└────────────────────────┴──► notification_service
#!/bin/bash
set -euo pipefail
# Phase 1: Seed root services (no cross-service dependencies)
echo "Phase 1: Seeding root services..."
SEEDFAST_DSN="$USERS_DB_URL" seedfast seed \
--scope "1,000 users with names, emails, phone numbers,
billing and shipping addresses, and notification preferences" \
--output plain &
SEEDFAST_DSN="$PRODUCTS_DB_URL" seedfast seed \
--scope "500 products across 15 categories including electronics,
clothing, and home goods, with prices ranging from 5 to 500 dollars,
and inventory counts" \
--output plain &
wait
echo "Phase 1 complete."
# Phase 2: Seed services that depend on roots
echo "Phase 2: Seeding order service..."
SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \
--scope "8,000 orders for user IDs 1-1000 referencing product IDs 1-500,
with 1-5 items per order, status distribution of 60% completed
25% shipped 10% processing 5% cancelled,
spread across the last 12 months" \
--output plain
echo "Phase 2 complete."
# Phase 3: Seed services that depend on orders
echo "Phase 3: Seeding downstream services..."
SEEDFAST_DSN="$PAYMENTS_DB_URL" seedfast seed \
--scope "payments for order IDs 1-8000, with amounts matching order totals,
90% captured 5% authorized 3% refunded 2% failed" \
--output plain &
SEEDFAST_DSN="$NOTIFS_DB_URL" seedfast seed \
--scope "notifications for user IDs 1-1000 about order IDs 1-8000,
including order confirmation, shipping update, and delivery
confirmation types, with timestamps after the corresponding order dates" \
--output plain &
wait
echo "Phase 3 complete. All services seeded."
Because each phase runs its services in parallel, total wall-clock time is roughly the slowest service in each phase, not the sum of all five.
See it run on your setup. Seedfast's phased seeding works against any Postgres-compatible database — Supabase, RDS, Neon, self-hosted. Try it with the getting-started guide.
A successful seed run doesn't always mean the data is consistent across separate databases. Seedfast exits with an error on constraint violations within a single database, but cross-service references are invisible to it, and the order service can successfully insert 8,000 orders referencing users 1–1000, while the user service ended up with only 800 rows because a scope quirk hit a unique constraint at row 801. A one-page verification script catches this before your tests run:
#!/bin/bash
# verify-seed-consistency.sh -- runs after all phases complete
set -euo pipefail
# Dump IDs from each service to temp files
psql "$USERS_DB_URL" -t -A -c "SELECT id FROM users" | sort -u > /tmp/users.ids
psql "$ORDERS_DB_URL" -t -A -c "SELECT user_id FROM orders" | sort -u > /tmp/order-users.ids
# Any user_id in orders that doesn't exist in the user service?
ORPHANS=$(comm -23 /tmp/order-users.ids /tmp/users.ids | wc -l)
if [ "$ORPHANS" -gt 0 ]; then
echo "FAIL: $ORPHANS orders reference non-existent users"
comm -23 /tmp/order-users.ids /tmp/users.ids | head -5
exit 1
fi
echo "OK: orders ↔ users cross-service references are consistent"
The check uses only standard psql and shell tools, with no dblink, no extensions, no superuser. It works on Supabase, RDS, Neon, and anywhere else your databases live. Add a block per cross-service reference you care about (payments ↔ orders, notifications ↔ users, etc.) and the whole check runs in two or three seconds. The script assumes a POSIX shell; on Windows, run it in WSL or Git Bash.
The alternative is losing hours to a phantom 404 when an integration test can't find an entity that "definitely should exist", though a few seconds of verification here saves that hunt.
Plugging phased seeding into Docker Compose or CI/CD is mechanical, taking one environment variable per database, seeding after migrations, and parallelizing within phases. For the full walkthrough (ephemeral Postgres services in GitHub Actions, SEEDFAST_DSN per service, exit-code handling, Docker Compose templates), see CI/CD database seeding.
A few patterns consistently emerge from teams running 5-20 microservices.
Most teams discover their implicit ID dependencies when seeding breaks. Document them explicitly:
# Cross-Service Data Contracts
## user_id
- Source: user-service (users.id, auto-increment)
- Referenced by: order-service, payment-service, notification-service
- Format: integer, 1-based
## order_id
- Source: order-service (orders.id, UUID v4)
- Referenced by: payment-service, notification-service
- Format: UUID
## product_id
- Source: product-service (products.id, auto-increment)
- Referenced by: order-service (order_items.product_id)
- Format: integer, 1-based
That document becomes the source of truth for seeding, testing, and debugging. It also helps new developers understand how data flows across services, which is notoriously opaque in microservice architectures.
Not every E2E test needs all five databases seeded. If you're testing the checkout flow, you need users, products, and orders. You don't need the notification service's database, which can tolerate missing user profiles gracefully. Seed only the services that your test scenario actually exercises, the same minimalism behind scoped E2E test fixtures.
# Checkout flow test: only seed what the checkout touches
SEEDFAST_DSN="$USERS_DB_URL" seedfast seed --scope "10 users with addresses"
SEEDFAST_DSN="$PRODUCTS_DB_URL" seedfast seed --scope "20 products with inventory"
# orders and payments will be created by the test itself
In production, microservice databases are eventually consistent, with windows where the order service holds an order the payment service hasn't processed yet, and your test data can reflect that. Not every cross-service reference needs to be perfect, because some tests specifically need to verify how services handle missing references.
# Deliberately seed some orphan references to test error handling
SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \
--scope "100 orders, 10% with user IDs that don't exist in the user service,
to test the order service's graceful degradation"
Designing for this on purpose means your services should tolerate missing cross-service data, and the tests need to confirm they actually do.
Microservice data seeding is genuinely hard. There's no tool — including Seedfast — that makes it trivial. The fundamental challenge is that microservices trade data consistency for operational independence, and seeding is where that tradeoff becomes obvious.
What you can do is reduce the manual coordination. Define your cross-service contracts, seed in dependency order, and lean on scope descriptions to keep seed runs loosely coupled, all while accepting that perfect consistency across five independently-seeded databases takes deliberate effort.
The monolith's seed.sql stayed simple only because the monolith itself was. Its microservice equivalent is a phased seeding pipeline with explicit ID contracts, more complex but manageable if you treat it as a first-class engineering problem from the start.
What order should I seed microservice databases?
Seed services with no cross-service dependencies first (typically users and products), then services that reference those IDs (orders), then downstream services (payments, notifications). The phased script above parallelizes each phase to cut total wall-clock time.
How do I keep user IDs consistent across five databases?
Either define explicit ID ranges per service in a shared config (users 1–10,000; orders 200,001–300,000) and honor them in every seed scope, or use query-back seeding where you seed the user service first and pass the returned IDs into downstream scopes.
Should I use UUIDs or integer IDs for cross-service references?
UUIDs eliminate collision risk when services generate IDs independently, but they make seed scopes harder to write as ranges. If your services already use integers, keep them and coordinate ranges. If you're starting fresh, UUID v7 gives you collision safety plus time-ordering.
Can I just copy a slice of production data across all five databases?
You can't do it safely. Consistent cross-database subsets require joins across separate hosts, which is nearly impossible to keep referentially clean, and you put real PII on every developer's laptop.
What happens if I seed an event-sourced service by inserting directly into the read model?
You produce state that could never exist in production, aggregates inconsistent with the event log. The correct approach is to seed the event stream and let the projection rebuild from it.
How small should seed volumes be in CI vs local dev?
CI seeds should finish in under 30 seconds, typically 10× smaller than local dev. The phased script here uses 1,000 users and 8,000 orders locally; in CI, drop the same scopes to roughly 100 users and 500 orders, changing only the counts. See also large-volume seeding for tuning Postgres itself when you do need big runs.
Get Started | Documentation | Pricing
Seedfast seeds each database independently while respecting the cross-service relationships you describe, so there are no shared fixtures or coordination scripts to maintain, and production data never leaves production.