AI
← All posts

· 16 min read · Engineering

Advanced Django — Part 1: The model layer

Welcome to Advanced Django, a series of posts on the pieces of Django I actually reach for in production. This isn't a tutorial series — the full-stack starter already covers "how do I set up a Django project." This is the "here's why I do it this way" series that picks up where the starter ends. Each post covers one layer of a real Django backend, deeply.

Part 1 is the model layer. I start here because everything else — managers, querysets, selectors, services, serializers, views — layers on top. A model with the right foundation is invisible; you build on it and it never gets in the way. A model with the wrong foundation gets in the way of every feature that touches it.

This post covers the BaseModel I inherit from on every table I define, the AuditTrailMixin and TenantScopedModel abstract classes that compose on top of it, the BaseTextChoices enum pattern for status fields, and the small conventions (typed managers, @final, explicit Meta) that keep the code readable a year in. There'll be a lot of code — that's the point. Copy what fits.

Companion repo: the patterns in this series live in ademolaidowu/django-saas-foundation on GitHub — a working Django + DRF project you can clone, run, and read alongside the posts.

The BaseModel, all in one place

Here's the whole file. I'll walk through every choice below.

# apps/core/models/base.py
from __future__ import annotations
 
import uuid
from typing import Any, Self
 
import uuid_utils
from django.db import models
from django.utils import timezone
 
 
def uuid7() -> uuid.UUID:
    """UUIDv7 — time-ordered UUID (RFC 9562). Sortable by creation time."""
    return uuid.UUID(str(uuid_utils.uuid7()))
 
 
class BaseQuerySet(models.QuerySet):
    def get_or_none(self, *args: Any, **kwargs: Any) -> Any | None:
        try:
            return self.get(*args, **kwargs)
        except self.model.DoesNotExist:
            return None
 
    def alive(self) -> Self:
        return self.filter(deleted_at__isnull=True)
 
    def deleted(self) -> Self:
        return self.filter(deleted_at__isnull=False)
 
 
class BaseManager(models.Manager):
    """Default manager — returns every row, including soft-deleted."""
 
    def get_queryset(self) -> BaseQuerySet:
        return BaseQuerySet(self.model, using=self._db)
 
    def get_or_none(self, *args: Any, **kwargs: Any) -> Any | None:
        return self.get_queryset().get_or_none(*args, **kwargs)
 
 
class RecordsManager(models.Manager):
    """`records` manager — alive rows only (`deleted_at IS NULL`)."""
 
    def get_queryset(self) -> BaseQuerySet:
        return BaseQuerySet(self.model, using=self._db).alive()
 
    def get_or_none(self, *args: Any, **kwargs: Any) -> Any | None:
        return self.get_queryset().get_or_none(*args, **kwargs)
 
 
class BaseTextChoices(models.TextChoices):
    """`TextChoices` with helpers so field definitions don't repeat themselves."""
 
    @classmethod
    def max_name_length(cls) -> int:
        return max(len(value) for value, _ in cls.choices)
 
    @classmethod
    def as_choices(cls) -> list[tuple[str, str]]:
        return cls.choices
 
 
