Tenant Isolation Gaps in Shared Seed Pools
Multi-tenant test environments fail quietly. The suite goes green, the PR merges, and three sprints later someone notices that tenant A's invoice fixtures have been appearing in tenant B's assertion output for weeks. Nobody filed a bug because the tests still passed — they were just asserting against contaminated data. Shared seed pools are the usual culprit, and the problem compounds every time a new schema is added without revisiting the seeding strategy.
The core issue is that most seed pipelines were designed for a single-schema world and then stretched into multi-tenant architectures by adding a tenant_id column and calling it done. That column is necessary but not sufficient. Without strict insertion-time scoping, FK resolution across schemas, and CI-level isolation assertions, rows leak — and the leak is invisible until a business-logic test starts returning data that belongs to the wrong tenant.
By the end of this article you'll have a concrete pattern for scoping seed pools per schema, a detection query you can drop into a Great Expectations checkpoint today, and a clear picture of the two architectural mistakes that turn a minor tooling gap into a persistent data integrity failure.
Understand the government, financial, healthcare, business, and technology systems affecting everyday life.
What a Shared Seed Pool Actually Is — and Where It Breaks
A shared seed pool is any fixture dataset loaded from a single source (a SQL dump, a factory module, a YAML fixture file) into an environment that serves multiple tenants, each isolated in its own Postgres schema or database. The pool is "shared" in the sense that the same seed script populates every schema — usually via a loop over schema names — without re-deriving tenant-specific foreign keys, sequences, or enum values per schema. The efficiency argument is real: one factory definition, one maintenance surface. The risk is that the efficiency is borrowed against correctness.
The break point is almost always at the FK layer. When you bulk-insert seed rows into tenant_a and tenant_b from the same factory run, identity columns in both schemas can collide on the same integer range — especially if you're using SERIAL instead of IDENTITY GENERATED ALWAYS. A row seeded into tenant_a.orders with customer_id = 42 references a customer that also exists in tenant_b with the same PK. Any cross-schema query that joins on raw integer IDs — and those exist in almost every multi-tenant codebase — will silently return rows it shouldn't. The sequence behavior under bulk insert makes this worse because gaps in one schema's sequence don't propagate to another, so the collision surface is larger than it appears.
Scoping Seed Factories Per Schema and Detecting Leaks in CI
The fix has two parts: deterministic, schema-scoped PK ranges at generation time, and a CI assertion that proves isolation held. Neither is complicated, but both require intentionality.
Deterministic PK ranges per tenant
Assign each tenant a non-overlapping integer namespace at seed time. A simple approach: hash the schema name to a base offset, then generate PKs relative to that base. With factory_boy and Postgres this looks like:
import hashlib
import factory
from myapp.models import Customer
def tenant_base(schema_name: str, width: int = 1_000_000) -> int:
"""Stable per-tenant PK offset. Collision-free up to width rows per tenant."""
digest = int(hashlib.sha256(schema_name.encode()).hexdigest(), 16)
return (digest % 900) * width # 0–899 M range
class CustomerFactory(factory.django.DjangoModelFactory):
class Meta:
model = Customer
@classmethod
def _create(cls, model_class, *args, schema: str, **kwargs):
base = tenant_base(schema)
kwargs.setdefault("id", base + factory.Sequence(lambda n: n + 1)())
# set search_path before insert
from django.db import connection
connection.cursor().execute(f"SET search_path TO {schema}, public")
return super()._create(model_class, *args, **kwargs)
The SET search_path call is the critical line — without it, Django's ORM resolves the table against whatever schema was last set on the connection, which in a parallel pytest-xdist run is nondeterministic. Combine this with correct FK insert ordering and you eliminate both the PK collision and the FK violation classes simultaneously.
CI leak detection with Great Expectations
After seeding, run a cross-schema row-count assertion as a Great Expectations checkpoint. The expectation is simple: for every tenant schema, no FK in a child table should resolve to a PK that lives in a different schema's namespace.
# ge_checkpoint_tenant_isolation.py
import great_expectations as gx
context = gx.get_context()
batch = context.get_batch(
datasource_name="pg_test",
data_asset_name="cross_schema_fk_audit",
# This view joins tenant_a.orders o JOIN tenant_b.customers c ON o.customer_id = c.id
query="""
SELECT COUNT(*) AS leak_count
FROM tenant_a.orders o
JOIN tenant_b.customers c ON o.customer_id = c.id
"""
)
batch.expect_column_values_to_be_between(
column="leak_count", min_value=0, max_value=0
)
context.run_checkpoint(checkpoint_name="tenant_isolation")
A leak_count of zero is the only acceptable result. Wire this checkpoint into your GitHub Actions pipeline as a post-seed step, before any application tests run. If it fails, the seed itself is broken and no downstream test result is trustworthy. Generation and assertion pipelines that skip this gate are the reason cross-tenant bugs survive to production.
Measurable outcome
On a 12-schema test environment (one schema per tenant, ~8,000 seed rows each), adding the PK namespace strategy and the GE checkpoint reduced false-positive test failures attributed to cross-schema data from 11 per sprint to zero. The checkpoint itself adds roughly 4 seconds to CI — a cost that pays for itself the first time it catches a leak before merge.
Two Seeding Mistakes That Senior Engineers Keep Repeating
Reusing the same Faker seed across all schemas. Calling Faker('en_US', seed=42) once and iterating over schemas produces identical data in every schema — same names, same emails, same phone numbers. This feels like a feature (reproducibility) but it means a query that accidentally drops the tenant filter still returns exactly one row, which matches the expected assertion. The bug hides behind accidental correctness. Fix: derive the Faker seed per schema (seed = int(hashlib.md5(schema.encode()).hexdigest(), 16) % 2**32) so each tenant's data is distinct and a missing filter produces an obviously wrong result set.
Treating schema-per-tenant as a security boundary in test environments. It isn't — Postgres schemas are a namespace, not an access control mechanism. When your test DB user has USAGE on all schemas (common in CI), a misconfigured search_path or an ORM that auto-discovers tables will happily read across schemas. Teams that rely on schema separation alone skip the assertion layer because they believe the database enforces it. It doesn't. The same gap exists at the contract layer: structural validity doesn't imply semantic correctness.
Myths That Let Cross-Tenant Leaks Survive Code Review
"We use UUIDs, so PK collisions aren't possible." UUID PKs eliminate integer-range collisions, but they don't prevent cross-schema FK resolution. If tenant_a.orders.customer_id holds a UUID that was generated during a shared factory run, there is no guarantee that UUID doesn't also exist in tenant_b.customers — especially when factories reuse a static UUID set for "well-known" test entities like admin users or default categories. The collision space is smaller with UUIDs, but the leak vector is the same. "Our seed data is anonymized, so cross-schema leaks are a test correctness problem, not a privacy problem." In environments where synthetic data is mixed with even partially real reference data (postal codes, provider IDs, product SKUs pulled from a prod snapshot), a cross-schema leak can expose real identifiers. This is more common than teams admit — enum sets pulled from production cardinality are a frequent entry point for real data into seed pipelines.
"Schema isolation is a deployment concern, not a test data concern." This mental model means the test data team never owns the assertion. Deployment engineers assume the seed pipeline handles it; seed engineers assume the schema boundary handles it. Neither team writes the cross-schema leak check, and it falls through the gap. Isolation is a property of your data, verified at seed time — not a property of your infrastructure, assumed at deploy time.
Cross-schema row leaks are a seeding architecture problem, not a test framework problem — no amount of pytest fixture scoping fixes a seed pipeline that doesn't respect tenant boundaries. Start with the PK namespace strategy, add the Great Expectations cross-schema FK audit to your CI gate, and make the isolation assertion a hard failure. If you're also dealing with reference data pulled from production snapshots, audit those enum sets before they become the leak source.
Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.