Django Test Fixtures: Generate loaddata JSON From a Seeded Template
Django test fixtures are JSON files that loaddata turns into database
rows, and most projects get them in one of two ways: written by hand
until nobody wants to touch them, or dumped from someone's dev
database with dumpdata. Both leave you with a file whose contents
nobody chose on purpose. This post generates the fixture instead. One
seeded template produces a loaddata-ready file with products,
customers and orders whose foreign keys always resolve, plus a separate
table of deliberately invalid inputs for form validation tests. A short
script regenerates both files from a seed, and you commit them.
JsonFabrica's part is small. It's a JSON-over-HTTP API: your script sends it a template and a seed and writes what comes back. There is no Django package or plugin, and your tests never call the API. They load the files with Django's own tools.
The Django loaddata JSON format
A Django fixture is a JSON array. Each item names a model as
app_label.modelname, gives its primary key as pk, and puts every
other field under fields:
[
{
"model": "shop.customer",
"pk": 1,
"fields": {"name": "Ada Byron", "is_active": true}
},
{
"model": "shop.order",
"pk": 1,
"fields": {"customer": 1, "quantity": 2}
}
]
A foreign key is the related object's primary key, and a many-to-many
field is a list of them. python manage.py loaddata shop_test looks
for a file of that name in three places: the fixtures/ directory of
every installed app, each directory in the FIXTURE_DIRS setting, and
the literal path you pass. The extension is optional.
Three properties of loaddata shape everything below:
- Rows are saved as they are in the file. The Django docs say it
directly: your model's
save()method isn't called, andpre_saveandpost_savesignals fire withraw=True. Field validators andfull_clean()don't run either. A quantity of 0 that your model forbids loads without complaint. auto_now_addandauto_nowaren't applied. A raw save stores the value the fixture gives, so acreated_atfield has to be in the file. If it's missing on a non-null column, the load fails.- Foreign keys are checked when the load finishes. On databases
that enforce them, a reference to a row that doesn't exist fails the
whole load with an
IntegrityError, which is why the order of objects within a file mostly doesn't matter.
Hand-maintained fixtures run into all three slowly. Every pk and
foreign key is typed by hand, and since nothing validates on load,
nothing checks that the values make sense until a test fails in a
confusing way.
The models and the form under test
The example is a small shop app with three models. Product has a
natural key, its sku, which comes up again below.
# shop/models.py
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
class ProductManager(models.Manager):
def get_by_natural_key(self, sku):
return self.get(sku=sku)
class Product(models.Model):
sku = models.SlugField(unique=True)
name = models.CharField(max_length=100)
price_cents = models.PositiveIntegerField()
objects = ProductManager()
def natural_key(self):
return (self.sku,)
class Customer(models.Model):
name = models.CharField(max_length=200)
email = models.EmailField()
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Order(models.Model):
customer = models.ForeignKey(
Customer, on_delete=models.PROTECT, related_name="orders"
)
product = models.ForeignKey(Product, on_delete=models.PROTECT)
quantity = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(100)]
)
status = models.CharField(max_length=20, default="pending")
created_at = models.DateTimeField(auto_now_add=True)
Orders come in through a ModelForm. It picks the product by SKU and
rejects inactive customers:
# shop/forms.py
from django import forms
from .models import Order, Product
class OrderForm(forms.ModelForm):
product = forms.ModelChoiceField(
queryset=Product.objects.all(), to_field_name="sku"
)
class Meta:
model = Order
fields = ["customer", "product", "quantity"]
def clean_customer(self):
customer = self.cleaned_data["customer"]
if not customer.is_active:
raise forms.ValidationError(
"This customer account is inactive.", code="inactive"
)
return customer
The form can fail in four ways, each with its own error code. An
unknown customer or product gives invalid_choice from
ModelChoiceField. An inactive customer gives the custom inactive.
A quantity outside 1-100 gives min_value or max_value from the
model validators, which a ModelForm runs as part of is_valid().
A Django fixtures generator template
One template produces both files the tests need. The fixture array
is the loaddata file, valid rows only. The cases array is a table
of form inputs, valid and invalid, each with the error it should
produce. They come from the same render, so a case can only point at a
customer the fixture just created.
<setVar('customers', getParam('customers', 8))>{
"fixture": [
{
"model": "shop.product",
"fields": {"sku": "basic", "name": "Basic", "price_cents": 900}
},
{
"model": "shop.product",
"fields": {"sku": "pro", "name": "Pro", "price_cents": 2900}
},
{
"model": "shop.product",
"fields": {"sku": "team", "name": "Team", "price_cents": 7900}
}<for(i, 1, getVar('customers'))>,
{
"model": "shop.customer",
"pk": <getVar('i')>,
"fields": {
"name": "<getRandomFullName()>",
"email": "<getRandomEmail('example.com')>",
"is_active": <if(getVar('i') == 1)>false<else>true<endIf>,
"created_at": "<getRandomDate('2025-01-01', '2026-06-30')>"
}
}<end_for><for(i, 1, getParam('orders', 20))>,
{
"model": "shop.order",
"pk": <getVar('i')>,
"fields": {
"customer": <getRandomNumber(2, getVar('customers'))>,
"product": ["<getRandomElement('basic', 'pro', 'pro', 'team')>"],
"quantity": <getRandomNumber(1, 100)>,
"status": "<getRandomElement('paid', 'paid', 'shipped', 'refunded')>",
"created_at": "<getRandomDate('2026-07-01', '2026-10-01')>"
}
}<end_for>
],
"cases": [
<for(i, 1, getParam('cases', 10))><if(getVar('i') > 1)>,<endIf>
{
"id": "case-<appendBefore('0', getVar('i'), 2)>",
"case": <if(getVar('i') == 1)>"unknown-customer"
<elseIf(getVar('i') == 2)>"inactive-customer"
<elseIf(getVar('i') == 3)>"zero-quantity"
<elseIf(getVar('i') == 4)>"quantity-over-limit"
<elseIf(getVar('i') == 5)>"unknown-product"
<else>"valid"<endIf>,
"data": {
"customer": <if(getVar('i') == 1)>999
<elseIf(getVar('i') == 2)>1
<else><getRandomNumber(2, getVar('customers'))><endIf>,
"product": <if(getVar('i') == 5)>"enterprise"
<else>"<getRandomElement('basic', 'pro', 'team')>"<endIf>,
"quantity": <if(getVar('i') == 3)>0
<elseIf(getVar('i') == 4)>101
<else><getRandomNumber(1, 100)><endIf>
},
"expectedField": <if(getVar('i') <= 2)>"customer"
<elseIf(getVar('i') <= 4)>"quantity"
<elseIf(getVar('i') == 5)>"product"
<else>null<endIf>,
"expectedCode": <if(getVar('i') == 1)>"invalid_choice"
<elseIf(getVar('i') == 2)>"inactive"
<elseIf(getVar('i') == 3)>"min_value"
<elseIf(getVar('i') == 4)>"max_value"
<elseIf(getVar('i') == 5)>"invalid_choice"
<else>null<endIf>
}<end_for>
]
}
What each part does:
customersis the only variable, because it's read three times.getParamreads the count, defaulting to 8, andsetVarkeeps it. It's the customer loop's end bound and the upper limit for every customer reference in orders and cases. The order and case counts are each read once, as a loop bound, so they callgetParaminline.- Primary keys come from the loop index. Customers are always
pks 1 to 8 and orders 1 to 20, so tests can say
pk=1and mean the same row every time. - Foreign keys are drawn from the valid range.
getRandomNumberpicks each order's customer between 2 and the count. Every reference resolves, and customer 1 never gets an order. - Customer 1 is the planted inactive account. It's a valid row, so it belongs in the fixture: an inactive customer with no orders, which is the record the inactive-customer case needs.
- Every case says what should happen. Rows 1 to 5 each break one
rule, and
expectedFieldandexpectedCodename the error. Rows 6 onward are valid and carrynullfor both. - The comma placement differs between the arrays. The three
products are written out first, so each customer and order starts
with a comma unconditionally. The
casesarray has nothing before its loop, so it uses the guard<if(getVar('i') > 1)>,<endIf>to skip the comma on the first item.
Template expressions support comparisons (==, !=, <, <=, >,
>=) and &&/||, but no arithmetic. That's why the planted rows are
picked by loop index, and why the customer that must never be ordered
for sits at index 1, where a fixed lower bound of 2 skips it. for
bounds are inclusive, and the control flow docs
cover for and if/elseIf/else. Keep customers between 2 and
998, so the valid range isn't empty and 999 really is unknown.
Natural keys for reference data
The products have no pk. They're reference data, and in a real
project they may already exist, created by a data migration. Because
Product defines natural_key() and its manager defines
get_by_natural_key(), loaddata looks each product up by sku
first. If it exists, the row is updated; if not, it's inserted with a
new primary key. Orders point at products the same way, with a
one-element list such as ["pro"] instead of a number.
Customers and orders keep integer pks because the template owns them and the tests refer to them. Mixing the two styles in one file is fine. If you only ever load into an empty test database, natural keys are optional; plain pks for everything work too.
What the template produces
Rendered with seed 1028 and the default counts, the fixture has 31
objects: 3 products, 8 customers and 20 orders. It starts like this,
trimmed to the first object of each model:
[
{
"model": "shop.product",
"fields": {
"sku": "basic",
"name": "Basic",
"price_cents": 900
}
},
{
"model": "shop.customer",
"pk": 1,
"fields": {
"name": "Sara Bishop",
"email": "[email protected]",
"is_active": false,
"created_at": "2025-12-23T22:34:20.355Z"
}
},
{
"model": "shop.order",
"pk": 1,
"fields": {
"customer": 3,
"product": [
"pro"
],
"quantity": 23,
"status": "shipped",
"created_at": "2026-09-07T09:56:21.972Z"
}
}
]
The timestamps are ISO 8601 in UTC, the same shape dumpdata writes,
and with USE_TZ = True (the default since Django 5.0) Django parses
them into aware datetimes. The cases file starts with the seed and the
planted rows:
{
"seed": 1028,
"cases": [
{
"id": "case-01",
"case": "unknown-customer",
"data": {
"customer": 999,
"product": "pro",
"quantity": 77
},
"expectedField": "customer",
"expectedCode": "invalid_choice"
},
{
"id": "case-02",
"case": "inactive-customer",
"data": {
"customer": 1,
"product": "team",
"quantity": 51
},
"expectedField": "customer",
"expectedCode": "inactive"
}
]
}
Each invalid row is otherwise ordinary. Case 2 orders a real product in a valid quantity, so the only thing wrong with it is the customer.
Why the invalid rows stay out of the loaddata fixture
It's tempting to put the bad rows in the fixture next to the good ones. It doesn't work, for three reasons:
loaddatawon't reject them. Validators don't run on a raw load, so an order with quantity 0 or 101 goes into the database like any other. It's then part of every test that loads the fixture: it shows up in counts, totals and list views, and nothing tests that it gets rejected.- Some of them would break the load. The order for customer 999 fails the foreign key check, and the whole fixture with it.
- They're a different shape. A validation case is form input:
"product": "pro"as the user submits it, nopk, nomodel. It isn't a database row and shouldn't pretend to be one.
So the cases go into a plain JSON file, shop/tests/data/order_cases.json,
which the tests read with the standard json module and pass to the
form. The data the form validates against, the customers and products,
comes from the fixture. Both files come from one render, which is why
case 2 can rely on customer 1 being inactive.
The same split works for DRF, but its related-field errors use the code
does_not_exist instead of invalid_choice, so give serializer cases
their own expectedCode values and read codes from
serializer.errors[field][0].code.
Regenerating both files from a seed
The regen script is the only code that talks to JsonFabrica. It sends
the raw template to
POST /v1/templates/generate, which
renders a template without saving it, and splits the response into the
two files:
#!/usr/bin/env python3
"""Regenerate the shop test fixtures from scripts/shop_test.tmpl.
Writes two files from one API call:
shop/fixtures/shop_test.json loaddata fixture, valid rows only
shop/tests/data/order_cases.json form cases, read with json.loads
Run by hand, review the diff, commit. Tests never call the API.
"""
import argparse
import json
import os
from pathlib import Path
import requests
API_URL = "https://api.jsonfabrica.com/v1/templates/generate"
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SEED = 1028 # change only when you mean to replace the data
def write_json(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
args = parser.parse_args()
api_key = os.environ["JSONFABRICA_API_KEY"]
response = requests.post(
API_URL,
json={
"body": (ROOT / "scripts" / "shop_test.tmpl").read_text(),
"params": {"customers": 8, "orders": 20, "cases": 10},
"seed": args.seed,
},
headers={"Authorization": f"Bearer {api_key}"},
timeout=30,
)
if not response.ok:
# {"error": {"code", "message", "details"}}, e.g. INVALID_TEMPLATE
raise SystemExit(f"{response.status_code}: {response.text}")
result = response.json()
seed, data = result["meta"]["seed"], result["data"]
write_json(ROOT / "shop/fixtures/shop_test.json", data["fixture"])
write_json(
ROOT / "shop/tests/data/order_cases.json",
{"seed": seed, "cases": data["cases"]},
)
print(f"seed {seed}: {len(data['fixture'])} objects, "
f"{len(data['cases'])} cases")
if __name__ == "__main__":
main()
The template lives in scripts/, not in shop/fixtures/, so
loaddata never mistakes it for a fixture. Things worth knowing about
the request and response:
- The response is
{ data, meta }.datais the generated document, already parsed.meta.seedechoes the seed used. - The seed is recorded in the cases file. A
loaddatafixture has to be a bare array, so there's nowhere to put it there. The cases file is an object, and itsseedkey says how to rebuild both files.meta.generatedAtis left out because it changes on every call. - Same template, seed and params give the same files. Running the
script twice produces no diff. Raising
casesonly appends rows, because the cases come last in the template. Changingcustomersororders, or editing the template, shifts the random draws for everything after it, so commit the template and both files together.
Run it, then run the tests before committing:
JSONFABRICA_API_KEY=... python scripts/regen_fixtures.py
python manage.py test shop
git add scripts/shop_test.tmpl shop/fixtures/shop_test.json \
shop/tests/data/order_cases.json
Reproducible test data from seeds covers how seeding works in more depth.
Loading Django TestCase fixtures
A TestCase lists fixture names, without extensions, in its
fixtures attribute. Django loads them once per test class, inside a
transaction, and runs each test in a savepoint that's rolled back
afterwards, so every test starts from the same rows.
TransactionTestCase instead truncates every table after each test
and reloads the fixtures before the next one, which is much slower.
# shop/tests/test_orders.py
import json
from pathlib import Path
from django.test import TestCase
from shop.forms import OrderForm
from shop.models import Customer, Order
CASES_FILE = Path(__file__).parent / "data" / "order_cases.json"
CASES = json.loads(CASES_FILE.read_text())["cases"]
class FixtureDataTests(TestCase):
fixtures = ["shop_test"]
def test_inactive_customer_has_no_orders(self):
customer = Customer.objects.get(pk=1)
self.assertFalse(customer.is_active)
self.assertFalse(customer.orders.exists())
def test_no_orders_for_inactive_customers(self):
orders = Order.objects.filter(customer__is_active=False)
self.assertFalse(orders.exists())
class OrderFormTests(TestCase):
fixtures = ["shop_test"]
def test_order_cases(self):
for case in CASES:
with self.subTest(case["id"], case=case["case"]):
form = OrderForm(data=case["data"])
field = case["expectedField"]
if field is None:
self.assertTrue(form.is_valid(), form.errors.as_json())
order = form.save()
self.assertIsNotNone(order.created_at)
else:
self.assertFalse(form.is_valid())
# one defect per row, reported on the expected field
self.assertEqual(list(form.errors), [field])
self.assertTrue(
form.has_error(field, code=case["expectedCode"]),
form.errors.as_json(),
)
How the pieces fit:
FixtureDataTestschecks the fixture's own invariants. If a template edit ever gives customer 1 an order, these tests fail with a clear message, instead of the inactive-customer case failing in a confusing one.OrderFormTestsreads the cases withjson, notloaddata. The fixture provides the customers and products the form looks up; the cases file provides the input.subTestreports every failing case, labelled with its id, instead of stopping at the first.- Invalid rows must fail for the right reason.
has_error()checks the field and the error code, and comparinglist(form.errors)to the one expected field catches a row that fails twice. A test that only assertednot form.is_valid()would pass if the quantity check fired on the inactive-customer row. - Valid rows go through
save(). Unlikeloaddata,form.save()takes the normal path, soauto_now_addfills increated_at. After a load,loaddataresets the primary-key sequences of the models it loaded, so the new orders don't collide with pks 1 to 20.
A failing case shows up in the test output with its id and label. It looks roughly like this, with the test's module path shortened to fit:
FAIL: test_order_cases (...) [case-03] (case='zero-quantity')
Because the ids come from the loop index, case-03 is the zero
quantity row on every regeneration.
Loading the fixture into a dev database
The same file loads outside tests:
python manage.py loaddata shop_test
Django reports Installed 31 object(s) from 1 fixture(s). loaddata
overwrites any existing rows with the same primary keys, so only load
a test fixture into a database you can throw away. For a dev database
with realistic volumes, a different job with different trade-offs, see
how to seed a local dev database with test data.
With pytest-django
If the suite runs under pytest-django, TestCase classes with
fixtures work unchanged. Plain test functions have no fixtures
attribute; the pytest-django docs show overriding the
django_db_setup fixture to call
call_command("loaddata", "shop_test") inside
django_db_blocker.unblock(), which loads the data once per session.
For transaction=True tests the database is flushed between tests, so
load the fixture in a function-scoped fixture instead. The cases file
is just JSON, so it can drive pytest.mark.parametrize directly. The pytest test data post covers
that side: session fixtures, parametrize ids and replaying a seed.
Django test fixtures vs factory_boy and dumpdata
A generated fixture is one of three common ways to get data into Django tests. Each fits a different job.
factory_boy builds objects in code, per test. A
DjangoModelFactory creates rows through the model manager, so
save(), signals and auto_now_add behave exactly as in production,
which loaddata can't offer. SubFactory builds related objects on
demand, and OrderFactory(quantity=100) states the one attribute a
test cares about right where it's used. If most tests need two or
three objects with specific attributes, factories are the better tool.
The data only exists while the tests run, though, and it's random
unless you reseed with factory.random.reseed_random(). You can't
open a file to see what the suite covers, and other tools, such as a
frontend's mock server, can't reuse it.
dumpdata captures a database you already have. One command writes a valid fixture from your dev database:
python manage.py dumpdata shop --natural-foreign --natural-primary \
--indent 2 -o shop/fixtures/dev_snapshot.json
That's useful for reproducing a bug from a specific state. As a long-lived test fixture, it has problems:
- It goes stale. It contains whatever was in the database that day. After a migration adds a non-null field, it either fails to load or loads with whatever default the field happens to have, and someone fixes it by hand.
- It can leak real data. If the dev database was ever restored from production or staging, the dump contains real customers' names and emails, now committed to the repository.
- Nothing in it is deliberate. Nobody chose which customer is inactive or which order is at the quantity limit, so tests end up depending on accidents of the data.
A generated fixture is a file you design. The template decides which rows exist and why, the regen script rebuilds the file from a seed, and schema changes are a one-line template edit and a rerun. It's extra machinery, though: a template, a script, and an API key for whoever regenerates. For a handful of rows that never change, a hand-written fixture is simpler.
The approaches mix. A suite can load its shared base data from a generated fixture and still use a factory inside a single test for an object nobody else needs. If what you need is bulk data inserted through the ORM rather than a fixture file, the ORM seed script alternative covers loading generated JSON in a management command. And if the thing under test is a migration rather than your forms, see test data for database migrations, which plants the rows that break schema changes.
FAQ
Where does Django look for fixture files?
loaddata searches three places: the fixtures directory inside every
installed app, every directory listed in the FIXTURE_DIRS setting,
and the literal path you pass. You can leave off the file extension, so
loaddata shop_test finds shop/fixtures/shop_test.json. Give
fixtures names that are unique across apps, because the name alone is
what TestCase.fixtures and loaddata look up.
Does Django loaddata call the model's save() method?
No. loaddata saves each object as it is in the file, without calling
your model's save() method and without running field validators or
full_clean(). pre_save and post_save signals still fire, with
raw=True. Fields with auto_now or auto_now_add are not filled in
during a raw load either, so the fixture has to carry those values.
Are Django TestCase fixtures loaded before every test?
With TestCase, the fixtures listed in the fixtures attribute are
loaded once per test class, inside a transaction, and each test runs
in a savepoint that is rolled back afterwards, so every test sees the
same data. TransactionTestCase instead truncates every table after
each test and reloads the fixtures before the next one, which is much
slower.
Should I use fixtures or factory_boy for Django tests?
Use factory_boy when each test builds the few objects it needs and you
want them created through the model's normal save() path. Use a
fixture file when many tests share one consistent data set, when the
data should be reviewable in a pull request, or when other tools need
the same records. Many suites use both: a shared fixture for the base
data and factories for one-off objects.
How do I use natural keys in Django fixtures?
Add a natural_key() method to the model that returns a tuple of
unique fields, and a get_by_natural_key() method on its default
manager. A foreign key in the fixture can then be written as a list,
such as ["pro"], instead of a primary key, and the referenced
object's own entry can leave out pk. dumpdata produces this format
with --natural-foreign and --natural-primary.
Is there a JsonFabrica Django package or plugin?
No. JsonFabrica is a JSON-over-HTTP API, with no Django package,
plugin, SDK or database connector. A small script you own sends a
template and a seed to the API and writes the generated JSON into your
repository. Django's own loaddata and your test code read those
files, and test runs never call the API.
Generated Django test fixtures give you a loaddata file whose
foreign keys always resolve and a separate table of invalid inputs
with their expected errors, both rebuilt from one seed. Rendering a raw
template with a seed and params is a single request to the JsonFabrica
API; the templates API reference
documents the request body and the { data, meta } response.
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.