# 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`](http://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:

```pgsql

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

That’s it.

No logic.  
No behavior.  
No permissions.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769157132151/feecf64c-983a-4947-8524-0631d0728413.png align="center")

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**.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769157166482/82d3d5cd-097b-473a-a256-c026683bc0dc.png align="center")

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

Conceptually:

```python
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`](http://post.py) into `blog/models/`[`content.py`](http://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:

```pgsql

  (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

```python

 blog.Post → blog.Article
```

The identity changes from:

```python

 (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`
        

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769156820432/3e2a56fc-3fb9-439c-a208-df3d6d1d1448.png align="center")

The old ContentType and permissions remain unchanged.

### Changing an App Label

```python

  blog.Post → content.Post
```

The identity now changes from:

```python

  (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`
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769157661408/a50b38b4-8f9e-445a-b36b-f6d042afef6e.png align="center")

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:

```python

  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.
