Skip to main content

Command Palette

Search for a command to run...

Authorization in Django: From Permissions to Policies : Part 3 — ContentTypes and the Model Registry

Updated
7 min read
Authorization in Django: From Permissions to Policies : Part 3 — ContentTypes and the Model Registry

Once we stop treating permissions as rules and start treating them as data, a natural question follows:

Data about what?

A permission is a label that says, *“*this action applies to that thing.”

But how does Django identify that thing—especially in a framework made up of many apps, models, and deployments?

This is where ContentTypes enter the picture.

They are not an advanced feature, and they are not specific to authorization. Yet without them, Django’s authorization system could not exist in a stable or predictable form.

This part explains what ContentTypes really are, why permissions depend on them, and how this design choice quietly supports everything from migrations to tooling.

The Problem Django Needs to Solve

Django applications are not monoliths. Even modest projects contain multiple apps, each with their own models:

  • blog.Post

  • billing.Invoice

  • accounts.UserProfile

  • inventory.Product

Permissions must be able to express statements like:

“This capability applies to this model.”

At first glance, this sounds trivial. Why not store a reference to the model class itself?

Because Django must function across boundaries that Python objects cannot persist through:

  • databases outlive code

  • migrations reshape schemas

  • apps are installed, removed, or renamed

  • permissions must remain stable across environments

Django therefore needs a database-level identity for models—one that is stable, introspectable, and independent of Python imports.

That is exactly what ContentTypes provide.

What a ContentType Actually Represents

A ContentType is a database record that represents one installed Django model.

Each ContentType answers a single question:

“Which model does this row refer to?”

It does this using two fields:

  • app_label

  • model (lowercase model name)

Together, these form a stable identifier such as:


  (blog + post)
  (auth + user)
  (billing + invoice)

That’s it.

No logic.
No behavior.
No permissions.

A ContentType is simply Django’s canonical registry of models, expressed as data.

Why the Name Is Misleading

The term ContentType often confuses people early on.

It does not mean:

  • CMS content

  • posts or pages

  • user-generated content

Despite the name, a ContentType is best understood as:

A database-level identifier for a Django model.

Once this definition clicks, the rest of the system becomes much easier to reason about.

Why Permissions Depend on ContentTypes

A permission describes a capability:

“A user may attempt action X on model Y.”

The action is easy to store (change, delete, publish, etc.).
The difficult part is model Y.

Instead of pointing to a Python class, each permission points to a ContentType row.

Conceptually:

Permission
  ├─ codename: "change_post"
  └─ content_type → ContentType("blog", "post")

This indirection is not accidental.
It is the design.

Why ContentTypes Exist (Design Consequences)

By referencing ContentTypes instead of model classes, Django gains several critical properties.

Stability

Because permissions point to (app_label, model) rather than a Python class, they remain valid even if the model’s import path or internal structure changes.

Example: refactoring blog/models/post.py into blog/models/content.py does not invalidate blog.change_post.

Portability

Permissions are stored as plain relational data and can be dumped and restored without relying on code execution order or imports.

Example: after restoring a production database into staging, permissions remain intact even before Django finishes loading all apps.

Introspection

Django can reason about models and permissions using database queries alone, without importing application code.

Example: Django Admin can list permissions for all installed models even when some apps are not actively loaded.

Consistency

Because every system uses canonical identifiers (app_label.codename), permission checks behave the same everywhere.

Example: user.has_perm("blog.change_post") works identically in Admin, DRF permission classes, and internal service code.

This is infrastructure-level thinking. Django optimizes for correctness over time, not convenience in the moment.

What Happens When Model Identity Changes

A Django model’s authorization identity is defined by its ContentType:


  (app_label, model).

So far, we have treated that identity as stable. Sometimes, however, it changes—usually during refactors.

When it does, Django treats the result as a new model, even if the database table or business meaning remains the same.

Renaming a Model


 blog.Post → blog.Article

The identity changes from:


 (blog, post) → (blog, article)

