How I Fix a Broken Project-Owner Relationship in Django

I was building a project management app in Django where users could own projects and collaborate with others. Simple enough, right? But when I tried to link Account and Project with a many-to-many relationship (so contributors could join projects), Django hit me with cryptic errors like “not a foreign key” and “missing a foreign key”.

Turns out, I misunderstood how through models and through_fields work. Here’s how I fixed it—and how you can avoid my mistakes.

The Original Problem Owners vs. Contributors

My goal was straightforward:

  • A Project should have one owner (an Account).
  • Multiple Account users could be contributors to a Project.

But my initial code looked like this:

# Broken Model Setup
class Project(models.Model):
project_name = models.CharField(max_length=50)

class Account(models.Model):
username = models.CharField(max_length=50)
projects = models.ManyToManyField(
Project,
through='AccountProjects',
through_fields=('owner', 'project'), # Wait, what’s this??
related_name='contributors'
)

class AccountProjects(models.Model):
owner = models.ForeignKey(Account, on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.CASCADE)

The Errors I Faced

  1. 'AccountProjects.owner' is not a foreign key to 'Project'
    I’d incorrectly assumed through_fields=('owner', 'project') meant “use owner from Account and project from Project. Nope. Django was yelling: “Your owner field points to Account, not Project!”
  2. Missing foreign key to 'Account' or 'Project'
    My AccountProjects model was missing a foreign key to one of the related models. Django requires the intermediate (“through”) model to have FKs to both sides of the M2M relationship.

Simplify and Clarify Relationships

Here’s the corrected code:

# Fixed Models
class Project(models.Model):
project_name = models.CharField(max_length=50)
# Owner is a direct FK (one owner per project)
owner = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='owned_projects')

class Account(models.Model):
username = models.CharField(max_length=50)
# Contributors are linked via M2M through AccountProjects
projects = models.ManyToManyField(
Project,
through='AccountProjects',
related_name='contributors'
)

class AccountProjects(models.Model):
account = models.ForeignKey(Account, on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.CASCADE)

Why This Works

  1. through_fields Removed
    The through_fields parameter is only needed if there’s ambiguity (e.g., multiple FKs to the same model). Here, AccountProjects has one FK to Account and one to Project, so Django automatically figures it out.
  2. Explicit Foreign Keys
    The AccountProjects model now has both account and project FKs, satisfying Django’s requirement for intermediate models.
  3. Separate Owner Field
    The owner is defined directly on Project, making it clear that ownership is distinct from contributors.

Bonus: Query Owners vs. Contributors

With the fixed setup, you can now:

  • List Contributors (Excluding the Owner)
def get_contributors(project):
    return Account.objects.filter(accountprojects__project=project).exclude(id=project.owner.id)
  • Check if a User is a Contributor
def is_contributor(user, project):
    return AccountProjects.objects.filter(account=user, project=project).exists()
  • Add Roles to Contributors (e.g., “Editor”, “Viewer”)
class AccountProjects(models.Model):
    ROLES = (("viewer", "Viewer"), ("editor", "Editor"))
    account = models.ForeignKey(Account, on_delete=models.CASCADE)
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
    role = models.CharField(max_length=20, choices=ROLES)

Final Thoughts

Here’s what I learned the hard way:

  1. through Models Are Bridges: They need explicit connections to both sides of the relationship.
  2. Ownership ≠ Contribution: Separate direct FKs (like owner) from M2M fields for clarity.
  3. Django’s Errors Are Clues: They’re frustrating but often point directly to the fix.

Next steps? Run makemigrations and migrate, then test in Django Admin to ensure owners and contributors behave as expected.

Related blog posts