class BaseModel(models.Model):
    class Meta:
        abstract = True
 
    class RecordSource(BaseTextChoices):
        API = "api", "API"
        ADMIN = "admin", "Admin Panel"
        IMPORT = "import", "Bulk Import"
        SYSTEM = "system", "System"
        INTEGRATION = "integration", "Third-Party Integration"
        MIGRATION = "migration", "Data Migration"
        SEED = "seed", "Data Seed / Generation"
 
    # Internal integer PK for FKs and joins
    id = models.BigAutoField(primary_key=True)
 
    # External-facing time-ordered UUID for APIs, URLs, external refs
    public_id = models.UUIDField(
        unique=True,
        db_index=True,
        editable=False,
        default=uuid7,
    )
 
    # Where did this row come from — API, admin, import, system job, etc.
    source = models.CharField(
        max_length=RecordSource.max_name_length(),
        choices=RecordSource.choices,
        default=RecordSource.API,
    )
 
    created_at = models.DateTimeField(auto_now_add=True)
    modified_at = models.DateTimeField(auto_now=True)
    deleted_at = models.DateTimeField(blank=True, null=True)
 
    objects = BaseManager()
    records = RecordsManager()
 
    def is_deleted(self) -> bool:
        return self.deleted_at is not None
 
    def delete(self, *args: Any, **kwargs: Any) -> None:
        """Soft-delete. `AuditTrailMixin` wraps this to require `deleted_by`."""
        self.deleted_at = timezone.now()
        self.save(update_fields=["deleted_at"])
 
    def hard_delete(self, *args: Any, **kwargs: Any) -> None:
        """Permanently remove the row. Prefer `delete()` unless you have to."""
        super().delete(*args, **kwargs)
 
    def select_for_update(self, nowait: bool = False, skip_locked: bool = False) -> Self:
        """Fresh row-locked instance of self (must be inside `transaction.atomic`)."""
        return self.__class__.objects.select_for_update(
            nowait=nowait, skip_locked=skip_locked,
        ).get(pk=self.pk)

That's BaseModel — the foundation every row sits on. There are two more abstract classes that compose on top of it: AuditTrailMixin (who touched this row) and TenantScopedModel (which organization owns it). Both come below. The whole file is in apps/core/models/base.py in the companion repo; what follows is why each piece is there.

Why UUIDv7 specifically

The exposed identifier on every API response is a public_id — a UUID. For years I used UUIDv4. Then I read RFC 9562 (which formalizes UUIDv6, v7 and v8) and switched. Two reasons.

UUIDv7 is time-ordered. The high bits of a UUIDv7 are a Unix timestamp in milliseconds. Two consequences: rows created close in time have UUIDs close in value, and you can sort by UUID as a rough proxy for creation time. UUIDv4 is random — no ordering, no locality.

Sequential UUIDs are kind to B-tree indexes. UUIDv4 inserts into a random position in the index on every write. That fragments the index and causes page splits on high-write tables. UUIDv7 inserts at (or near) the end of the index — no splits, no fragmentation. On tables with heavy insert workloads, it's a measurable difference. On tables with light inserts, it doesn't matter — but there's no downside either.

There's one thing UUIDv7 gives up: it leaks the creation timestamp (to millisecond precision) in the ID. That's fine for almost every use case I've had. The exceptions are rare: identifiers that must never disclose when the record was created, for legal or security reasons. On those, stay on UUIDv4.

Neither UUIDv7 nor v4 solves the underlying question of why you need a UUID at all, though — that's the split I do next.

BigAutoField for id, UUIDField for public_id

Two identifier columns is a deliberate choice. Each does one job.

id (BigAutoField) — the internal primary key. Foreign keys reference it, joins use it, database indexes love it. Small (8 bytes), monotonic, cache-friendly. It never leaves the database.

public_id (UUIDField, UUIDv7) — the external identifier. API responses expose this. URLs use this. Third-party systems reference this. It's not guessable, doesn't leak table growth (/orders/47324 tells you your order count; /orders/018f2c… tells nothing), and it's globally unique so you can safely merge or move data.

The rule I follow: id never crosses a wire, public_id never joins a table. DRF views take public_id in the URL, look up the model, then use id for everything downstream. select_related and prefetch_related traverse id links because Postgres joins on 8-byte integers are dramatically cheaper than joins on 16-byte UUIDs. And every API response serializes public_id, never id.

You do pay 16 bytes per row for the UUID column plus its index. On tables with billions of rows that adds up. On tables with millions, it's noise. For me it's always been worth it.

Two managers, one obvious default

Every model gets two managers.

  • objects sees every row. Deleted rows included. This is what the Django admin uses, what audit and undo workflows use, what a data engineer would use in a shell.
  • records sees only alive rows. This is what the app uses.

The reason to have both is that you can't undelete what you never see. If objects filtered out soft-deleted rows silently, restoring a row would be a database update by primary key with no ORM support. And auditing "what happened to that order that was here yesterday" would mean poking at raw SQL.

