Upgrading to Direct Foreign Keys
This guide covers the process of migrating a running, production project from django-guardian's default generic permission tables to the direct foreign key approach — without losing any existing permission data.
Why Migrate?
Django-guardian's default UserObjectPermission and GroupObjectPermission
models use Django's contenttypes framework to store a generic relation
(content_type + object_pk). This design is flexible, but it has a cost:
object_pkis stored as aCharField, so every query must cast or compare strings instead of using native primary key types.- There is no real database-level foreign key, meaning the database cannot enforce referential integrity or use foreign key indexes efficiently.
- As the permission tables grow, queries that filter by
content_typeandobject_pkbecome increasingly expensive — especially on tables with millions of rows.
For high-volume projects — or any project where object permission checks appear in hot paths — switching to direct foreign keys can yield a dramatic improvement in query performance and data integrity.
See Also
Performance Tuning — Direct Foreign Keys for an overview of the direct foreign key approach.
Operational Safety
Do Not Run This Migration on a Live System
This migration must not run while the application is actively serving requests that create, modify, or delete object permissions. If a permission is assigned or removed in the generic table during the data copy, a race condition occurs: the new record will not be copied to the direct table, resulting in silently lost permissions.
Before starting the migration, complete all three of the following pre-flight checks:
1. Enable Maintenance Mode
Take the application offline so that end users cannot trigger any permission changes. A convenient way to do this is the django-maintenance-mode package:
pip install django-maintenance-mode
python manage.py maintenance_mode on
2. Stop Background Workers
Stop or pause all Celery workers, cron jobs, management command schedules,
and any other background tasks that may call assign_perm, remove_perm, or
modify object permissions in any way. Any write to the generic permission
tables during the migration window can cause data inconsistency.
3. Take a Full Database Backup
Before touching any data, create a complete database backup. If anything goes wrong during the migration, you need to be able to restore to the pre-migration state.
# PostgreSQL example
pg_dump -Fc mydb > pre_direct_fk_migration.dump
Step 1 — Define the Direct Foreign Key Models
For each model that has object permissions, create two new models that inherit
from UserObjectPermissionBase and GroupObjectPermissionBase. Set
enabled = False so that django-guardian continues to use the generic tables
while the new tables are being created and populated.
# myapp/models.py
from django.db import models
from guardian.models import UserObjectPermissionBase, GroupObjectPermissionBase
class Project(models.Model):
name = models.CharField(max_length=128, unique=True)
class Meta:
permissions = (
("view_project", "Can view project"),
("manage_project", "Can manage project"),
)
class ProjectUserObjectPermission(UserObjectPermissionBase):
content_object = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="user_object_permissions"
)
enabled = False # Keep using generic tables for now
class ProjectGroupObjectPermission(GroupObjectPermissionBase):
content_object = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="group_object_permissions"
)
enabled = False # Keep using generic tables for now
Now generate and apply the migration to create the new tables:
python manage.py makemigrations myapp
python manage.py migrate myapp
At this point, the new tables exist but are empty, and django-guardian is still reading from and writing to the generic tables.
Step 2 — Data Migration
Create a data migration that copies every existing permission from the generic tables into the new direct tables.
python manage.py makemigrations myapp --empty -n migrate_to_direct_fk
Then edit the generated file:
# myapp/migrations/XXXX_migrate_to_direct_fk.py
from django.db import migrations
def forwards(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
Project = apps.get_model("myapp", "Project")
ProjectUserObjPerm = apps.get_model("myapp", "ProjectUserObjectPermission")
ProjectGroupObjPerm = apps.get_model("myapp", "ProjectGroupObjectPermission")
project_ct = ContentType.objects.get_for_model(Project)
# --- User permissions ---
user_perms = UserObjectPermission.objects.filter(content_type=project_ct)
BATCH_SIZE = 2000
batch = []
for perm in user_perms.iterator():
batch.append(
ProjectUserObjPerm(
permission_id=perm.permission_id,
user_id=perm.user_id,
# Cast object_pk to the target model's PK type.
content_object_id=int(perm.object_pk), # see warning below
)
)
if len(batch) >= BATCH_SIZE:
ProjectUserObjPerm.objects.bulk_create(batch, ignore_conflicts=True)
batch = []
if batch:
ProjectUserObjPerm.objects.bulk_create(batch, ignore_conflicts=True)
# --- Group permissions ---
group_perms = GroupObjectPermission.objects.filter(content_type=project_ct)
batch = []
for perm in group_perms.iterator():
batch.append(
ProjectGroupObjPerm(
permission_id=perm.permission_id,
group_id=perm.group_id,
content_object_id=int(perm.object_pk), # see warning below
)
)
if len(batch) >= BATCH_SIZE:
ProjectGroupObjPerm.objects.bulk_create(batch, ignore_conflicts=True)
batch = []
if batch:
ProjectGroupObjPerm.objects.bulk_create(batch, ignore_conflicts=True)
def backwards(apps, schema_editor):
"""
Reverse migration: remove all records from direct tables.
The generic table records are still intact.
"""
ProjectUserObjPerm = apps.get_model("myapp", "ProjectUserObjectPermission")
ProjectGroupObjPerm = apps.get_model("myapp", "ProjectGroupObjectPermission")
ProjectUserObjPerm.objects.all().delete()
ProjectGroupObjPerm.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("myapp", "XXXX_previous_migration"), # replace with actual name
("guardian", "0001_initial"),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.RunPython(forwards, backwards),
]
Primary Key Type Casting
The generic table stores object_pk as a string. You must cast it to
the target model's actual primary key type when building the direct
record:
| Target PK type | Cast |
|---|---|
AutoField / BigAutoField / IntegerField |
int(perm.object_pk) |
UUIDField |
uuid.UUID(perm.object_pk) |
CharField |
No cast needed — use perm.object_pk directly |
Mismatched types will cause IntegrityError or silent data corruption.
Multiple Models
If you have more than one model with object permissions (e.g. Project,
Task, Document), you need to repeat the direct model pair and the
migration block for each model. You can place all copies in the same
RunPython function to run them in a single migration.
Apply the migration:
python manage.py migrate myapp
Step 3 — Enable the Direct Models
Now that the data is in place, remove the enabled = False flag (or set it to
True) so that django-guardian starts using the direct tables for all future
reads and writes:
# myapp/models.py
class ProjectUserObjectPermission(UserObjectPermissionBase):
content_object = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="user_object_permissions"
)
# enabled = False ← removed
class ProjectGroupObjectPermission(GroupObjectPermissionBase):
content_object = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="group_object_permissions"
)
# enabled = False ← removed
Since enabled is a class attribute and not a database field, no new
migration is required. A deployment (application restart) is enough for the
change to take effect.
Verification
After enabling, verify that permissions still work correctly:
from guardian.shortcuts import get_perms
from myapp.models import Project
project = Project.objects.first()
print(get_perms(user, project))
The output should match the permissions that were previously assigned via the generic tables.
At this point you can disable maintenance mode and resume normal operations:
python manage.py maintenance_mode off
Step 4 — Clean Up Generic Records
After the migration is verified and the direct tables are actively in use, the old generic records for the migrated models are no longer read by django-guardian. However, they still occupy space in the database. Leaving them in place has two negative consequences:
-
Database bloat. The
guardian_userobjectpermissionandguardian_groupobjectpermissiontables retain rows that are never queried, wasting storage and slowing down maintenance operations likeVACUUMor index rebuilds. -
Stale data risk. If the
enabled = Falseflag is accidentally reintroduced (e.g. during a merge conflict), django-guardian will fall back to reading the generic tables. Those records are now stale — they do not reflect permission changes made after the migration — and will produce incorrect authorization decisions that are extremely hard to debug.
Important
It is strongly recommended to delete the orphaned generic records once you are confident the direct foreign key setup is working correctly.
Create a dedicated cleanup migration:
# myapp/migrations/XXXX_cleanup_generic_perms.py
from django.db import migrations
def cleanup_generic_records(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
Project = apps.get_model("myapp", "Project")
project_ct = ContentType.objects.get_for_model(Project)
UserObjectPermission.objects.filter(content_type=project_ct).delete()
GroupObjectPermission.objects.filter(content_type=project_ct).delete()
class Migration(migrations.Migration):
dependencies = [
("myapp", "XXXX_migrate_to_direct_fk"), # replace with actual name
("guardian", "0001_initial"),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.RunPython(cleanup_generic_records, migrations.RunPython.noop),
]
Tip
If you are not comfortable with an irreversible cleanup, keep a database backup taken after Step 2 as your rollback safety net.
Quick Reference
| Step | Action | Downtime Required |
|---|---|---|
| Prep | Backup database, enable maintenance mode | Yes |
| 1 | Define direct FK models with enabled = False, run makemigrations + migrate |
No (schema only) |
| 2 | Data migration — copy generic → direct with bulk_create |
Yes |
| 3 | Remove enabled = False, deploy |
No (app restart) |
| 4 | Delete stale generic records | No |
| Post | Disable maintenance mode, verify | — |