ContentTypes & Generic Relations

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example was run on that stack in a scratch project on SQLite with the Django test runner, manage.py check, dumpdata and remove_stale_contenttypes; none of them needs PostgreSQL, and the two places where PostgreSQL would behave differently are called out. The contenttypes framework (django.contrib.contenttypes) keeps one database row per installed model and lets a row of one table point at a row of any other table, which is what the official documentation calls a generic (or polymorphic) relation. It is the machinery under the permission system and the admin history, and it is the usual answer to "one comment, tag or attachment model shared by many kinds of object". The Bookshelf application (the catalog app built across this section) uses it to tag books, authors and shelves with a single TaggedItem table, and the second half of the page explains why that convenience has a price and when a plain foreign key is the better design.

In a hurry: what goes where

Need Rule of thumb Section

Know which model a row refers to

ContentType has app_label and model; get it with ContentType.objects.get_for_model(), never a hand-written query.

The ContentType model

Point one row at any model

A ForeignKey to ContentType, a PositiveBigIntegerField (or CharField) for the key, and a GenericForeignKey.

GenericForeignKey

Reach the tags from a book

Add GenericRelation on the target model; it gives a related manager and (with related_query_name) reverse lookups.

GenericRelation

Filter tags by a property of the target

related_query_name, or filter on content_type plus object_id; filter(content_object=…​) raises FieldError.

Querying generic relations

Avoid one query per row

prefetch_related("content_object"), or GenericPrefetch to control each model’s queryset.

Avoiding one query per row

Edit generic objects in a form or the admin

generic_inlineformset_factory() and GenericTabularInline or GenericStackedInline.

Generic inlines in forms and the admin

Move data between databases

Dump with --natural-foreign; clean up with remove_stale_contenttypes.

Fixtures, natural keys, renames and stale rows

Decide whether to use any of it

Prefer a real foreign key, several nullable keys with a constraint, or inheritance when the set of targets is small and known.

When to avoid generic relations

The ContentType model

ContentType lives in django.contrib.contenttypes.models. Each row has two fields, app_label and model, which together identify one installed model: the last component of the app’s import path (catalog, auth, sites) and the lower-case model name (book, user). A read-only name property returns the model’s verbose_name. The framework is part of the default INSTALLED_APPS that startproject writes; the admin (for its change history) and django.contrib.auth (to tie permissions to models) both require it, so removing it is rarely an option.

Rows are created by the post_migrate signal: after every migrate, Django inserts a row for each model that has none, including proxy models. ContentTypeManager.get_for_model() also creates the row on demand if it is missing.

>>> from django.contrib.contenttypes.models import ContentType
>>> ct = ContentType.objects.get(app_label="catalog", model="book")
>>> ct
<ContentType: Catalog | book>
>>> ct.name
'book'
>>> ct.model_class()
<class 'catalog.models.Book'>
>>> ct.get_object_for_this_type(title="The Dispossessed")
<Book: The Dispossessed>

model_class() returns the model class a row stands for (or None when the model no longer exists, see Fixtures, natural keys, renames and stale rows), and get_object_for_this_type(using=None, **kwargs) runs a get() on that model. Together they allow code that works on "some model, named at runtime" without importing it. The string form includes the app label (for example Catalog | book) since Django 3.0, which disambiguates models with the same name in different apps.

ContentTypeManager and its cache

Do not look rows up with ContentType.objects.get(…​) in application code. The custom manager has methods that share a per-process cache, so after the first lookup there is no query at all:

Looking up content types (Django shell or tests)
from django.contrib.contenttypes.models import ContentType

from catalog.models import Author, Book, BookProxy, Shelf

book_ct = ContentType.objects.get_for_model(Book)             # a class ...
same = ContentType.objects.get_for_model(Book.objects.first())  # ... or an instance
by_id = ContentType.objects.get_for_id(book_ct.pk)            # shares the cache

mapping = ContentType.objects.get_for_models(Book, Author, Shelf)
# {<class 'catalog.models.Book'>: <ContentType: Catalog | book>, ...}