The reason to make records the day-to-day default is that everyone forgets soft-delete exists. If you have to remember to filter out deleted rows on every query, you'll forget, and a support ticket six months later will say "why does this deleted order still show up on the customer's dashboard?" I've had that ticket. I don't want it again.

In practice, service functions and DRF viewsets both call Order.records.all() (or .filter(...)). Order.objects shows up only when you actually want to see deleted rows — admin, imports, restore. That distinction reads well in code review: objects on a query is a signal to check whether the caller meant to include deleted rows.

One caveat: Django's admin uses _default_manager, which is the first manager defined on the model. I put objects first for exactly this reason — admin should see everything by default. If you want the admin to hide deleted rows, put records first, or override get_queryset on the admin.

Audit timestamps

Two timestamps on every row: created_at (auto_now_add=True) and modified_at (auto_now=True). Both come free from Django.

The one small thing worth calling out is the rename from updated_at to modified_at. Pair that with deleted_at (soft-delete) and the trio reads as a group — created_at, modified_at, deleted_at. Three verbs that match in form. I renamed when I noticed how often I was writing updated_at and reading modified_at on the same page.

The source field — where did this row come from?

source is a short categorical column on every row that records which path wrote it: api, admin, import, system, integration, migration, or seed. The RecordSource enum (nested on BaseModel) is the full list.

The point is debuggability. When a row behaves unexpectedly in production, "did a human create this through the API, or was it the overnight CSV backfill, or did the webhook listener produce it?" shouldn't be an educated guess from the timestamp. One cheap enum column makes it a direct question of SQL. Defaults to api because that's where the vast majority of rows come from; services and management commands override it explicitly when they write.

It's a small field. I only started adding it after the second time I had to work out which code path produced a strange row in prod from circumstantial evidence.

AuditTrailMixin — who touched this row

Audit timestamps go on BaseModel. Audit actors — created_by, modified_by, deleted_by — live on a separate abstract class, AuditTrailMixin, that you compose in only when you need it.

# apps/core/models/base.py (continued)
from django.conf import settings
 
 
class AuditTrailMixin(models.Model):
    """
    Compose with `BaseModel` on any row a human touches through the API:
 
        class Project(AuditTrailMixin, BaseModel):
            ...
 
    Skip on rows the system writes automatically (tokens, audit logs,
    notifications) and on system reference data (Permissions, Role
    templates). The acting user is passed explicitly to service methods —
    the write layer sets these fields. No middleware, no thread-locals,
    no signals.
    """
 
    class Meta:
        abstract = True
 
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        null=False,
        blank=False,
        related_name="+",
        related_query_name="+",
    )
    modified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        related_name="+",
        related_query_name="+",
    )
    deleted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        related_name="+",
        related_query_name="+",
    )
 
    def delete(self, deleted_by: Any = None, *args: Any, **kwargs: Any) -> None:
        if deleted_by is None:
            raise ValueError("deleted_by is required for soft delete")
        super().delete(*args, **kwargs)
        self.deleted_by = deleted_by
        self.save(update_fields=["deleted_by"])

Three things worth naming here.

Why a separate mixin, not fields on BaseModel. A lot of rows don't have a "who". Auth tokens, system-generated audit records, cached projections, notification queue entries, join tables that only the ORM touches — all of them would carry three nullable FKs for no reason. Keeping actors on an opt-in mixin means BaseModel stays honest: every field on it applies to every row.

PROTECT on every FK, related_name="+" on every FK. PROTECT because if a user has created anything, you don't get to delete them without reckoning with that history — you deactivate them instead. related_name="+" (and related_query_name="+") because the reverse accessors from User to every audited row would be hundreds of unused managers (user.project_set, user.task_set, user.role_set...). The forward relation is all we need.

