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):

  1. The parent slug's JSON Schema is read from Postgres
  2. util/dynamic_forms.py builds a Django form from it at request time
  3. The form validates the submission
  4. serialize_cleaned_data() converts cleaned_data into a BSON-safe dict — dates to ISO strings, uploaded files through the storage backend with the returned key stored
  5. The child Slug row is saved to Postgres
  6. 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
  • _id is the owning Postgres row's UUID as a string
  • Payload nested under a data key rather than spread at the document root
  • Return {} for a missing document, not None — 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

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