Document Store
Pengin-Pi-3 uses FerretDB as a document store alongside PostgreSQL. It exists so content shapes can be defined at runtime — a new page type, a new field on a product — without a model, a migration, or a restart.
This page covers how it's wired, what currently uses it, and how to use it in your own code.
One database, two protocols
FerretDB speaks the MongoDB wire protocol but stores everything in PostgreSQL. There is no second database server:
┌─────────────────────────────────────────┐
│ postgres │
│ ghcr.io/ferretdb/postgres-documentdb │
│ │
│ ${FERRETDB_DB} ← DocumentDB extension│
│ ${DB_NAME} ← Django's tables │
└──────────┬───────────────────┬──────────┘
│ │
psycopg2 (SQL) FerretDB service
│ │ (MongoDB wire)
│ │
Django ────── pymongo ─┘
Relational and document data share one instance, one volume, and one backup. That is the main practical argument for FerretDB over MongoDB here: document flexibility without a second database to run, secure, and back up separately.
The image requirement
The Postgres service must run FerretDB's DocumentDB image. A stock
postgres:17-alpine starts cleanly and then fails every document operation
with:
schema "documentdb_api" does not exist
The extension installs via pg_cron into exactly one database, named by
FERRETDB_DB (default postgres). Django's own database is created
alongside it on first boot by docker-entrypoint-initdb/01-create-app-db.sh.
That is why FERRETDB_DB and DB_NAME are separate settings and why the
init script is mounted as a single file rather than a directory — a directory
mount would shadow the image's baked-in extension scripts.
Full detail in Deployment.
The client
util/ferretdb/__init__.py is the entire access layer:
from util.ferretdb import get_mongo_db
db = get_mongo_db()
One process-wide MongoClient, created lazily behind a double-checked lock
so concurrent uWSGI threads don't race to build several. On first
construction it issues a ping and raises ConnectionFailure if FerretDB
isn't reachable, with serverSelectionTimeoutMS=5000 — failing in five
seconds rather than hanging a worker.
A failed handshake resets the cached client to None, so the next call
retries rather than reusing a dead connection.
get_mongo_db() returns a pymongo database handle. Everything pymongo can
do works: find, find_one, update_one, aggregate, indexes.
Configuration
| Setting | Default | In Docker |
|---|---|---|
| MONGODB_URI | mongodb://ferretdb:27017/ | Credentials injected from DB_USER/DB_PASSWORD |
| MONGODB_DB_NAME | dynamic_cms | Set to ${DB_NAME} |
Note the defaults differ from what Compose supplies. A local development run
without Docker points at an unauthenticated ferretdb host and a database
called dynamic_cms, while a deployed instance uses credentials and matches
the Django database name. Set both explicitly if you care which you get.
What uses it today
One collection: slug_dynamic_data. One document per dynamic child slug,
_id set to that slug's own UUID as a string.
{
"_id": "3f9a1c2e-...",
"data": {
"title": "Transmission rebuild",
"price": 1850.0,
"category": "service",
"in_stock": true,
"cover_image": "uploads/rebuild-a91f.jpg"
}
}
Keying on the slug's existing id means there is no second id scheme to keep synchronized between the two stores.
util/slug_dynamic_data.py is the whole API:
save_dynamic_data(slug_id, data) # full replace, upsert
get_dynamic_data(slug_id) # returns {} if absent
delete_dynamic_data(slug_id)
save_dynamic_data replaces the document wholesale rather than merging —
callers always pass the complete current field set.
The flow
Creating or editing a dynamic child (main/views/slug_dynamic.py):
- The parent slug's JSON Schema is read from Postgres
util/dynamic_forms.pybuilds a Django form from it at request time- The form validates the submission
serialize_cleaned_data()convertscleaned_datainto a BSON-safe dict — dates to ISO strings, uploaded files through the storage backend with the returned key stored- The child Slug row is saved to Postgres
save_dynamic_data()writes the document to FerretDB
Postgres holds identity and hierarchy — id, name, parent, author, history. FerretDB holds the arbitrary fields. Neither store knows anything about the other's schema.
What is not wired up
Stated plainly, because the gaps are larger than the module comments imply.
Nothing reads the data back on render. SlugView builds its context from
Slug.json and never calls get_dynamic_data(). A dynamic child created
through the form has no template_name, no render_template, and an empty
json — so visiting its URL returns an empty response. The data is stored
and editable, but not yet displayed. Rendering dynamic children is the
missing half of this feature.
delete_dynamic_data() is never called. It is defined, exported, and
dead. Deleting a child slug leaves its document behind. The comment in
slug_dynamic_data.py saying SlugDeleteView cleans up the single slug it
is given is aspirational — that view calls slug.delete() and nothing else.
Cascade deletes orphan silently. Deleting a dynamic parent cascades at
the SQL level, which bypasses Model.delete() entirely, so no Python cleanup
hook fires for any child.
There is no transaction across the two stores. A Slug row and its document are written through separate connections and protocols. The ordering is deliberate — save the row, then the document — but a crash between the two leaves one side orphaned. Acceptable at current scale: staff-only, low write volume, and an orphan costs disk rather than correctness. Reconciliation tooling does not exist.
No indexes are declared. Lookups are by _id, which Mongo indexes
automatically. Any query pattern you add needs its own index.
Orphan cleanup, in practice, means a periodic pass comparing document ids against live Slug ids. Worth writing as a management command if you deploy this feature; nothing in the core does it for you.
Using it in your own code
The store is not reserved for the slug system. Any module can use it for arbitrary data that doesn't warrant a model.
When to use it
Reach for FerretDB when the shape is defined by a user or an editor rather than a developer — schema-driven form submissions, per-tenant configuration, arbitrary metadata hung off a page or view, anything where "add a field" should not mean "write a migration."
Stay in PostgreSQL when you need relational integrity, foreign keys, joins, transactions with other writes, or Django admin and ORM support. Relational data in a document store is a slow, quiet mistake.
A useful split, and the one the slug system already follows: identity, ownership, and hierarchy in Postgres; open-ended fields in FerretDB, keyed by the Postgres row's id.
Pattern
from util.ferretdb import get_mongo_db
COLLECTION = 'my_feature_data'
def save_record(owner_id, data):
db = get_mongo_db()
db[COLLECTION].update_one(
{'_id': str(owner_id)},
{'$set': {'data': data}},
upsert=True,
)
def get_record(owner_id):
db = get_mongo_db()
doc = db[COLLECTION].find_one({'_id': str(owner_id)})
return doc['data'] if doc else {}
Conventions worth keeping, since the core follows them and consistency here is cheap:
- One collection per feature, named for the feature
_idis the owning Postgres row's UUID as a string- Payload nested under a
datakey rather than spread at the document root - Return
{}for a missing document, notNone— callers shouldn't branch - Write the Postgres row first, the document second
Things to handle yourself
Serialization. BSON has no bare date type and no Django model awareness.
Convert dates to ISO strings and files to storage keys before writing —
serialize_cleaned_data() in util/dynamic_forms.py is the reference.
Connection failures. get_mongo_db() raises ConnectionFailure when
FerretDB is down. Decide per feature whether that should surface as an error
or degrade to empty. The slug editor currently does neither and will 500.
Cleanup. Nothing cascades. If your feature deletes owning rows, call your own delete function — and remember that SQL-level cascades won't run it.
Validation. Documents are unvalidated by default. The slug system gets its guarantees from validating the JSON Schema at save time and running submissions through a generated Django form. Do something equivalent, or accept whatever gets written.
Operations
Inspecting
docker compose exec ferretdb mongosh \
"mongodb://$DB_USER:$DB_PASSWORD@localhost:27017/$DB_NAME"
> show collections
> db.slug_dynamic_data.find().limit(5)
Backups. One pg_dumpall covers both stores — they are the same
instance. There is no separate document backup, and a Postgres restore
restores documents with it. That is the main operational benefit of this
architecture.
Checking health
docker compose logs ferretdb
docker compose exec web python manage.py shell -c \
"from util.ferretdb import get_mongo_db; print(get_mongo_db().list_collection_names())"
An empty list is normal on a fresh install — the collection is created lazily on first write.
Status
This layer is new. The client, the storage module, and the schema-driven form pipeline are working and in use. The render path, cleanup, and reconciliation are not built yet.
Application branches on the public mirror and Tobu Pengin's internal deployments use the store more extensively than the core does, but those are outside the supported surface (see Info).
Treat this as a foundation to build on rather than a finished feature, and expect the API to move before first alpha.
Next
- Slugs and Pages — dynamic content types from the editor's side
- Architecture — where the store sits in the system
- Deployment — the DocumentDB image requirement
Pages Here
No sub-pages yet.
Page Info
Wiki: Docs
Created on Sep 21, 2026 by Tobu Pengin, L.L.C.
Maintainers
| Editor | Last Activity |
|---|---|
| Tobu Pengin, L.L.C. creator | Sep 21, 2026 |