modified_by lives here, but the service layer has to earn it. The previous version of this post argued against modified_by on the grounds that it lies too easily — half the writes in a real app are system-written. That argument isn't wrong; it's just a prescription against letting it lie. The mixin opts the model in; what makes modified_by honest is the discipline from Part 3: every write goes through a service, every service takes an actor argument, and background jobs pass the system user through explicitly rather than defaulting it. simple-history (below) is still the thing that captures the full diff — modified_by is the last-touched actor pointer.

The delete() override is the enforcement teeth. super().delete() on BaseModel does the soft-delete timestamp; the mixin wraps that to require deleted_by be passed in. There is no way to soft-delete an audited row without naming the actor who did it — the method raises ValueError on deleted_by=None.

In practice, almost everything a human touches inherits both: class Project(AuditTrailMixin, BaseModel). AuditTrailMixin goes first in the MRO so its delete() wraps BaseModel's.

TenantScopedModel — a preview

There's one more abstract class that sits alongside BaseModel in a multi-tenant project: TenantScopedModel. It extends BaseModel, adds an organization FK, and swaps the default managers for guarded ones that refuse to evaluate a query without an explicit .for_organization(org) call.

# apps/core/models/tenancy.py (preview)
class TenantScopedModel(BaseModel):
    class Meta:
        abstract = True
        indexes = [models.Index(fields=["organization", "deleted_at"])]
 
    organization = models.ForeignKey(
        "tenants.Organization",
        on_delete=models.PROTECT,
        related_name="+",
        db_index=True,
    )
 
    objects = TenantManager()
    records = TenantRecordsManager()

Composition in practice: class Project(AuditTrailMixin, TenantScopedModel). The project inherits the organization FK, the public UUID, the audit timestamps, and the actor FKs — one declaration, every rule baked in.

The guarded queryset underneath this — the one that raises UnscopedQueryError on any evaluation entry point unless you've scoped to an organization first — is the main worked example in Part 2. It's the one case where I reach for a custom queryset on every project, and it deserves its own treatment.

Soft-delete: one field, one override, one trap

Only one column: deleted_at, a nullable timestamp. Alive rows have deleted_at IS NULL; deleted rows have a timestamp. is_deleted is a computed property, not a stored field — adding a boolean alongside the timestamp is redundant. You can already derive it, and having both stored means the two can drift (is_deleted=True but deleted_at IS NULL, or vice versa) if code writes one without the other.

The other decision here is overriding delete() itself rather than adding a separate soft_delete(). Django's Model.delete() is what gets called by cascades, the admin, querysets, and any downstream code you don't control. If soft-delete lives on a differently-named method, all of those still do hard deletes and you have to remember to route them. Overriding delete() makes soft-delete the default and hard-delete the explicit choice — the shape you want.

hard_delete() is the escape hatch for when you really do mean DELETE FROM ... — cleanup jobs, GDPR requests, test tear-down. Because it calls super().delete(), cascade rules run normally.

The trap: soft-deleted rows still take up disk and still show up in unique constraints. If you have email = models.EmailField(unique=True) and you soft-delete a user, they can't sign up again with the same email until you either hard-delete or lift the unique. Two ways to handle it:

  1. Change the unique to a partial index. In Postgres you can add a UniqueConstraint with condition=Q(is_deleted=False). Only alive rows enforce uniqueness. This is what I do most of the time.
  2. Hard-delete after a grace period. Nightly job that removes rows soft-deleted more than N days ago. Legal/compliance rules usually decide the number.

Neither one is free. Which you pick depends on how strong the "restore me" story needs to be in your app.

The select_for_update() shorthand

select_for_update() on BaseModel returns a fresh, row-locked copy of the current instance (SELECT … FOR UPDATE). Shadowing the manager method name is deliberate — it's the same operation, just at the instance level, and the shadowing means autocomplete leads you to the right method on either side. The two flags come from experience:

  • nowait=True — raise immediately if the row is already locked. Right for a web request. You don't want to hold a client waiting on another request's transaction.
  • skip_locked=True — skip the row entirely if it's locked. Right for a background worker draining a queue-like table. Another worker holds it; move on to the next row.

Neither flag set (the default) means "wait as long as it takes." Rare, and dangerous in a web handler.

