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!