During migration, Django:

  • creates a new ContentType for blog.article

  • generates a new set of default permissions:

    • blog.add_article

    • blog.change_article

    • blog.delete_article

    • blog.view_article

The old ContentType and permissions remain unchanged.

Changing an App Label


  blog.Post → content.Post

The identity now changes from:


  (blog, post) → (content, post)

This represents an entirely new model identity. Django creates:

  • a new ContentType

  • a new set of permissions such as content.change_post

The old permissions remain in the database but no longer correspond to an active model.

Enforcement After Identity Changes

When identity changes:

  • a new ContentType is created

  • new default (and custom) permissions are generated

  • existing user and group assignments remain tied to the old permissions

As a result, checks such as:


  user.has_perm("blog.change_article")

begin returning False until permissions are explicitly reassigned.

This behavior is intentional. Django enforces permissions by current identity, not historical intent.

What Happens to Custom Permissions

Custom permissions follow the same rules as default permissions because they are the same kind of data. Each custom permission is stored as a (content_type, codename) pair. When a model is renamed or moved, Django creates new permission rows for the new identity, while existing assignments remain linked to the old permissions. Until those assignments are explicitly migrated, custom permission checks will also fail.

Safe Refactor Checklist (Authorization-Aware)

Use this checklist whenever a refactor touches model names or app labels.

Before the change

  • Treat app_label and model names as authorization identifiers

  • Audit which users and groups rely on affected permissions

  • Search for hard-coded permission strings (has_perm, DRF classes, templates)

During the change

  • Use explicit rename migrations (not delete + create)

  • Avoid changing app labels unless absolutely necessary

  • Review custom permission codenames

After the change

  • Verify new permissions were generated

  • Reassign or migrate permission grants intentionally

  • Check Admin visibility and API access paths

  • Run authorization-focused tests

Principle to keep in mind

Structural refactors can be authorization events. Treat them with the same care as access changes.

The Authorization Loop Django Builds

Once models are registered through ContentTypes, Django forms a closed loop:

  • models are defined in code

  • migrations ensure ContentTypes exist

  • permissions are generated per ContentType

  • authorization checks rely on canonical strings

  • tooling consumes those identifiers everywhere

Because this loop is convention-based and data-driven, it remains stable across deployments—as long as app labels and model names remain stable.

What ContentTypes Do Not Do

ContentTypes do not:

  • enforce permissions

  • understand ownership

  • encode workflows

  • apply tenant boundaries

  • make authorization decisions

They exist so other systems—permissions, generic relations, admin tooling—can refer to models consistently.

Nothing more.
Nothing less.

A Boundary That Matters

Because permissions reference models via ContentTypes:

  • permissions are model-level by default

  • has_perm("blog.change_post") has no object context

  • Django cannot infer which post is being changed

This is intentional.

Permissions answer “may attempt?”
Domain logic answers “is this valid right now?”

Blurring that boundary is where authorization systems become brittle.

Why This Design Scales

Django could have built rule engines or policy DSLs. Instead, it chose conventions, stable identifiers, relational data, and predictable APIs.

ContentTypes are a quiet but critical part of that choice.

The Takeaway

A ContentType is Django’s answer to a simple but essential question:

“How does the database know what a model is?”

Permissions depend on ContentTypes because:

  • permissions must reference models

  • references must be stable

  • stability enables tooling, migrations, and long-term safety

Once we see ContentTypes as a model registry, Django’s authorization system becomes easier to reason about—and much harder to misuse.

Where We Go Next (Part 4 Preview)

Now that we understand:

  • permissions are data

  • ContentTypes identify models

  • the system relies on conventions

The next question is unavoidable:

How does Django decide which permissions exist in the first place?

Part 4 explains Django’s convention-based permission generation—why add, change, delete, and view exist, why naming matters, and why this choice underpins Django’s authorization ecosystem.

More from this blog

A

Abhilash PS — Engineering Thought & Software Architecture

23 posts