The wrapper isn't strictly necessary — MyModel.objects.select_for_update(...).get(pk=self.pk) works. But you write that pattern ten times and you'll want a shorthand, and the shorthand is a good place to document the trade-offs in one docstring. Must be called inside a transaction.atomic block; Postgres rejects the lock otherwise.

@final on every concrete model

Every concrete model gets @final at the class level:

from typing import final
 
@final
class Order(BaseModel):
    ...

Two things this buys you:

  • mypy stops you from subclassing it. Which is the intent — concrete models are terminal.
  • A reader knows at a glance that this is the terminal class. No hidden subclass overriding save() from another app.

Django's own model inheritance (class Meta: abstract = True) is different. Abstract models (like BaseModel itself) are meant to be subclassed. Concrete models generally shouldn't be. The @final decorator makes that difference explicit.

Meta options I always set explicitly

The Meta block on every concrete model has four things:

class Meta:
    db_table = "orders"
    ordering = ["-created_at"]
    indexes = [
        models.Index(fields=["organization", "status"]),
        models.Index(fields=["created_at"]),
    ]
    constraints = [
        models.UniqueConstraint(
            fields=["organization", "order_number"],
            name="unique_order_number_per_organization",
        ),
    ]

db_table explicit. Django will auto-generate a table name like orders_order if you don't set one. It's fine until you rename the app or move the model between apps, at which point the auto-generated name changes and Django wants to drop and recreate the table on migration. Explicit names never move.

ordering on every model that ends up in a list view. The default is unordered — which is technically undefined by Postgres and returns rows in whatever order the query planner decided this time. Almost always you want -created_at; occasionally there's a domain-specific order.

Indexes on anything you filter or order by in production. Postgres is great, but it can't read your mind. The rule I apply: if a query in your production code has filter(foo=X, bar=Y).order_by("baz"), either it hits a covering index or it hits a Postgres sequential scan. Choose which one you want.

UniqueConstraint over unique_together. unique_together is soft-deprecated. UniqueConstraint supports conditions (partial unique constraints), custom names, and reads better in migrations.

BaseTextChoices — no more hardcoded max_length

Django's TextChoices is what you use for enum-like fields. The friction: you write the choices, then you have to guess a max_length on the CharField. And every time you add a new value, you check whether it fits.

The BaseTextChoices subclass from the code block above (max_name_length() and as_choices()) fixes that. Usage:

class Status(BaseTextChoices):
    PENDING = "PENDING", "Pending"
    PAID = "PAID", "Paid"
    CANCELLED = "CANCELLED", "Cancelled"
 
 
status = models.CharField(
    max_length=Status.max_name_length(),
    choices=Status.choices,
    default=Status.PENDING,
)

Add REFUNDED = "REFUNDED", "Refunded" and nothing about the field changes. The max_length recomputes at class definition. One source of truth for both the values and the field length.

The as_choices() classmethod does the same job for DRF ChoiceField(choices=Status.as_choices()), so the API serializer picks up new values the moment the enum changes. Small, but I use both on every choice field I write.

History with django-simple-history

For any model where "who changed what and when" matters — money, permissions, patient records, orders that go through fulfillment — I add django-simple-history. It's a small third-party package that tracks every insert and update on the models you opt in.

Install it:

uv add django-simple-history

Add "simple_history" to INSTALLED_APPS, then on the model:

from simple_history.models import HistoricalRecords
 
 
@final
class Order(BaseModel):
    # ... fields ...
 
    history = HistoricalRecords(table_name="orders_history")

django-simple-history writes a row to orders_history for every insert and update, with the full state of the model at that point. Admin integration is one line. Diffs between versions come free. When a customer says "my order status changed to cancelled, I didn't do that" — you have the answer.

The cost is a second write on every save (bearable in most apps, less so at very high throughput) and one more table per tracked model. For anything with an audit story, it's the cheapest good decision you can make.

I don't put it on BaseModel because plenty of models don't need it — join tables, transient state, log rows. It's opt-in at the concrete model level.

The custom User — a note