natural = ContentType.objects.get_by_natural_key("catalog", "book")
  • get_for_model(model, for_concrete_model=True) accepts a class or an instance. With for_concrete_model=False it returns the row of a proxy model instead of the row of the model the proxy is based on.

  • get_for_models(*models, for_concrete_models=True) returns a dictionary and fetches all missing rows in one query, which is the right call when a view needs several types.

  • get_for_id(id) shares the cache and is preferred over get(pk=id).

  • get_by_natural_key(app_label, model) exists so that rows can be referenced by natural key when data is deserialized (see Fixtures, natural keys, renames and stale rows).

  • clear_cache() empties the cache. Application code rarely needs it, but tests that count queries or reset state do.

from django.contrib.contenttypes.models import ContentType
from django.test import TestCase


class ContentTypesTests(TestCase):
    def setUp(self):
        ContentType.objects.clear_cache()
        self.addCleanup(ContentType.objects.clear_cache)

Run in the scratch project, the first get_for_model(Book) issued one query and the next get_for_model() and get_for_id() calls issued none.

Proxy models

A proxy model shares its table with its concrete parent but gets its own ContentType row (it has its own permissions, for example). get_for_model() returns the parent’s row by default; pass for_concrete_model=False to get the proxy’s:

class BookProxy(Book):
    class Meta:
        proxy = True


ContentType.objects.get_for_model(BookProxy)                           # the Book row
ContentType.objects.get_for_model(BookProxy, for_concrete_model=False)  # the bookproxy row

GenericForeignKey has the same switch as a constructor argument, for_concrete_model=False, so that a generic key can point at a proxy model’s row. Both behaviours were confirmed in the scratch tests.

Where Django itself uses it

The simplest use of ContentType is a plain ForeignKey to it, as a way of tying rows to model classes. The permission system does exactly that: auth.Permission has a content_type foreign key, which is how add_book is known to belong to catalog.Book. In the scratch project, Permission.objects.get(codename="add_book").content_type equals ContentType.objects.get_for_model(Book). See Permissions & Authorization for the rest of that machinery. The admin’s LogEntry does the same to record which kind of object was changed. Everything below goes one step further and adds the object’s primary key, which makes the relation generic.

GenericForeignKey

A normal ForeignKey points at exactly one model. A GenericForeignKey stores two plain columns instead (a content type and an object id) and combines them in Python, so one field can point at a row of any model.

