Overview
Django 4.0, released on December 7, 2021, opens a new major cycle for the framework. This version modernizes timezone management by adopting zoneinfo from the standard library, introduces a built-in Redis cache backend, and strengthens database constraints with expressions in conditions.
Django 4.0 also drops support for Python 3.6 and 3.7, now requiring Python 3.8 at minimum. The scrypt password hasher makes its debut as a more hardware-attack-resistant alternative.
Key Features
Redis cache backend
Django now includes a native Redis cache backend via django.core.cache.backends.redis.RedisCache. Previously, a third-party package such as django-redis was required. The new backend relies on redis-py and supports URL-based configuration, sentinel mode, and clusters.
# settings.py — Built-in Redis cache configuration
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379',
},
'sessions': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'TIMEOUT': 86400, # 24 hours
},
}
# Using the cache in a view
from django.core.cache import cache
def product_catalog(request):
products = cache.get('active_products')
if products is None:
products = list(
Product.objects.filter(active=True)
.select_related('category')
.values('name', 'price', 'category__name')
)
cache.set('active_products', products, timeout=300)
return render(request, 'catalog.html', {'products': products})
# Targeted invalidation on update
def update_product(request, product_id):
product = Product.objects.get(pk=product_id)
product.price = request.POST['new_price']
product.save()
cache.delete('active_products')
return redirect('catalog')
zoneinfo by default
Django now uses the zoneinfo module from the Python standard library (introduced in Python 3.9) instead of pytz. The USE_DEPRECATED_PYTZ setting allows a gradual migration. The zoneinfo object interface is more intuitive: there is no longer a need to call localize() or normalize().
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
# Creating a timezone-aware datetime
paris = ZoneInfo('Europe/Paris')
meeting = datetime(2022, 3, 15, 14, 30, tzinfo=paris)
print(meeting) # 2022-03-15 14:30:00+01:00
# Converting to another timezone
new_york = ZoneInfo('America/New_York')
meeting_ny = meeting.astimezone(new_york)
print(meeting_ny) # 2022-03-15 09:30:00-04:00
# No more localize() needed as with pytz
from django.utils import timezone
now = timezone.now() # uses zoneinfo internally
print(now.tzinfo) # zoneinfo.ZoneInfo(key='UTC')
# settings.py — gradual migration
# USE_DEPRECATED_PYTZ = True # to keep pytz temporarily
TIME_ZONE = 'Europe/Paris'
USE_TZ = True
scrypt password hasher
The new ScryptPasswordHasher uses the scrypt algorithm, designed to be memory-hard and thus resistant to GPU and ASIC attacks. It joins existing hashers like PBKDF2, bcrypt, and Argon2.
# settings.py — Add scrypt at the top of the list
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.ScryptPasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]
# Manual verification
from django.contrib.auth.hashers import make_password, check_password
hashed = make_password('my_secret_password')
print(hashed[:20]) # scrypt$16384$8$1$...
# The hasher is selected automatically
assert check_password('my_secret_password', hashed)
# Old PBKDF2 hashes remain valid
# and are automatically re-hashed to scrypt on login
Expressions in constraints
The CheckConstraint and UniqueConstraint classes now accept full expressions, not just Q objects. This allows creating constraints that compute values using database functions, partial indexes, and more sophisticated conditions.
from django.db import models
from django.db.models import Q, F, UniqueConstraint, CheckConstraint
from django.db.models.functions import Lower
class Event(models.Model):
title = models.CharField(max_length=200)
start_date = models.DateTimeField()
end_date = models.DateTimeField()
venue = models.CharField(max_length=100)
capacity = models.PositiveIntegerField()
registered = models.PositiveIntegerField(default=0)
cancelled = models.BooleanField(default=False)
class Meta:
constraints = [
# End date must be after start date
CheckConstraint(
check=Q(end_date__gt=F('start_date')),
name='event_end_after_start',
),
# Registered count cannot exceed capacity
CheckConstraint(
check=Q(registered__lte=F('capacity')),
name='event_registered_max',
),
# Case-insensitive unique title
UniqueConstraint(
Lower('title'),
name='event_title_unique_ci',
),
# Only one active event per venue and time slot
UniqueConstraint(
fields=['venue', 'start_date'],
condition=Q(cancelled=False),
name='event_unique_active_venue',
),
]