User is the one model where the inheritance story needs an extra class. Django ships AbstractBaseUser + PermissionsMixin for custom users and expects specific manager methods on top. What I do is inherit from them and BaseModel:

@final
class User(AbstractBaseUser, PermissionsMixin, BaseModel):
    ...

This gets you public_id, timestamps, soft-delete, and the dual managers on the user model too. What it deliberately leaves off is AuditTrailMixin — the first user has no "created by" user to point at, and threading an actor through user creation turns createsuperuser into a chicken-and-egg problem for the sake of a nullable FK that mostly stays null. On User, you accept that creation is self-attributed and move on. The starter post covers the full custom-User setup in Part 1 of the starter.

Putting it all together: an Order model

Here's what an actual concrete model looks like after all of the above. This is the shape you'll see repeated across the codebase, with only the fields changing.

# apps/orders/models/order.py
from typing import final
 
from django.db import models
from simple_history.models import HistoricalRecords
 
from core.models import AuditTrailMixin, BaseTextChoices, TenantScopedModel
 
 
class OrderStatus(BaseTextChoices):
    PENDING = "PENDING", "Pending"
    PAID = "PAID", "Paid"
    FULFILLED = "FULFILLED", "Fulfilled"
    CANCELLED = "CANCELLED", "Cancelled"
 
 
# `AuditTrailMixin` first so its `delete(deleted_by=...)` wraps
# `BaseModel.delete()` (inherited through `TenantScopedModel`).
@final
class Order(AuditTrailMixin, TenantScopedModel):
    order_number = models.CharField(max_length=32)
    status = models.CharField(
        max_length=OrderStatus.max_name_length(),
        choices=OrderStatus.choices,
        default=OrderStatus.PENDING,
    )
    total = models.DecimalField(max_digits=12, decimal_places=2)
 
    history = HistoricalRecords(table_name="orders_history")
 
    class Meta:
        db_table = "orders"
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["organization", "status"]),
        ]
        constraints = [
            models.UniqueConstraint(
                fields=["organization", "order_number"],
                name="unique_order_number_per_organization",
            ),
        ]
 
    def __str__(self) -> str:
        return f"{self.order_number} ({self.status})"

Notice what isn't there. No explicit organization FK — inherited from TenantScopedModel. No created_by / modified_by / deleted_by field declarations — inherited from AuditTrailMixin. No custom manager, no custom queryset. The objects and records managers come free from the base classes, and because Order is tenant-scoped, those managers return the guarded queryset that .for_organization(org) must be called on before any evaluation. Custom querysets — unpaid() and the chainable domain filters that go with them — are exactly what Part 2 is about.

Every piece here has a reason. Every model in the project looks structurally like this. Once your team has read one, they've read them all.

What's next in the series

The series builds bottom-up. Each post is one layer above the last.

  • Part 2: Managers and querysets. Custom QuerySet classes that compose, multi-tenant scoping with for_organization(), chainable domain filters, and how far you can push method chaining before you regret it. This is still ORM territory — no service functions yet.
  • Part 3: Selectors and services. The read and write layers I put between the ORM and everything else. Selectors are read-only query functions built on top of the querysets from Part 2. Services are the write-side atomic operations, with keyword-only arguments and a single job each. The split is what keeps business logic out of your views and serializers.
  • Part 4: Serializers with an input/output split. Why one serializer per shape beats one clever serializer per model. DRF-specific patterns, validation that lives in the right place, and how output serializers stay decoupled from write concerns.
  • Part 5: Views and permissions. DRF viewsets kept deliberately thin — parse input, call a service or selector, serialize output. Permission classes that consume a pre-computed permission set (not one query per check). URL routing patterns. This is where the whole vertical stitches together and you see why the earlier layers exist.

The theme across the whole series is the same: pick your defaults once, apply them everywhere, and stop making tiny decisions on every new model or view. That's how a codebase stays legible for a team over years.

If a choice in this post is different from how you do it — especially if you've been burned by something I'm about to be burned by — tell me. That's exactly how this series gets better.

Comments