flowchart LR T["TaggedItem row
tag, content_type_id, object_id"] C["django_content_type
id, app_label, model"] B["catalog_book
id = 7"] S["catalog_shelf
id = a UUID"] T -->|"content_type_id (real foreign key)"| C T -.->|"object_id = 7 (no constraint)"| B T -.->|"object_id = UUID text (no constraint)"| S

The solid arrow is a real foreign key with a database constraint. The dotted arrows are only a convention: the database knows nothing about them, which is the source of both the flexibility and the pitfalls discussed below.

The three parts

Setting one up takes three declarations on the model that does the pointing, as in the reference’s tagging example:

  1. a ForeignKey to ContentType (conventionally named content_type);

  2. a field for the target’s primary key (conventionally object_id, usually a PositiveBigIntegerField);

  3. a GenericForeignKey("content_type", "object_id"). When the two fields have the conventional names, the arguments may be omitted.

catalog/models.py (tags for books, authors and shelves)
import uuid

from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
    tags = GenericRelation("TaggedItem", related_query_name="book")

    def __str__(self):
        return self.title


class Shelf(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    name = models.CharField(max_length=100)
    tags = GenericRelation("TaggedItem", related_query_name="shelf")

    def __str__(self):
        return self.name


class TaggedItem(models.Model):
    tag = models.SlugField()
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.CharField(max_length=64)
    content_object = GenericForeignKey("content_type", "object_id")

    class Meta:
        indexes = [models.Index(fields=["content_type", "object_id"])]

    def __str__(self):
        return self.tag

The Meta.indexes entry matters: unlike a ForeignKey, a GenericForeignKey does not create a database index, and every lookup of the tags of one object filters on the pair (content_type, object_id). The official documentation recommends adding the composite index yourself and notes that this behaviour may change in the future.

Choosing the type of object_id

The reference uses PositiveBigIntegerField, which suits the default BigAutoField keys (the default for new projects since Django 6.0, see Where the books are outdated, and what changed recently). The rule is that the target’s primary key values must be coercible to the type of the object_id field by its get_db_prep_value() method; the field type does not have to match the primary key’s type. That is why the model above uses a CharField: it can hold the integer key of a Book and the UUID key of a Shelf in one column. A TextField has no maximum length but, according to the documentation, may cost significant performance on some backends; there is no universally best choice, so decide from the models you expect to point at.

The CharField choice has a cost, found while running the examples: the generic key stores the text form of the key ('78ce402e-8ef2-4a8c-86b5-03dfcb7b23cb' for a UUID), while a UUID column on SQLite stores its hex form without dashes. The manager shelf.tags.all() works, because it filters object_id using the same conversion; the reverse lookup TaggedItem.objects.filter(shelf__name=…​) joins object_id to the shelf’s primary key column directly, and in the scratch run it returned no rows. On PostgreSQL, which this page did not run, comparing a varchar column with an integer or UUID column in a join is a type mismatch, so the same caution applies. If you rely on reverse lookups through related_query_name, keep the target’s key type and the object_id type the same (a PositiveBigIntegerField for integer keys, a UUIDField for UUID keys), which in practice means one generic-relation table per key type.

Using the field

The field behaves like a foreign key for reading and assigning, including as a keyword argument to the constructor and to create():

Assigning and reading a generic key (Django shell)
>>> from catalog.models import Author, Book, Shelf, TaggedItem
>>> book = Book.objects.get(title="The Dispossessed")
>>> tag = TaggedItem.objects.create(content_object=book, tag="scifi")
>>> tag.content_type.model_class()
<class 'catalog.models.Book'>
>>> tag.object_id
'1'
>>> tag.content_object
<Book: The Dispossessed>
>>> shelf = Shelf.objects.create(name="Favourites")
>>> TaggedItem.objects.create(content_object=shelf, tag="mine").content_object
<Shelf: Favourites>

Assigning content_object fills content_type and object_id; reading it fetches the content type (from the cache) and then the target row, one query per distinct object. The result is cached on the instance, so the second read costs nothing.

What a generic key cannot do

Because the field is not a real column, four things behave differently from a ForeignKey, and all four were reproduced in the scratch tests:

  • No filtering. TaggedItem.objects.filter(content_object=book) and .get(content_object=book) raise FieldError. Filter on the two real fields instead (see Querying generic relations).

  • Not in model forms. A GenericForeignKey is not a form field. Listing it in a ModelForm’s `fields raises FieldError; a form for the model offers only tag, and the pointing is set in code or through the generic formset (see Generic inlines in forms and the admin).

  • No integrity. If the target row is deleted, content_type and object_id keep their values and content_object returns None. Nothing stops you storing an object_id that never existed.

  • No on_delete. GenericForeignKey accepts no on_delete argument, so the cascade behaviour of the next section is all you get. The documentation points at the pre_delete signal (see Signals) for custom behaviour.

The dangling reference, and the two ways to filter (Django shell)
>>> TaggedItem.objects.filter(content_object=book)
Traceback (most recent call last):
  ...
django.core.exceptions.FieldError: Field 'content_object' does not generate an automatic reverse relation and therefore cannot be used for reverse querying. If it is a GenericForeignKey, consider adding a GenericRelation.

>>> author = Author.objects.get(name="Ursula K. Le Guin")
>>> orphan = TaggedItem.objects.create(content_object=author, tag="author-tag")
>>> author.delete()
>>> TaggedItem.objects.get(pk=orphan.pk).content_object is None
True

The error message already names the remedy, which is the next section. The dangling row remains in the table until you delete it yourself, because Author has no GenericRelation here to cascade the deletion.

GenericRelation

GenericRelation is the reverse side: a field declared on a target model that names the generic-key model. It creates no column. It exists so that the target gets a related manager, can be queried from the tag side, takes part in aggregation, and (most importantly) deletes the tags when it is deleted.

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
    tags = GenericRelation("TaggedItem", related_query_name="book")

If the generic-key model uses non-default field names, the relation must be told which they are, with content_type_field and object_id_field:

tags = GenericRelation(
    TaggedItem,
    content_type_field="content_type_fk",
    object_id_field="object_primary_key",
)

The related manager

A Book now has a tags manager that filters on its own content type and key. It supports all(), filter() and the usual queryset methods, plus add(), create(), set(), remove() and clear():

Working with the generic manager (Django shell)
>>> t1 = TaggedItem.objects.create(content_object=book, tag="django")
>>> t2 = TaggedItem.objects.create(content_object=book, tag="python")
>>> book.tags.all()
<QuerySet [<TaggedItem: django>, <TaggedItem: python>]>
>>> t3 = TaggedItem(tag="web")
>>> book.tags.add(t3, bulk=False)    # t3 is saved with the book's type and key
>>> book.tags.create(tag="framework")
<TaggedItem: framework>
>>> book.tags.set([t1, t3])          # removes (deletes) the others
>>> book.tags.all()
<QuerySet [<TaggedItem: django>, <TaggedItem: web>]>
>>> book.tags.remove(t3)             # a bulk delete, not a detach
>>> book.tags.clear()
>>> TaggedItem.objects.count()
0

remove() and clear() delete the tag rows (a bulk delete, as the reference says); they do not detach them as the manager of a nullable ForeignKey would, because a tag with no target has no meaning. add() of an unsaved instance needs bulk=False, as in the example, so that Django saves the instance itself. Each call matched the behaviour shown in the reference; the final count() of zero was asserted in the scratch tests.

Deletion cascades through GenericRelation

If you delete an object that has a GenericRelation, the rows pointing at it through a GenericForeignKey are deleted too. Deleting a Book deletes its TaggedItem rows; deleting an Author (which declares no GenericRelation in this example) leaves orphans, as the previous section showed. This is the only deletion behaviour the framework offers: there is no on_delete option, and the documentation says that if you want to avoid the cascade, do not declare the GenericRelation and provide any alternative behaviour with a pre_delete signal receiver.

Two consequences follow. First, declare a GenericRelation on every model that can be a target, even if you never use the manager, or you will leak orphan rows. Second, the delete is performed by Django’s collector in Python, so queries that bypass it (a raw SQL DELETE, or a database-level cascade such as DB_CASCADE on some other relation) do not clean the tags up.

Querying generic relations

filter(content_object=…​) fails, but there are three supported ways to ask the question.

Through related_query_name. Declaring related_query_name="book" on the relation creates a lookup from the tag to the book (book__title), and the field name tags already gives the lookup in the other direction. Both work in filter(), exclude() and order_by():

Filtering through the generic relation (Django shell)
>>> TaggedItem.objects.filter(book__title__contains="Django")
<QuerySet [<TaggedItem: django>]>
>>> Book.objects.filter(tags__tag="scifi")
<QuerySet [<Book: The Dispossessed>]>

The query behind the first line joins catalog_book on object_id and also restricts content_type_id to the book’s row, so shelves with the same numeric key are not matched. Mind the key-type remark from Choosing the type of object_id for UUID targets.

By content type and key. Without related_query_name (or for a single object) filter on the two real fields:

book_type = ContentType.objects.get_for_model(Book)
TaggedItem.objects.filter(content_type=book_type, object_id=book.pk)

# for several objects, a queryset can stand in for the key list
books = Book.objects.filter(title__contains="Django")
TaggedItem.objects.filter(content_type=book_type, object_id__in=books)

Always include the content type in such a filter. object_id alone matches the same number in every table, and the scratch tests created a Book and an Author with the same primary key, each with its own tag, to confirm that only the content type keeps them apart.

Through aggregation. The aggregation API works with a GenericRelation: Book.objects.aggregate(Count("tags")) returned {'tags__count': 4} for four tags across two books, and Book.objects.annotate(n=Count("tags")) ranks books by tag count in one query. See Aggregation, Expressions & Functions.

Avoiding one query per row

A list of generic objects is a classic N+1 trap: each tag.content_object is one query on whichever table the row points at. With three tags on a book and three on a shelf, iterating TaggedItem.objects.all() and reading content_object issued 1 + 6 queries in the scratch tests (the content types were already cached).

Three ways to read the targets (Django shell)
from django.contrib.contenttypes.prefetch import GenericPrefetch

# 1. prefetch_related: one query per referenced table
tags = TaggedItem.objects.prefetch_related("content_object")
[t.content_object for t in tags]          # 1 + 2 queries (tags, books, shelves)

# 2. GenericPrefetch: you choose the queryset for each model
prefetch = GenericPrefetch(
    "content_object",
    [Book.objects.select_related("author"), Shelf.objects.only("name")],
)
tags = TaggedItem.objects.prefetch_related(prefetch)
[t.content_object for t in tags]          # 3 queries, books already joined to authors

# 3. From the target side: prefetch the generic relation
books = Book.objects.prefetch_related("tags")   # 2 queries for all books and all their tags

prefetch_related() supports GenericRelation (case 3) as it does any many-valued relation. For a GenericForeignKey (cases 1 and 2) the number of queries depends on the data: one per referenced table, plus possibly queries on the ContentType table for types not yet cached. GenericPrefetch(lookup, querysets, to_attr=None) takes a list of querysets, one for each ContentType the rows can reference, and it is how to apply select_related(), only() or filters to each model; it appeared in Django 5.0 together with plain support for prefetching a heterogeneous GenericForeignKey. The counts above were asserted with assertNumQueries(); see Query Optimization & Fetch Modes for the general techniques.

Generic inlines in forms and the admin

Because GenericForeignKey is not a form field, editing the tags of a book needs a dedicated formset class, in the way inlineformset_factory() handles a regular foreign key.

generic_inlineformset_factory

django.contrib.contenttypes.forms provides generic_inlineformset_factory(), built on modelformset_factory(), and the base class BaseGenericInlineFormSet. Its signature is generic_inlineformset_factory(model, form=ModelForm, formset=BaseGenericInlineFormSet, ct_field="content_type", fk_field="object_id", fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, validate_max=False, for_concrete_model=True, min_num=None, validate_min=False, absolute_max=None, can_delete_extra=True). Pass ct_field and fk_field when your fields have non-default names, and for_concrete_model=False to match a GenericForeignKey declared that way. The other arguments are the same as for the model formset factories; list fields explicitly, as the example does, so that no column is exposed by accident.

catalog/forms.py
from django.contrib.contenttypes.forms import generic_inlineformset_factory

from .models import TaggedItem

TagFormSet = generic_inlineformset_factory(
    TaggedItem, fields=["tag"], extra=2, can_delete=True
)
catalog/views.py
from django.shortcuts import get_object_or_404, redirect, render

from .forms import TagFormSet
from .models import Book


def book_tags(request, pk):
    book = get_object_or_404(Book, pk=pk)
    if request.method == "POST":
        formset = TagFormSet(request.POST, instance=book)
        if formset.is_valid():
            formset.save()
            return redirect("book-tags", pk=book.pk)
    else:
        formset = TagFormSet(instance=book)
    return render(request, "catalog/book_tags.html", {"book": book, "formset": formset})
catalog/templates/catalog/book_tags.html
<h1>Tags for {{ book }}</h1>
<form method="post">
  {% csrf_token %}
  {{ formset.management_form }}
  {{ formset.as_div }}
  <button type="submit">Save</button>
</form>
catalog/urls.py
from django.urls import path

from . import views

urlpatterns = [path("books/<int:pk>/tags/", views.book_tags, name="book-tags")]

The view is the same as for any inline formset: the form needs {% csrf_token %} and the management form, and save() sets content_type and object_id from instance. In the scratch test the GET page rendered with two empty forms, a POST of two tags redirected, and the book ended with exactly those two tags. The form prefix is built from the model and the two field names (catalog-taggeditem-content_type-object_id), so the management-form keys used by tests and JavaScript have that prefix. The formset fields offered were tag, the hidden id and DELETE, never the pointing columns. See Model Forms & Formsets for formsets in general; Django 3.2 added the absolute_max and can_delete_extra arguments to this factory, so a hostile POST cannot instantiate an unbounded number of forms when absolute_max is set.

GenericTabularInline and GenericStackedInline

The admin equivalent lives in django.contrib.contenttypes.admin. GenericInlineModelAdmin extends InlineModelAdmin with two attributes, ct_field (the name of the content-type foreign key, default content_type) and ct_fk_field (the name of the integer field holding the id, default object_id); GenericTabularInline and GenericStackedInline are its tabular and stacked layouts. Used on the parent’s admin, they behave like any other inline:

catalog/admin.py
from django.contrib import admin
from django.contrib.contenttypes.admin import GenericTabularInline

from .models import Author, Book, Shelf, TaggedItem


class TaggedItemInline(GenericTabularInline):
    model = TaggedItem
    extra = 1
    fields = ["tag"]


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ["title", "author"]
    inlines = [TaggedItemInline]


@admin.register(Shelf)
class ShelfAdmin(admin.ModelAdmin):
    inlines = [TaggedItemInline]


admin.site.register(Author)

One inline class serves both parents, because the inline derives the content type from whichever object is being edited. In the scratch test a superuser opened the Book change page, which contained the inline’s management form, and posted a new tag, which was saved with the book’s content type and key. The usual inline options (extra, min_num, max_num, fields, classes, can_delete) apply as for any inline; see The Admin Site for those and Admin Customization for custom inlines.

Fixtures, natural keys, renames and stale rows

The primary key of a ContentType row is an artefact of the database: it depends on the order in which models were first migrated, so it differs between a developer’s database, staging and production. Data that stores content_type_id therefore cannot be moved by plain primary keys.

Natural keys

ContentType supports natural keys: the pair (app_label, model). dumpdata --natural-foreign writes the content type of a TaggedItem as a pair, and deserialization resolves it with get_by_natural_key(). The documentation says that when you serialize a model with generic relations you should probably use natural keys:

python manage.py dumpdata catalog.taggeditem --natural-foreign --indent 2
[
  {
    "model": "catalog.taggeditem",
    "pk": 1,
    "fields": {
      "tag": "n",
      "content_type": ["catalog", "book"],
      "object_id": "1"
    }
  }
]

(The output above is reformatted from the single line dumpdata printed in the scratch run.) The test deleted the tags, deserialized the dump with serializers.deserialize("json", …​) and saved the objects, and the restored tag’s content_object was the original book. Remember that object_id is still an uninterpreted number: the dump is only portable if the target rows keep their primary keys. See Fixtures, Serialization & Initial Data for natural keys and loaddata in general.

Renames and stale content types

When a model is deleted, its ContentType row stays behind, because post_migrate only adds rows. The row is stale: model_class() returns None for it, and anything that depends on it (permissions, and any generic-key rows pointing at it) still exists. The remove_stale_contenttypes command, available when the contenttypes app is installed, deletes them; it first lists the objects that would be deleted along with them and asks for confirmation, and it accepts --database and --include-stale-apps (which also removes rows belonging to apps no longer in INSTALLED_APPS, added in Django 3.1).

python manage.py remove_stale_contenttypes
python manage.py remove_stale_contenttypes --include-stale-apps --database default

In the scratch test a hand-created row catalog | gone was removed by the command with interactive=False, which printed Deleting stale content type 'catalog | gone'. Treat the command as destructive: deleting a content type cascades to every Permission and every generic-key row that references it, so run it after a deploy that deleted models, and review the list it prints. Renaming a model is handled by the framework: before a migration run, the contenttypes app inserts a step after each RenameModel operation so that the existing row is renamed instead of a new one being created next to the old one, which keeps permissions and generic keys attached. The framework implements this on its own and does not document it as an API, so check your migration plan with Migrations when renaming a model that is the target of generic keys.

System checks

Models that declare a GenericForeignKey or GenericRelation are covered by the contenttypes system checks, which run with every manage.py check and migrate (see System Checks):

Check Meaning

contenttypes.E001

The GenericForeignKey object ID references a field that does not exist.

contenttypes.E002

The GenericForeignKey content type references a field that does not exist.

contenttypes.E003

The content-type field is not a ForeignKey.

contenttypes.E004

The content-type field is not a ForeignKey to contenttypes.ContentType.

contenttypes.E005

Model names must be at most 100 characters (the length of ContentType.model).

contenttypes.E006

The content-type field uses a database-level on_delete variant (DB_CASCADE and friends, new in 6.1).

The most common mistake is a typo in the field names. Adding this model to the scratch app produced, from manage.py check:

class Broken(models.Model):
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    thing = GenericForeignKey("content_type", "oid")
SystemCheckError: System check identified some issues:

ERRORS:
catalog.Broken.thing: (contenttypes.E001) The GenericForeignKey object ID references the nonexistent field 'oid'.

System check identified 1 issue (0 silenced).

The scratch tests also produced E002 (a content-type name that does not exist), E003 (an IntegerField named content_type), E004 (a foreign key to Author) and E006 (models.DB_CASCADE on the content type). The last one is worth remembering: a database-level cascade is carried out by the database without Django’s collector running, and the checks reject that option for the content-type field of a generic key, so the check rejects the combination.

Async code

content_object is a plain attribute that runs a query when first read, so reading it from async code raises SynchronousOnlyOperation. The scratch run confirmed it. Use the async queryset API, or wrap the access:

Reading a generic target from an async view
from asgiref.sync import sync_to_async
from django.contrib.contenttypes.models import ContentType

from catalog.models import TaggedItem


async def target_of(tag_pk):
    tag = await TaggedItem.objects.aget(pk=tag_pk)

    # option 1: resolve it yourself with the async ORM
    content_type = await ContentType.objects.aget(pk=tag.content_type_id)
    model = content_type.model_class()
    return await model.objects.aget(pk=tag.object_id)

    # option 2: run the attribute access in a thread
    # return await sync_to_async(lambda: tag.content_object)()

Both options returned the book in the scratch run. Iterating a GenericRelation manager works with async for ([t.tag async for t in book.tags.all()]), as for any queryset. See Making Queries for the async ORM interface.

When to avoid generic relations

The documentation shows generic relations as a feature, but they trade away exactly the things a relational database is for. Count the costs before choosing one:

  • No referential integrity. The database cannot stop object_id from naming a row that does not exist, and it cannot cascade. Everything depends on GenericRelation being declared on every target and on every delete going through the ORM.

  • No joins by default. Without related_query_name, the database cannot join tags to targets, so reporting queries ("every tag on a book by this author") need several queries or manual object_id__in subqueries.

  • Weak typing and indexing. The key column is one type for all targets (and is a CharField if mixed key types are needed, which breaks the reverse join as shown in Choosing the type of object_id), and the composite index is yours to maintain.

  • N+1 by nature. Reading the targets of many rows costs one query per referenced table at best, which is why this page needs a prefetching section.

  • Awkward for forms, serializers and APIs. Model forms do not offer the field and serializer libraries need custom code for it, so each layer needs custom code (see Generic inlines in forms and the admin).

  • Awkward for data migration. Primary keys of content types differ between databases, and orphan rows survive deletions that bypass the collector.

The usual alternatives, ordered from the simplest:

  1. A plain ForeignKey. If the relation has one target model (comments on books, reviews of books) use a normal key. The Bookshelf reviews in the Relationships page do exactly that.

  2. A few nullable foreign keys plus a constraint. When the set of targets is small and known (a note on a book or on an author), give the model one nullable key per target and a CheckConstraint that requires exactly one of them. The database enforces integrity, the ORM joins and filters normally, and forms work. The cost is one column per target and a migration when a target is added.

  3. Model inheritance. If the targets share a common parent (Publication with Book and Magazine children), point at the parent with a normal key; see Model Inheritance & Meta API.

  4. One table per target, generated from an abstract base class (BookTag, ShelfTag), when the volume is high and each tag is queried only with its target.

  5. A many-to-many with a through model, if the same tag must attach to many targets of one kind.

catalog/models.py (the constraint alternative)
class Note(models.Model):
    text = models.CharField(max_length=200)
    book = models.ForeignKey(
        Book, null=True, blank=True, on_delete=models.CASCADE, related_name="notes"
    )
    author = models.ForeignKey(
        Author, null=True, blank=True, on_delete=models.CASCADE, related_name="notes"
    )

    class Meta:
        constraints = [
            models.CheckConstraint(
                condition=(
                    models.Q(book__isnull=False, author__isnull=True)
                    | models.Q(book__isnull=True, author__isnull=False)
                ),
                name="note_exactly_one_target",
            ),
        ]

    def __str__(self):
        return self.text

In the scratch tests a Note with only a book and one with only an author were saved, while a note with both targets, and one with neither, raised IntegrityError from the database. book.notes.all() and Book.objects.filter(notes__text=…​) are ordinary joins. (The argument is named condition; the older check argument was deprecated in Django 5.1.) See Constraints, Indexes & Composite Keys for the constraint API.

Source: CheckConstraint

Generic relations remain the right tool when the set of targets is open (a reusable app that must attach tags or comments to models it has never seen), when you control all deletes, and when the target count is small enough that one query per referenced table is acceptable. Inside one project with a known list of models, the constraint-based design is almost always the better one.

Where the books are outdated, and what changed recently

The books consulted for this section (listed on the section bibliography page) describe the contenttypes framework as of Django 1.x and 2.x. The concepts (the ContentType row, the three-part generic key, GenericRelation, generic inlines) are unchanged and still work as they did; the edges differ.

  • object_id type. Older tutorials declare the id as PositiveIntegerField, which the current admin documentation still shows in its inline example. The contenttypes reference now uses PositiveBigIntegerField (the field was added in Django 3.1), which matches the BigAutoField default primary keys that new projects get from Django 6.0 on. With AutoField or BigAutoField keys the old integer field works until the first id above its limit.

  • The index is yours. index_together is gone (removed in Django 5.1); declare the composite index with Meta.indexes, as the reference does, because a generic key does not index itself.

  • Prefetching. Heterogeneous prefetching of a GenericForeignKey (rows pointing at several different models) arrived in 5.0, with GenericPrefetch to control each model’s queryset; books describe only the plain prefetch_related() call.

  • Stale rows. remove_stale_contenttypes gained --include-stale-apps in 3.1, which matters for projects that dropped apps long ago.

  • Formset limits. The absolute_max and can_delete_extra formset arguments (3.2) are not in any of the books.

  • Related-manager caching. Django 4.1 briefly cached the related managers of GenericRelation on the model instance; the change was reverted in 4.1.2, so nothing needs to be done today.

  • Async. None of the books covers async views; the generic key is a synchronous attribute (see Async code).

Table 1. contenttypes changes by version
Version Change

3.0

ContentType.__str__() includes the app label, to disambiguate models with the same name in different apps.

3.1

PositiveBigIntegerField added. remove_stale_contenttypes --include-stale-apps added.

3.2

generic_inlineformset_factory() gained absolute_max and can_delete_extra.

4.1

Related managers (including GenericRelation) cached on the instance; reverted in 4.1.2.

5.0

prefetch_related() supports a GenericForeignKey with a heterogeneous set of results; GenericPrefetch added.

5.1

index_together removed, so composite indexes use Meta.indexes.

6.0

BigAutoField becomes the default primary key type for new apps and projects.

6.1

GenericForeignKey now uses a separate (private) GenericForeignKeyDescriptor class; the DB_CASCADE family of on_delete values arrives, and the content-type field may not use it (contenttypes.E006).

Checklist

  • Every target model of a GenericForeignKey declares a GenericRelation, so deletes cascade and no orphans are left.

  • The generic-key model has a composite index on (content_type, object_id), declared in Meta.indexes.

  • object_id is a PositiveBigIntegerField unless the targets have mixed key types; with CharField, reverse related_query_name joins were checked on the target database.

  • Filters always include the content type; nothing filters on content_object=.

  • Lists of generic objects use prefetch_related() or GenericPrefetch, and query counts are asserted in tests.

  • Content types are looked up with get_for_model() or get_for_models(), not with get().

  • Fixtures use --natural-foreign; remove_stale_contenttypes is run, reviewed, after models are deleted.

  • Async code does not read content_object directly.

  • Before adding a generic relation, a plain foreign key, a constrained set of nullable keys and inheritance were considered and rejected.