New goodies in Django 5.0

A girl feeding a cupcake to the Django pony in the clouds. The cupcake is topped with a cherry with light cream and sprinkles.
Image by Annie Ruygt

Mariusz Felisiak, a Django and Python contributor and a Django Fellow, shares with us his personal favorites from a “deluge” of exciting new features in Django 5.0. Django on Fly.io is pretty sweet! Check it out: you can be up and running on Fly.io in just minutes.

As planned, after 8 months of intensive development, the first alpha and beta versions of Django 5.0 are out! Almost 700 commits were merged to this release. 204 people 💗 and even more unnamed heroes, dedicated their time and efforts to make the first in the 5.X series the best Django ever 🎉 Let’s explore a “deluge” of amazing features added in this version.

Generated fields

As always, ORM features are firmly on the new features map. Let’s start with the generated fields which are truly game-changing. For many years Django developers struggled with the following questions:

  • Where to keep reusable properties based on model objects?
  • How to make them available in all Django components?

Django 5.0 brings the answer to these needs, the new GeneratedField! It allows creation of database-generated fields, with values that are always computed by the database itself from other fields. Database expressions and functions can also be used to make any necessary modifications on the fly. Let’s find out how it works in practice.

Suppose we have fields that are often used concatenated together like first_name and last_name. Implementing a model’s QuerySet or Manager was the best option to share annotations, such as full_name, in Django versions < 5.0. We discussed this in the article about organizing database queries. As a short reminder, it’s possible to change the default manager (objects) and extend available fields by annotations:

from django.db import models
from django.db.models.functions import Concat


class Order(models.Model):
    person = models.ForeignKey("Person", models.CASCADE)
    ...


class PersonQuerySet(models.QuerySet):
    def with_extra_fields(self):
        return self.annotate(
            full_name=Concat(
                "first_name", models.Value(" "), "last_name",
            ),
        )


class Person(models.Model):
    first_name = models.CharField(max_length=255)
    last_name = models.CharField(max_length=255)

    objects = PersonQuerySet.as_manager()

    def __str__(self):
        return f"{self.first_name} {self.last_name}"

Adding annotations in custom QuerySets allows sharing common calculations for model objects:

python3 manage.py shell
>>> from order.models import Order, Person
>>> Person.objects.with_extra_fields().filter(full_name="Joe Doe")
<PersonQuerySet [<Person: Joe Doe>]>
>>> mark = Person.objects.with_extra_fields().get(full_name="Mark Doe")
>>> mark.full_name
'Mark Doe'
# It's not available on relationship :(
>>> Order.objects.filter(person__full_name="Catherine Smith")
    ...
    raise FieldError(
django.core.exceptions.FieldError: Unsupported lookup 'full_name'
for ForeignKey or join on the field not permitted.

However, QuerySet annotations have limitations. First, the full_name is only available to objects returned from the .with_extra_fields() method, so we must remember to use it. It’s also not available for the self instance inside the model methods because it’s not a Person attribute. For example, it cannot be used in Person.__str__(). Furthermore, the values are calculated every time, which can be a problem for complex computations.

GeneratedField makes providing values based on other fields really seamless. Its value is automatically set each time the model is changed by using the given database expression. Moreover, the db_persist parameter allows deciding if it should be stored or not:

  • db_persist=True: means that the values are stored and occupy storage like any normal column. On the other hand, they are only calculated when the values change, so we avoid additional calculations when fetching the data. On some databases (MySQL, Postgres, SQLite) they can even be indexed!
  • db_persist=False: means the column doesn’t occupy storage, but its values are calculated every time.

Database support for using different expressions and options may vary. For example, Postgres doesn’t support virtual column, so the db_persist must be set to True. SQLite and MySQL support both virtual and stored generated fields.

Coming back to our “Full name” example. In Django 5.0, we can get rid of all disadvantages of previous approach and define a GeneratedField with the expression used in the full_name annotation:

from django.db import models
from django.db.models.functions import Concat


class Order(models.Model):
    person = models.ForeignKey("Person", models.CASCADE)
    ...

class Person(models.Model):
    first_name = models.CharField(max_length=255)
    last_name = models.CharField(max_length=255)
    # ↓ Our new GeneratedField based on an expression ↓
    full_name = models.GeneratedField(
        expression=Concat(
            "first_name", models.Value(" "), "last_name"
        ),
        output_field=models.CharField(max_length=511),
        db_persist=True,
    )

    def __str__(self):
        # There is no need to re-implement the same logic anymore!
        return self.full_name

Now full_name is a proper Person attribute and can be used in all Django components as any other field:

python3 manage.py shell
>>> from order.models import Order, Person
>>> Person.objects.filter(full_name="Joe Doe")
<PersonQuerySet [<Person: Joe Doe>]>
>>> mark = Person.objects.get(full_name="Mark Doe")
>>> mark.full_name
'Mark Doe'
# It can be used on relationships.
>>> Order.objects.filter(person__full_name="Catherine Smith")
<QuerySet [<Order: Order object (1)>, <Order: Order object (4)>]>

Let’s move on to another amazing new ORM feature that was first requested over 18 (yes, eighteen!) years ago.

Database-computed default values

Database-computed default values were the last significant blocker in fully expressing the structure of Django models and relationship in the database. In Django versions < 5.0, Field.default was the only option for setting the default value for a field, however it’s calculated on Python-side and passed as a parameter when adding a new row. As a consequence, it’s not visible from the database structure perspective and it’s not set when adding rows directly in the database. This also creates a risk of data inconsistency if we take database and network latency into account, for example, when we want to default to the current point in time. Moreover, it’s not visible for people who only have direct access to the database like database administrators or data scientists.

Django 5.0 supports database-computed default values via the new Field.db_default option. This is a panacea for all the concerns related to the previous approach as it allows us to define and compute default values in the database. It accepts literal values and database functions. Let’s inspect a practical example:

from decimal import Decimal

from django.db import models
from django.db.models.functions import Now


class Order(models.Model):
    person = models.ForeignKey("Person", models.CASCADE)
    created = models.DateTimeField(db_default=Now())
    priority = models.IntegerField(db_default=0)
    ...

Form field group rendering

The third great feature that I’d like to highlight is the concept of form field group rendering introduced in Django 5.0, which made form field rendering simpler and more reusable.

Form fields are often rendered with many of their related attributes such as help texts, labels, errors, and widgets. Every project has its own HTML structure and preferred way of rendering form fields. Let’s assume that we use <div> based field templates (like Django) in order to help users with assistive technologies (e.g. screen readers). Our preferred way to render a field could look like this:

<div class="form-field">
  {{ field.label_tag }}
  {% if field.help_text %}
    <p class="help" id="{{ field.auto_id }}_helptext">
      {{ field.help_text|safe }}
    </p>
  {% endif %}
  {{ field.errors }}
  {{ field }}
</div>

It’s exactly the same as Django’s default template for the new as_field_group() method, so now we can simplify it to the:

<div class="form-field">{{ field.as_field_group }}</div>

By default, as_field_group() uses the "django/forms/field.html" template that can be customized on a per-project, per-field, or per-request basis. This gives us a lot of flexibility. Check out the Django documentation for all useful attributes that we can use on custom templates.

Closing thoughts

Django 5.0 with tons of new features is a great start to the 5.X series. Besides the three magnificent enhancements described in this article, Django 5.0 contains outgoing improvements in accessibility and asynchronous areas and many more that can be found in release notes.

What are your personal favorites? Try Django 5.0 and share!