Plain is headed towards 1.0! Subscribe for development updates →

Removing model abstractions, changing field options

Development update by @davegaeddert • 2025-03-13

Up to this point, I haven't made that many changes to models. But there are some things I've been considering for quite a while and finally decided to tackle.

The biggest changes involve removing abstractions that paper over how the database is actually being implemented. "Magic" is a bit of an overused and undefined term, but I think it applies here.

Plain strives to be more straightforward and obvious — if the code doesn't clearly reflect what is happening in the database, then it may be too abstract. The tradeoff is that we push more code and knowledge onto the developer, but in the long run I think this is a good thing for beginners and experts alike.

I'm sure some people would disagree with these changes, but the nice thing about removing features is that we can always add them back! The only person that has to be convinced is me.

Here are the changes:

Removed abstract models, proxy models, and multi-table inheritance

Django has several ways that you can do model inheritance. I'm not going to go over them in detail here, but odds are if you don't know them, then you probably don't need to! Personally, I don't think they're worth the complexity — I prefer to have my models map pretty directly to the database structure, so I've made some changes in that direction.

Now when you create a model in Plain, it has to inherit directly from models.Model. It can't extend another model subclass. For the vast majority of use cases, nothing changes!

from plain import models


@register_model
class User(models.Model):
    email = models.EmailField()

We do still support the purpose of abstract models, but they are ordinary classes instead of models.Model with Meta.abstract = True. You can think of these more as "mixins", where you simply group some fields that you want to reuse across your app.

from plain import models


class TimestampedModel:
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


@register_model
class User(TimestampedModel, models.Model):
    email = models.EmailField()

So you can still reuse fields and class methods, but there is no automatic reuse of Meta (which was too confusing anyway, in my opinion).

Removed OneToOneField

The OneToOneField was basically just a ForeignKey with unique=True. Again, if we're being more transparent about what is actually happening then I think it is ok to use a ForeignKey with a UniqueConstraint. Otherwise it can be hard to envision how this is working in the database.

I will admit that it was kind of nice to have the reverse accessor set up as a single object instead of a queryset, but again I think it's ok if the framework leans more towards clarity of what is happening in the database. We'll see!

Requiring through on ManyToManyField

The ManyToManyField now requires that you define a through model to go with it. It will no longer create one for you at runtime (which was also the only instance of an "auto created" model, which is a concept we can now remove entirely).

In my experience, you almost always end up wanting control of this model anyway so you can attach additional data to the relationship.

from plain import models


@register_model
class User(models.Model):
    email = models.EmailField()


@register_model
class Group(models.Model):
    name = models.CharField(max_length=100)
    members = models.ManyToManyField(User, through="GroupMembership")


@register_model
class GroupMembership(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    group = models.ForeignKey(Group, on_delete=models.CASCADE)
    date_joined = models.DateTimeField(auto_now_add=True)

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

Removed db_index fields

Up to this point you could define Meta.indexes for the entire model, or you could index a single field with db_index=True. The Django docs say that db_index may be deprecated in the future, but we can skip straight to removing it. Meta.indexes are way more flexible and just forcing you to define indexes at the model level typically leads to better decisions anyway.

The exception to this change is ForeignKey fields. Not every database indexes foreign keys, and some databases allow you to disable the index. Long story short, ForeignKey fields have their own db_index parameter that defaults to True, giving you the option to set it to False. Otherwise there's no way to remove an index from a foreign key.

Removed unique fields

Like db_index, database uniqueness could be enforced per field with unique=True, or at the model level with Meta.constraints. Again, I think doing this at the model level is more flexible and leads to more holistic, better decisions. So unique is gone and you should use Meta.constraints instead!

Changed blank fields to required

I've always found blank=True to be confusing. Since this is really a validation rule, I think it makes more sense to say required=False.

Changed null fields to allow_null

For sake of clarity, null has been changed to allow_null.

Other notable changes

  • Removed Meta.order_with_respect_to
  • Removed SlugField
  • Removed editable fields
  • Field keyword arguments are now required

Next steps

It's time to get serious about documentation! Shifting into documentation will force me through each module and package to see where things stand. I'm sure some changes will come as a result of that process (being forced to explain in writing is a great way to find shortcomings), but the goal is to refocus on the user-facing content so we can push towards a 1.0 release.