When building Django applications that rely on permissions and roles, the auth.Group
model becomes an essential part of your authentication strategy. But one common pitfall developers face is forgetting to ensure required groups exist, leading to runtime errors, permission issues, or confusing behaviour for admins and users.
In this post, we’ll explore why and when you need to pre-create groups, and the best ways to ensure they exist when your Django project starts.
The Hidden Costs of Using Data Migrations
Data migrations are often the first tool developers reach for when they want to insert default records into the database, and for good reason. They’re versioned, repeatable, and part of Django’s built-in workflow. But once your project grows, the downsides of relying on data migrations become more apparent:
1. The Code Is Easy to Forget About
Data migration logic is buried inside the migrations/ folder, tucked away from your main app logic.
Unless you’re specifically looking for it, it’s easy to:
- Forget what data was created and why
- Miss it during reviews
- Duplicate effort elsewhere in your codebase
This leads to things like:
- Devs recreating groups/settings manually
- QA or staging environments with missing setup
- Confusing bugs that come down to “works on my machine”
- You can’t search for Group.objects.get_or_create(…) and expect to find it if it’s only in a migration.
2. Clashing or Deleted Migrations = Lost Data Setup
In fast-moving projects, developers often:
- Rebase or squash migrations
- Regenerate migrations from scratch
- Manually delete broken migration chains
This is fine for schema migrations, but it wipes out any embedded data logic. Imagine you’re merging branches, and someone squashed away the migration that created your “Admin” group. Suddenly, that logic is gone, and nobody notices until the production deploy breaks.
3. Harder to Evolve Over Time
If you change your required groups:
# from
['Admin', 'Editor']
# to
['Admin', 'Manager', 'Support']
You now have to write a new migration to reflect the update. Carefully decide whether to delete the old ones. Handle the upgrade/downgrade logic manually. This gets clunky if you’re evolving your business logic frequently.
4. No Visibility at Runtime
With migrations, you don’t know what was created or skipped unless you:
- Manually inspect the database
- Re-read the migration files
- Parse console logs during deployment
- This makes debugging harder, especially in environments where you don’t control the DB directly (like Heroku or managed services).
5. Less Friendly for Testing or CI/CD Pipelines
If your tests or CI setup recreate the database often, you’ll need to:
- Re-run migrations every time
- Hope the data migration ran successfully
- Have brittle test assumptions that rely on it
This is fine for some setups, but for others, it’s better to initialize key data dynamically during app boot or with a dedicated init script.
So… are data migrations bad? Not at all. They’re still:
- Deterministic
- Repeatable
- Version-controlled
But they work best for fixed, foundational data that doesn’t evolve much over time. Think:
- Initial superuser
- Base Group entries
- Hardcoded settings
If your data is dynamic, evolves frequently, or needs runtime awareness, you’re better off with startup checks:
A Safer, Cleaner Alternative: Using AppConfig.ready() and post_migrate
Instead of relying on fragile data migrations, a more maintainable and robust strategy is to run initialization logic when your Django app starts. One clean way to do this is by hooking into Django’s AppConfig.ready()
method and using the post_migrate
signal.
This pattern ensures that your critical data (like groups, permissions, or initial settings) is always validated or created after migrations run, without relying on buried data migration files.
Suppose you have a Django app called “myapp”, and it needs a specific group called “MyApp Admin” for managing application permissions. Here’s how you can make sure it always exists:
# myapp/apps.py
from typing import Any
from django.apps import AppConfig
from django.db.models.signals import post_migrate
MYAPP_ADMIN_GROUP_NAME = "MyApp Admin"
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self) -> None:
# Connect a handler that runs after migrations
post_migrate.connect(self.setup_group, sender=self)
@staticmethod
def setup_group(**kwargs: Any) -> None:
# Ensure the MyApp Admin group exists.
from django.contrib.auth.models import Group
Group.objects.get_or_create(name=MYAPP_ADMIN_GROUP_NAME)
The post_migrate signal runs after every migrate command, ensuring your logic executes only once the necessary database tables exist. Unlike data migrations, which are hidden away in the migrations folder, this approach is centralized in your main app code, making it easier to find, edit, and evolve alongside the rest of your application logic.
Bonus: Create a Unit Test to Ensure the Group Exists
When you rely on your Django app to create essential groups at startup, it’s a good idea to have a safety net in place. Here’s a simple unit-test:
# myapp/tests.py
import pytest
from django.contrib.auth.models import Group
from myapp.apps import MYAPP_ADMIN_GROUP_NAME
@pytest.mark.django_db
def test_admin_group_is_created() -> None:
"""The MyApp Admin group should exist after the app starts up."""
assert Group.objects.filter(name=MYAPP_ADMIN_GROUP_NAME).exists(), (
f"Group '{MYAPP_ADMIN_GROUP_NAME}' was not created during app startup"
)