The "Save Button" Dilemma: Choosing Redundancy Over DRY in Django

Comparing Explicit Tables, GenericForeignKeys, and Many-to-Many relationships for user collections.


TLDR: I needed a way for users to save movies, anime, and manga. After weighing three common Django patterns, I chose explicit join tables over "cleaner" options like GenericForeignKey. While it felt redundant at first, this approach provides better database integrity and makes it easier to add type-specific features later without messy schema hacks.


The Problem

I’m building a project that aggregates movies, anime, and manga. With authentication already handled, the next step was a standard feature: letting logged-in users save items to their profile using a "Save" button on detail pages.

The challenge wasn't the UX; it was modeling the relationship between a user and three different content types that all required the same behavior. Django offers three reasonable ways to handle this, and the choice has long-term implications.

What I needed

The project has three models in a media app (Movie, Anime, and Manga) and the standard User model. The feature had to support saving and unsaving items, a dashboard listing a user’s saved items, and a constraint to prevent duplicate saves.

Option 1: Three explicit "saved" tables

This approach uses one join table per content type:

class SavedMovie(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="saved_movies")
    movie = models.ForeignKey("media.Movie", on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["user", "movie"], name="uniq_user_movie"),
        ]

I would then repeat this structure for SavedAnime and SavedManga.

This offers a straightforward schema, database-level integrity, and room for type-specific fields later, such as watched status or episode_progress. Adding a fourth content type in the future is purely additive. The downside is the repetition: three similar models and views, plus the need to merge queries in Python to create a unified feed.

Option 2: Django’s GenericForeignKey

This uses a single table to hold saves for any content type:

class SavedItem(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    content_object = GenericForeignKey("content_type", "object_id")

This provides a single save endpoint and a unified feed automatically. Adding new content types requires no changes to the saving logic.

However, the cost is significant: object_id is just an integer. Because the database doesn't know it points to a specific model, deleting a record outside the ORM can leave dangling references that a standard foreign key would have caught. Additionally, per-type fields become awkward, as a column meant for anime would remain empty for every movie or manga row.

Option 3: ManyToManyField(User) on each content model

class Movie(models.Model):
    title = models.CharField(...)
    saved_by = models.ManyToManyField(User, related_name="saved_movies", blank=True)

Saving is concise: movie.saved_by.add(request.user).

Structurally, this is similar to Option 1 because Django generates hidden "through" tables. The problem is that I don't own those tables. To add a created timestamp for chronological sorting, I would need to define a through model, effectively reverting to Option 1 with more complexity. Furthermore, I didn't want the Movie model to be directly coupled to the User model.

I picked Option 1

The deciding question was: "Which will I regret?"

Option 2 might have tempted me into rigid, one-size-fits-all logic. The moment I needed episode_progress for anime but not for movies, I would have been forced into using nullable columns or side tables. Option 3 would have blocked me from using timestamps without extra steps. Option 1 starts with some duplication but remains easy to understand.

The duplication made me hesitate, but the benefits are clear. Three small models are manageable, even if a fourth is added later. The view layer can still handle things generically: a single save endpoint can accept a content type as a URL parameter and map it to the correct table using a lookup dictionary. This results in explicit storage with a generic API.

A detour: The middle ground

Before deciding, I considered a "disjoint subtype" pattern: one table with three nullable foreign keys and a CheckConstraint ensuring exactly one is set:

class Favorite(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    movie = models.ForeignKey("media.Movie", null=True, ...)
    anime = models.ForeignKey("media.Anime", null=True, ...)
    manga = models.ForeignKey("media.Manga", null=True, ...)
    # ... CheckConstraint enforcing exactly one is set

This provides real foreign keys and a unified feed. It is an excellent pattern for systems where branches have distinct structures, like a Transaction table pointing to either a CreditCardCharge or a BankTransfer.

However, it felt like accidental complexity for this project. Movies, anime, and manga are interchangeable at the save-record level. Using this schema would make every read operation more difficult, as the code would constantly need to check which foreign key is populated.

A change of heart on app structure

I originally placed the saving code in a separate library app to keep dependencies clean. After a few days, I moved everything back into the media app.

The split was correct in theory but premature in practice. It added extra maintenance and import layers for concerns that weren't actually conflicting. The media app already handled User logic, and the saving models were small enough to coexist there.

The lesson: Separation of concerns is worth the effort only when those concerns are actually fighting. Until then, premature splitting is just more files.


Next in the series: actually building the thing — models with UniqueConstraint, URL routing, content-type dispatch, and the Post/Redirect/Get pattern that makes Save and Unsave behave nicely on a double-click.