30 Jul 2025

feedDjango community aggregator: Community blog posts

Django: split ModelAdmin.get_queryset() by view

Within Django's popular admin site, you can override ModelAdmin.get_queryset() to customize the queryset used by the admin views. It's often used for performance optimizations, such as adding a select_related() call to batch-fetch related objects:

from django.contrib import admin

from example.models import Book


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).select_related("author")

However, one thing this approach lacks is granularity-the queryset returned by get_queryset() is used for all admin views, such as the change list, change form, and any custom views that you might add. That can mean that adding an optimization in get_queryset() for one view can impose a performance cost on other views that don't need it. For example, the above select_related() call might optimize showing author details shown on the change list view, but other pages that don't show the author will still incur the cost of the join.

There isn't an easy way to customize the queryset for individual views without overriding a lot of their code. However, the queryset() method is passed the current request object as context, which allows you to differentiate between views based on request.resolver_match. I think the most robust way to check the current admin view from there is with the __name__ attribute of the func:

from django.contrib import admin

from example.models import Book


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        queryset = super().get_queryset(request)

        if request.resolver_match.func.__name__ == "changelist_view":
            queryset = queryset.select_related("author")

        return queryset

request.resolver_match.func is the current view function, which will be a method of the current ModelAdmin instance, wrapped with the AdminSite.admin_view decorator. Its __name__ attribute gives the name of the view function, which you can use to differentiate between views. For the built-in admin views, it will be one of the following:

(add_view is not included here as it does not call get_queryset().)

Fin

May your admin views run swift and true,

-Adam

30 Jul 2025 4:00am GMT

29 Jul 2025

feedDjango community aggregator: Community blog posts

Our tools are still not designed for the AI future

First a disclaimer on this one: I am making the assumption that the AI trend is here to stay in some form and an economic crash/bubble doesn't make the usage of them untenable, also I have yet experiment with every tool out there!

With that said, a brief personal history of my usage of LLM's and the current wave of AI. I tried out ChatGPT when it was first released and was fairly impressed by the results, but the cruical missing step for me was the lack of browser integration, searching Google was still much quicker from a new tab page and the results from ChatGPT felt isolated, there was too much friction in my workflow for it be usable. I tried out a different product (I forget the name), which allowed me to search from a new tab page and I got AI results and normal search results in one go. This was better, but it still didn't stick, and so I kept experimenting with the tools on an ad-hoc basis, solving small challenges, but it not being a daily driver. In this I experimented with local LLMs and Zed's AI integration.

This changed earlier this year where I experimented with Manus.im and using Claude with Zed's agent mode. Both of these unlocked ideas or wrote decent code directly into my project for me to review, saving me time that I could measure. Since then I have used Zed's agent mode more frequently, that said I do still enjoy coding myself so sometimes forget to use the tools available.

This daily to weekly usage has led me to consider what an AI-first IDE would look like. At this point the newly released Kiro or Cursor comes to mind or others such as Zed or VSCode plus extensions, they are all really designed for a developer-first point of view since their beginning's were from a time before agent workflows existed.

Personally I am looking forward to the IDE that is built ground up to create, run and manage agents that follows the trend of 'spec-driven-development' that has started to form recently within AI circles. Generally I would expect the following:

Is this something of a wishlist? Yes, but I could easily a version of this starting soon, because there a paradigm shift coming I think, where the day to day activity goes from one of writing on our local machines, to one of review on our local machines being the priority. Let me know what you think!

29 Jul 2025 5:00am GMT

28 Jul 2025

feedDjango community aggregator: Community blog posts

User Timezones in Django

When you create a local website, the local time usually matches your country's timezone, and all visitors see times in that timezone. That's not a big issue if your country has only one timezone and your audience is local.

But when building a social platform like pybazaar.com, users are international and need to see times in their timezones. In this article, I'll show you how to handle that in Django.

Time Zone Database

Since version 4.0, Django has used the zoneinfo library for managing timezones, and it used pytz up to version 3.2. Both rely on the IANA Time Zone Database (tzdata). IANA is the same organization that manages the DNS root zone, IP addresses, and other global internet resources.

Install tzdata in your virtual environment as usual:

(venv)$ pip install --upgrade tzdata

Timezone Changes

Timezone information changes several times a year due to:

  1. Daylight Saving Time (DST) adjustments
  2. Political and border changes
  3. Shifts in standard time offset

Daylight Saving Time (DST) was first introduced in 1914 in Canada and later standardized in the U.S. in 1966. When dealing with historic dates before 1966-or future dates with uncertain timezone rules-precise time calculations can be unreliable.

# Before U.S. DST standardization:
old_date = datetime(1960, 6, 15, 12, 0)  

# DST rules may change in the future:
future_date = datetime(2030, 6, 15, 12, 0) 

Some timezone changes are driven by politics:

And countries sometimes adjust their UTC offsets:

Best Practices for Django

Timezone Management for a Social Platform

For platforms with global users:

1. Enable Timezone Support in Django Settings

Set the default timezone to UTC:

# settings.py
USE_TZ = True
TIME_ZONE = "UTC"  # Store everything in UTC

2. Add a timezone Field to the Custom User Model

Use a function for dynamic timezone choices, so you don't need new migrations when the list changes.

def get_timezone_choices():
    import zoneinfo
    return [(tz, tz) for tz in sorted(zoneinfo.available_timezones())]

class User(AbstractUser):
    # ...
    timezone = models.CharField(
        _("Timezone"), max_length=50, choices=get_timezone_choices, default="UTC"
    )

3. Detect Timezone on the Frontend

Add hidden fields in your Login and Signup forms to capture the user's timezone from their browser:

document.addEventListener('DOMContentLoaded', function () {
    const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
    const timezoneInput = document.getElementById('id_timezone');
    if (timezoneInput) {
        timezoneInput.value = userTimezone;
    }
});

You can also let users change their timezone manually in account settings.

4. Use a Custom DateTime Field in Forms

This field will convert datetimes between UTC and the user's local timezone:

import datetime
from zoneinfo import ZoneInfo
from django import forms
from django.utils import timezone
from django.utils.dateparse import parse_datetime

class TimezoneAwareDateTimeField(forms.DateTimeField):
    widget = forms.DateTimeInput(attrs={"type": "datetime-local"})

    def __init__(self, user_timezone=None, *args, **kwargs):
        self.user_timezone = user_timezone
        super().__init__(*args, **kwargs)

    def prepare_value(self, value):
        if value and self.user_timezone:
            try:
                user_tz = ZoneInfo(self.user_timezone)
                if timezone.is_aware(value):
                    value = value.astimezone(user_tz)
            except Exception:
                pass
        return value

    def to_python(self, value):
        if value in self.empty_values:
            return None
        if isinstance(value, datetime.datetime):
            result = value
        elif isinstance(value, datetime.date):
            result = datetime.datetime(value.year, value.month, value.day)
        else:
            try:
                result = parse_datetime(value.strip())
            except ValueError:
                raise forms.ValidationError(
                    self.error_messages["invalid"], code="invalid"
                )
        if not result:
            result = super(forms.DateTimeField).to_python(value)
        if result and self.user_timezone:
            try:
                user_tz = ZoneInfo(self.user_timezone)
                if timezone.is_naive(result):
                    result = result.replace(tzinfo=user_tz)
                result = result.astimezone(ZoneInfo("UTC"))
            except Exception:
                pass
        return result

The type="datetime-local" widget uses the browser's native date/time picker.

Use the custom field like this:

from django import forms
from django.utils.translation import gettext_lazy as _
from myproject.apps.core.form_fields import TimezoneAwareDateTimeField
from .models import Post

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "content", "published_from"]

    def __init__(self, request, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.request = request
        self.fields["published_from"] = TimezoneAwareDateTimeField(
            label=_("Published from"),
            help_text=_("Enter date and time in your local timezone."),
            required=False,
            user_timezone=self.request.user.timezone,
        )

5. Output Dates and Times in User's Timezone

{% load tz %}
{% with user_timezone=request.user.timezone|default:"UTC" %}
    {{ post.published_from|timezone:user_timezone|date:"j M, Y H:i" }}
{% endwith %}

Other Options

You can also detect the visitor's timezone in JavaScript and send it via Ajax to be saved in the Django session. Then you can use it even for anonymous users.

Final Words

Timezones aren't so scary if you follow Django's best practices:

This keeps your website accurate, user-friendly, and ready for global audiences.


Cover photo by Andrey Grushnikov

28 Jul 2025 5:00pm GMT

25 Jul 2025

feedDjango community aggregator: Community blog posts

Django News - DjangoCon US 2025 Talks Announced - Jul 25th 2025

News

Announcing our DjangoCon US 2025 Talks!

The official DjangoCon US 2025 talk lineup has been unveiled, featuring expert sessions on Django deployments, ORM alternatives, search, AI integration, CMS, migrations, performance, and community practices.

djangocon.us

Python 3.14 release candidate 1 is go!

This is the first release candidate of Python 3.14.

blogspot.com

PSF Board Election Nominations Opening July 29th

PSF opens 2025 Board Election nominations from July 29 to August 12 UTC, providing a timeline, resources, and guidance for prospective candidates.

blogspot.com

Updates to Django

Today 'Updates to Django' is presented by Pradhvan from the Djangonaut Space! πŸš€

Last week we had 14 pull requests merged into Django by 8 different contributors including 2 first-time contributors! Congratulations to LauHerregodts and Ishita Jain for having their first commits merged into Django, welcome on board! πŸŽ‰

This week's Django highlights 🌟

Deprecated most positional args to django.core.mail, adding deprecation warnings for non-keyword arguments in email functions, with removal planned for Django 7.0.

Added support for GeometryType database function, bringing new GIS functionality for spatial queries with cross-database compatibility including Oracle.

Updated migration command to record applied/unapplied migration statuses recursively, fixing how Django handles double-squashed migrations to ensure proper status tracking.

That's all for this week in Django development! 🐍 πŸ¦„

Django Newsletter

Sponsored Link 1

AI-Powered Django Development & Consulting

REVSYS specializes in seamlessly integrating powerful AI technologies, including GPT-4, into your existing Django applications. Your Django project deserves modern, intelligent features that enhance user engagement and streamline content workflows.

revsys.com

Articles

Checking Out CPython 3.14's remote debugging protocol

Python 3.14 adds sys.remote_exec and python -m pdb -p pid for nonintrusive remote debugging and script injection into running processes without restarting.

rtpg.co

How to Get Foreign Keys Horribly Wrong

Common Pitfalls and Potential Optimizations in Django.

hakibenita.com

Quirks in Django's template language part 2

Django's center filter inherits Python's str.center odd padding behaviour, causing inconsistent centering for odd padding totals unless overridden with custom filters.

lilyf.org

How I do it

Daniel Stenberg shares his disciplined, independent, user-centric leadership approach and daily routine that sustain his long-term success leading the curl project.

haxx.se

Django: iterate through all registered URL patterns

Recursive generator function traverses Django URLResolvers to list all registered URL patterns and namespaces for auditing views or enforcing inheritance rules.

adamj.eu

EuroPython 2025 in Prague Recap

A recap by Will Vincent sharing talks, Django's 20th anniversary celebration, and sights/sounds in Prague.

wsvincent.com

Philadelphia parking violations: Report obstructions to PPA - WHYY

From illegal sidewalk parking to bike lane obstructions, violations captured by photo trigger an automatic report that gets sent to the PPA.

https://github.com/PhillyBikeAction/apps

whyy.org

Events

Wagtail Space 2025 CFP

Wagtail Space 2025 is seeking 25-minute proposals from developers and professionals to share insights on modern Django, Wagtail, and Python practices at its October conference.

pretalx.com

DjangoCon Videos

Feature Flags: Deploy to some of the people all of the time, and all of the people some of the time! - Graham Knapp

Feature flags activate features for some users whilst hide them for others. They help you deploy code more often, work more collaboratively, get early feedback from colleagues and customers and separate deployment from feature activation.

djangotv.com

The incredible Djangonaut Space project - Paolo Melchiorre

A talk on what the Djangonaut Space program is up close, understand its goal and its innovative strength. Analyzing it from the internal point of view, but also from that of its participants, in the hope of inspiring other people to participate.

djangotv.com

Anatomy of a Database Operation - Karen Jex

What happens behind the scenes when you send a query to your database? How is the data stored, retrieved and modified? Why should you care?

With a basic understanding of the way select, insert and update operations work, you can make sure your code and database play nicely together!

djangotv.com

Podcasts

Test & Code 235: pytest-django with Adam Johnson

Adam Johnson joins the show to share tips and insights on using pytest-django to improve Django testing.

transistor.fm

Django News Jobs

Senior Backend Engineer at Prowler πŸ†•

Full Stack Engineer at LevPro

Backend Engineer at 7Learnings

Senior Backend Python Developer at Gravitas Recruitment

Django Newsletter

Projects

fstrings.wtf - Python F-String Quiz

Test your knowledge of Python's f-string formatting with this interactive quiz. How well do you know Python's string formatting quirks?

fstrings.wtf

Brktrlw/django-admin-collaborator

Real-time collaborative editing for Django admin with WebSockets

github.com

mohitprajapat2001/django-otp-keygen

A Multi Purpose Django package to generate and manage One-Time Passwords (OTP).

github.com

Sponsorship

Lightning-Fast Python: Mastering the uv Package Manager - Livestream on August 7th

Register for an upcoming livestream featuring Michael Kennedy, host of Talk Python to Me and Python Bytes podcasts, on August 7th at 11am Eastern Daylight Time (UTC-4).

jetbrains.com


This RSS feed is published on https://django-news.com/. You can also subscribe via email.

25 Jul 2025 3:00pm GMT

Deploying a Django App to Sevalla

This tutorial looks at how to deploy a Django application to Sevalla.

25 Jul 2025 3:28am GMT

24 Jul 2025

feedDjango community aggregator: Community blog posts

Latest feature of Comfort Monitor Live released!

Yesterday I finally finished and released the latest feature of Comfort Monitor Live, which allows users to design custom layouts for the comfort monitor.

This has been build has been a long slog of over 6 months slowly working on it every Wednesday evening for a couple of hours. AI has helped to an extent in building this feature, but mostly it was still me slowly building and debugging issues as they came up. Being a chrome extension, means a lot of Javascript and I the UI has been NextJS. Both of these have made me realise the beauty and speed Django brings to a project for CRUD operations against a datastore and a structure for quickly creating a UI that can store that data.

This was also one of those features which resulted in a rebuild of the core logic to be better and scalable for future use, especially the next feature which will allow the user to implement some rules around how the comfort monitor runs during an event. It's going to be another big lift when I do build it, but for now, with this shipped it's going to allow me to focus more on some Django contribution work, namely django-prodserver, an open PR for a messages ticket and work on the Online Community WG.

24 Jul 2025 5:00am GMT

22 Jul 2025

feedDjango community aggregator: Community blog posts

Django: iterate through all registered URL patterns

I've found it useful, on occasion, to iterate through all registered URL patterns in a Django project. Sometimes this has been for checking URL layouts or auditing which views are registered.

In this post, we'll look at a pattern for doing that, along with an example use case.

Get all URL patterns with a recursive generator

The below snippet contains a generator function that traverses Django's URLResolver structure, which is the parsed representation of your URLconf. It extracts all URLPattern objects, which represent individual URL patterns from path() or re_path() calls, and returns them along with their containing namespace. It handles nested URL resolvers from include() calls by calling itself recursively.

from typing import Iterator, Optional

from django.urls import URLPattern, URLResolver, get_resolver


def all_url_patterns(
    url_patterns: Optional[list] = None, namespace: str = ""
) -> Iterator[tuple[URLPattern, str]]:
    """
    Yield tuples of (URLPattern, namespace) for all URLPattern objects in the
    given Django URLconf, or the default one if none is provided.
    """
    if url_patterns is None:
        url_patterns = get_resolver().url_patterns

    for pattern in url_patterns:
        if isinstance(pattern, URLPattern):
            yield pattern, namespace
        elif isinstance(pattern, URLResolver):
            if pattern.namespace:
                if namespace:
                    namespace = f"{namespace}:{pattern.namespace}"
                else:
                    namespace = pattern.namespace
            yield from all_url_patterns(pattern.url_patterns, namespace)
        else:
            raise TypeError(f"Unexpected pattern type: {type(pattern)} in {namespace}")

An example: finding all class-based views

The below example uses all_url_patterns() to find all registered view classes. The key here is that View.as_view() returns a function, but it attaches a view_class attribute to that function, which points to the actual class.

from example.utils import all_url_patterns

for pattern, namespace in all_url_patterns():
    if hasattr(pattern.callback, "view_class"):
        view_class = pattern.callback.view_class
        print(view_class)

Example output:

<class 'example.views.AboutView'>
<class 'example.views.CashewView'>
<class 'example.views.HazelnutView'>
<class 'example.views.MacadamiaView'>

Test for a given base view class

One use case for this tool is to ensure that all views in your project inherit from a specific base class. This can be useful, for example, to ensure that your custom access control logic is always used. Below is a test case that checks all registered views and fails if any do not inherit from example.views.BaseView.

from inspect import getsourcefile, getsourcelines

from django.test import TestCase

from example.utils import all_url_patterns
from example.views import BaseView


class BaseViewTests(TestCase):
    def test_all_views_inherit_from_base_view(self) -> None:
        non_compliant_view_classes = []

        for url_pattern, namespace in all_url_patterns():
            try:
                view_class = url_pattern.callback.view_class
            except AttributeError:
                # Function-based view
                continue

            if not issubclass(view_class, BaseView):
                non_compliant_view_classes.append(view_class)

        if non_compliant_view_classes:
            error_msg = "Views not inheriting from BaseView:\n"
            for view_class in non_compliant_view_classes:
                file = getsourcefile(view_class)
                _, lineno = getsourcelines(view_class)
                error_msg += f"  - {view_class.__module__}.{view_class.__name__} (defined in {file}:{lineno})\n"
            self.fail(error_msg)

The test finds all class-based views as before, and places the non-compliant ones into the non_compliant_view_classes list. At the end, it raises an error with a detailed message listing the non-compliant view classes, including their "line number path", per my previous post, which allows quick navigation to the source code.

Running it can produce a failure like:

./manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_all_views_inherit_from_base_view (tests.BaseViewTests.test_all_views_inherit_from_base_view)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../tests.py", line 29, in test_all_views_inherit_from_base_view
    self.fail(error_msg)
    ~~~~~~~~~^^^^^^^^^^^
AssertionError: Views not inheriting from BaseView:
  - example.views.MacadamiaView (defined in /.../example/views.py:22)


----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Destroying test database for alias 'default'...

In a real project, you might want to extend this test to allow-list certain base classes, such as those used by third-party apps.

(The above example might actually be better as a Django system check, now I think about it.)

Analyze other URLconfs

Some projects use multiple URLconf modules, for example, when hosting multiple domains. To use all_url_patterns() with a specific URLconf, call Django's get_resolver() with the desired URLconf module name, then pass the returned resolver's url_patterns attribute to all_url_patterns():

from django.urls import get_resolver

from example.utils import all_url_patterns

url_patterns = get_resolver("example.www_urls").url_patterns
for pattern, namespace in all_url_patterns(url_patterns):
    ...

Fin

URL be alright!

-Adam

22 Jul 2025 4:00am GMT

18 Jul 2025

feedDjango community aggregator: Community blog posts

Django News - πŸŽ‚ Django Turns 20! - Jul 18th 2025

News

πŸŽ‚ Happy 20th birthday Django!

On July 13th 2005, Jacob Kaplan-Moss made the first commit to the public repository that would become Django. Twenty years and 400+ releases later, here we are - Happy 20th birthday Django! πŸŽ‰

djangoproject.com

πŸ“» W2D Django 20th Celebration Continues πŸŽ‚

Nearing one week in, two operators of the W2D special event station honoring Django's 20th birthday have made over 400 contacts with 22 geopolitical entities on 4 continents.

github.com

Prohibiting inbox.ru email domain registrations

A recent spam campaign against PyPI has prompted an administrative action, preventing using the inbox.ru email domain. This includes new registrations as well as adding as additional addresses.

pypi.org

Affirm Your PSF Membership Voting Status

If you are a voting member of the Python Software Foundation and you plan on voting this year, here are the details you need to know for how to vote. If you voted in 2024, you are still eligible to vote in 2025; however, we recommend double-checking your status to ensure accuracy.

blogspot.com

Updates to Django

Today 'Updates to Django' is presented by Pradhvan from the Djangonaut Space! πŸš€

Last week we had 7 pull requests merged into Django by 7 different contributors including a first-time contributor! Congratulations to tinmarbusir for having their first commits merged into Django, welcome on board! πŸŽ‰

This week's Django highlights 🌟

Restored UNNEST strategy for foreign key bulk inserts on PostgreSQL, fixing a performance regression in Django 5.2.1 and restoring significant bulk insert speed improvements.

That's all for this week in Django development!

With Django turning 20 this month, it's the perfect time to revisit Django Chat's episode with co-creator Jacob Kaplan-Moss on Django's evolution. 🐍 πŸ¦„

Django Newsletter

Sponsored Link 1

πŸŽ‚ Donate for Django's Birthday!

Django is turning 20, and needs your help to keep going strong! Join us in supporting Django!

djangoproject.com

Articles

How to Migrate your Python & Django Projects to uv

Notes from recently migrating a legacy Django project to uv.

caktusgroup.com

Why This Python Performance Trick Doesn't Matter Anymore

CPython 3.11's specializing adaptive interpreter has optimized global builtins lookups so local aliasing hardly boosts performance anymore, though module attribute calls still benefit.

codingconfessions.com

Virtual Tables and Django Foreignkeys

Disabling default database constraints with db_constraint=False on ForeignKey and ManyToMany fields ensures Django unmanaged virtual tables operate correctly in SQLite temp schemas.

paultraylor.net

Cut Django Database Latency by 50-70ms with Native Connection Pooling

Native connection pooling in Django 5.1 cuts PostgreSQL connection latency by 50-70ms, simplifies configuration, and boosts response times in minutes.

saurabh-kumar.com

πŸŽ‚ Django turns 20

Django marks its 20th anniversary highlighting two decades of open source contributions, vibrant community growth, and the ongoing DSF fundraising drive to ensure its future.

foxleytalent.com

πŸŽ‚ Django turned 20!

Django celebrates 20 years of powering reliable web applications, exemplified by a 17-year editorial management system continuously maintained since 2008.

someonewho.codes

πŸŽ‚ Happy 20th Birthday, Django!

Celebrating Django's 20th anniversary highlights its open-source beginnings in Lawrence Kansas, community-driven evolution, and enduring influence on modern Python web development.

github.io

πŸŽ‚ Happy Pony

Django's intuitive API, clean code, comprehensive docs and built-in admin interface prove its enduring value for building database backed applications after 20 years.

rgaz.fr

Events

PyLadiesCon is back!

PyLadiesCon 2025 returns on December 5-7 as a free online Python conference, featuring multilingual talks, workshops, panels, and open-source sprints to promote inclusivity.

pyladies.com

DjangoCon Videos

Zango: Accelerating Business App Development with an Opinionated Django Meta - Bhuvnesh Sharma

A talk on Zango: An opinionated Django meta-framework that supercharges business application development. Built for teams who need the flexibility of Django with batteries-included security, compliance, and workflows-without the overhead of building everything from scratch.

djangotv.com

Dynamic models without dynamic models - Jacob Walls

If your users are data modelers themselves, your underlying data model might be extremely generic. What if you still want a model-like interface shaped like those user-defined schemas? This is the story of subclassing QuerySets and REST Framework functionality to produce something a lot like dynamic Django models, only with souped-up annotations supporting creates, updates, and deletes.

djangotv.com

Evolving Django: What We Learned by Integrating MongoDB - Anaiya Raisinghani

Building on the progress of the past, most notably django-mongodb-engine and django-nonrel, MongoDB has taken on the challenge of developing a production-ready Django database backend for MongoDB.

djangotv.com

Sponsored Link 2

Scout Monitoring: Logs, Traces, Error (coming soon). Made for devs who own products, not just tickets.

scoutapm.com

Django News Jobs

Full Stack Engineer at LevPro

Backend Engineer at 7Learnings

Senior Backend Python Developer at Gravitas Recruitment

Senior/Staff Software Engineer at Clerq

Django Newsletter

Projects

husseinnaeemsec/octopusdash

Dynamic Django admin panel.

github.com

loopwerk/drf-action-serializers

An easy way to use different serializers for different actions and request methods in Django REST Framework.

github.com


This RSS feed is published on https://django-news.com/. You can also subscribe via email.

18 Jul 2025 3:00pm GMT

Why Django's DATETIME_FORMAT ignores you

When you start a new Django project, you get a handful of default settings for localization and timezones:

settings.py

USE_I18N = True
LANGUAGE_CODE = "en-us"
USE_TZ = True
TIME_ZONE = "UTC"

I've written before about the default timezone being a silly choice for sites with a global user base, both on the backend and the frontend.

But today, I want to talk about internationalization (I18N) and language settings. For my sites, LANGUAGE_CODE = "en-us" is perfectly fine; all my admin users speak English, and we prefer American spelling over the British variant. But there are some weird things going on in Django that I want to address.

The USE_I18N puzzle

Here's the first weird thing. The default settings have USE_I18N = True, which enables Django's internationalization features. The default LANGUAGES setting also includes a massive list of every language under the sun.

You'd think this means the Django Admin would automatically switch languages. If I set my browser's preferred language to Dutch, shouldn't the Admin follow suit? Nope. It remains stubbornly English.

It turns out you need to add this to your middleware for the translation to actually happen:

settings.py

MIDDLEWARE = [
    # ...
    "django.middleware.locale.LocaleMiddleware",
]

Only after adding LocaleMiddleware will the admin honor your browser's language preference. This feels weird to me. Why enable USE_I18N by default, which has a small performance cost, if it doesn't do anything without manual intervention?

It's also very strange to me that there isn't a language drop-down in the Admin, where users can choose from the available languages (as defined by the LANGUAGES setting). That seems like such an obvious improvement to the Admin, in the same way that there really should be a timezone dropdown as well, to render dates and times in your local timezone.

But since I never add translations for my own code (models and templates), I only ever want my Admin in English anyway, so I just turn the whole translation system off. My settings become:

settings.py

USE_I18N = False
LANGUAGE_CODE = "en-us"
LANGUAGES = [("en-us", "English")]
USE_TZ = True
TIME_ZONE = "UTC"

Formatting settings are ignored

With LANGUAGE_CODE = "en-us", Django formats all dates and times according to US conventions. This means using the 12-hour clock with "a.m." and "p.m.". As a European, this format is just hard to read, especially when you have to mentally parse "12 a.m." and "12 p.m." We want a simple 24-hour clock.

Let's test this with a basic model:

models.py

from django.db import models

class Appointment(models.Model):
    scheduled_at = models.DateTimeField()

And a simple admin:

admin.py

from django.contrib import admin
from .models import Appointment

@admin.register(Appointment)
class AppointmentAdmin(admin.ModelAdmin):
    list_display = ["scheduled_at"]

As expected, the admin form widget and the list display both render the time in the 12-hour format. No problem, I thought. Django has settings for this! I'll just force the 24-hour format everywhere.

settings.py

DATETIME_FORMAT = "N j, Y, H:i"
TIME_FORMAT = "H:i"

And now for the second weird thing: this does absolutely nothing. The times in the admin are still shown with a.m./p.m. A quick trip to the documentation reveals the culprit:

The default formatting to use for displaying datetime fields in any part of the system. Note that the locale-dictated format has higher precedence and will be applied instead.

Wait.. what? So even though I set USE_I18N = False, Django still uses the LANGUAGE_CODE to determine formatting rules, and overrides my custom settings. Setting USE_I18N = False only stops the translation framework; it doesn't stop the localization formatting. The en-us locale's formatting rules are hardcoded to use the 12-hour clock, and they will always win against the DATETIME_FORMAT setting.

So, what is the point of DATETIME_FORMAT and TIME_FORMAT? They seem only to work if you use a locale that doesn't have its own predefined formats? It feels completely backward; a specific custom setting should always override a general locale-based one.

The fix: overriding locale formats

So how do we get our 24-hour clock? If the locale format is the problem, we need to change the locale format.

Django provides a clean, if somewhat hidden, way to do this with the FORMAT_MODULE_PATH setting. This tells Django to look in a specific Python module for custom format definitions.

1. Create a formats package

In your project directory (the one with manage.py), create a new package for our custom formats. I'll call mine formats.

myproject/
β”œβ”€β”€ formats/
β”‚   β”œβ”€β”€ __init__.py
β”‚   └── en/
β”‚       β”œβ”€β”€ __init__.py
β”‚       └── formats.py
└── manage.py

2. Create a custom formats.py

Inside myproject/formats/en/formats.py, we can define our own formats for the en language code. We'll specify the 24-hour clock using %H for the hour.

formats.py

DATETIME_FORMAT = "N j, Y, H:i"
TIME_FORMAT = "H:i"
SHORT_DATETIME_FORMAT = "m/d/Y H:i"

You can add a few other locate-related formats you want to override here, see the documentation for FORMAT_MODULE_PATH for the available settings.

3. Point Django to your custom formats

Finally, in your settings.py, tell Django where to find this new module:

settings.py

LANGUAGE_CODE = 'en-us'

# This tells Django to look in `formats` for locale formats
FORMAT_MODULE_PATH = 'formats'

And voilΓ ! The Django admin now displays all times in the glorious, unambiguous 24-hour format, even while LANGUAGE_CODE is still en-us.

It's definitely more work than you'd expect for such a simple change. I really do think they should change the precedence order, but now you know how to change formatting settings for an existing locale.

18 Jul 2025 1:45pm GMT

16 Jul 2025

feedDjango community aggregator: Community blog posts

Django at 20: a personal journey through 16 years

Django turned 20 a few days ago, which is a remarkable milestone for any software project. I've been along for most of that ride, starting my Django journey in September 2009. That's almost 16 years ago! It's been fascinating to watch both Django and my use of it evolve over time.

From server-rendered pages to APIs and back

When I started with Django in 2009, it was a different world. Everything was server-rendered, with a bit of jQuery sprinkled in. I wrote my very first Django article in November 2009 about dynamically adding fields to models. This was quickly followed by articles on what I didn't like about Python and Django and how to use Jinja2 templates in Django, which solved some of my pain points.

In 2012, my focus shifted dramatically. I went from full-time web developer to full-time iOS developer. Django didn't disappear from my life though, it just changed roles. Instead of building full websites, I was creating REST APIs to power mobile apps, which is when I discovered Django REST Framework.

Fast forward to 2023, and I've come full circle, returning to full-time web development. These days, I mostly use SvelteKit on the frontend with Django REST Framework providing the API. But my latest project is pure Django again (without Jinja2 even), using Alpine AJAX for interactivity. It feels like returning to the old days of server-rendered apps, except without the full page refreshes and jQuery spaghetti. There's something very refreshing about the simplicity.

The deployment evolution

My deployment story has changed as much as my use of Django. I started with the push-to-deploy magic of Heroku. Eventually, my desire for more control led me to self-hosting on a bare metal server, where I configured everything myself with systemd scripts and Nginx. I documented this journey in Setting up a Debian 11 server for SvelteKit and Django. It gave me complete control but came with significant operational overhead.

More recently, I've found a happy medium with Coolify, a self-hosted PaaS that gives me a Heroku-like experience on my own hardware, as I detailed in my article on hosting Django with Coolify. It provides the git-based, zero-downtime deployments I want without the manual configuration overhead.

My favorite dependencies

No framework is an island, and Django's rich ecosystem of third-party packages is a huge part of its power. Over the years, I've developed a set of favorite dependencies that I return to again and again.

Core Django extensions

Django REST Framework ecosystem

Frontend and styling

Infrastructure

Django's enduring strengths

There's a reason I've stuck with Django for so long. While other frameworks have come and gone, Django's core strengths have only become more apparent.

First and foremost are the "big three": the ORM, the migrations system, and the Admin. When I started in 2009, migrations didn't even exist, but today, they are arguably Django's killer feature. The ORM is a joy to use, and the built-in Admin is an unparalleled tool for getting a project off the ground and managing data. As I've written before, these three features are the main reason why I still choose Django over frameworks like Flask or FastAPI.

Beyond the code, the community is one of Django's greatest assets. It's mature, stable, and welcoming. You can find an answer to almost any problem, and there are countless high-quality packages to extend the framework. This maturity also leads to stability; you don't have to worry about crazy breaking changes every six months, which is a breath of fresh air compared to the churn in other ecosystems.

Things I'd wish to see differently

Despite my affection for it, Django isn't perfect. I'd love to see the Django Admin get a modern overhaul. It's incredibly functional, but its interface feels dated. I also believe it's time for a capable REST framework to be included in the core. So many Django projects today are APIs that it feels like a natural evolution. Finally, seeing the ORM lean more heavily on standard Python type hints and Pydantic-style models, much like FastAPI does, would be a fantastic modernization.

My Django contributions

Over the years, I've created several Django packages to scratch my own itches:

I've also written quite a few articles on Django, and have been made a Django Software Foundation member.

Looking forward

Django at 20 is in a great place. It's mature without being stagnant, stable without being boring. The framework has evolved thoughtfully over the years, adding features like async support while maintaining backward compatibility.

For new projects, I still reach for Django. Not Flask, not FastAPI - Django. The "batteries included" philosophy means I can focus on building features instead of gluing libraries together. The boring, stable foundation lets me be creative where it matters.

Here's to another 20 years of Django. May it continue to be the reliable, productive framework that lets us turn ideas into working applications with minimum fuss. Happy birthday, Django!

16 Jul 2025 9:23pm GMT

15 Jul 2025

feedDjango community aggregator: Community blog posts

How to Get Foreign Keys Horribly Wrong


Constraints keep the integrity of your system and prevent you from shooting yourself in the foot. Foreign keys are a special type of constraint because, unlike unique, check, and primary keys, they span more than one relation. This makes foreign keys harder to enforce and harder to get right.

In this article, I demonstrate common pitfalls, potential optimizations, and implicit behavior related to foreign keys.

Table of Contents

Watch

πŸ“Ί This article is inspired by a talk I gave at DjangoCon EU. Watch it here.

Naive Implementation

Imagine a simple application to manage a product catalog:

Generated using dbdiagram.io
Generated using dbdiagram.io

The first table, or model, in the catalog is the Category model:

class Category(models.Model):
    id: int = models.BigAutoField(primary_key=True)
    name: str = models.CharField(max_length=50)

Categories can be "household items", "fruit", "apparel", and so on.

Next, a model to store products:

class Product(models.Model):
    class Meta:
        unique_together = (
            ('category', 'category_sort_order'),
        )

    id = models.BigAutoField(primary_key=True)
    name = models.CharField(max_length=50)
    description = models.TextField()

    category = models.ForeignKey(to=Category, on_delete=models.PROTECT, related_name='products')
    category_sort_order = models.IntegerField()

    created_by = models.ForeignKey(to=User, on_delete=models.PROTECT, related_name='+')
    last_edited_by = models.ForeignKey(to=User, on_delete=models.PROTECT, related_name='+', null=True)

Products have a name and description, and they are associated with a category by foreign key.

To make sure we present products in the right order in the UI, we add a sort order within the category. To make sure the order is deterministic, we add a unique constraint to prevent products in the same category from having the same sort order.

Finally, we added two columns to keep track of the user who created the product and the user who last edited the product. This is mostly for auditing purposes.

Since we are on the subject of foreign keys, here are the 3 foreign keys we have in this very simple product table:

This naive implementation, as the name suggests, is very naive! There is a lot more than meets the eye. In the next sections, we'll make adjustments to improve this model.


Enhanced Implementation

It's been said that developers spend more time reading code than writing code. As someone who has spent a fair amount of time giving code reviews and going through code to understand how it works, I believe this to be true. We started with a naive implementation and now we'll review the code and make it better.

Replacing unique_together

To control the order we present products in the UI we added a category_sort_order to each product. To make sure two products in the same category don't have the same value, we added a unique constraint on the combination of category and category_sort_order:

class Product(models.Model):
    class Meta:
        unique_together = (
            ('category', 'category_sort_order'),
        )

The Django documentation on unique_together includes a special note we should consider:

Use UniqueConstraint with the constraints option instead. UniqueConstraint provides more functionality than unique_together. unique_together may be deprecated in the future.

Unique together is deprecated and discouraged, so let's replace it with a unique constraint:

@@ -13,9 +13,15 @@ class Category(models.Model):

 class Product(models.Model):
     class Meta:
-        unique_together = (
-            ('category', 'category_sort_order', ),
-        )
+        constraints = [
+            models.UniqueConstraint(
+                name='product_category_sort_order_uk',
+                fields=(
+                    'category',
+                    'category_sort_order',
+                ),
+            ),
+        ]

Unique constraint is easier to modify, allows to use advanced B-Tree index features (we'll use some later) and is recommended by the documentation, so let's always use that!

πŸ’‘ Takeaway

Don't use unique_together, use UniqueConstraint instead.

Identifying Duplicate Indexes

Now that we got rid of unique_together, let's have a look at the schema:

catalog=# \d catalog_product
       Column        β”‚         Type
─────────────────────┼───────────────────────
 id                  β”‚ bigint
 name                β”‚ character varying(50)
 category_sort_order β”‚ integer
 category_id         β”‚ bigint
 created_by_id       β”‚ integer
 last_edited_by_id   β”‚ integer
Indexes:
    "catalog_product_pkey" PRIMARY KEY, btree (id)
    "catalog_product_category_id_35bf920b" btree (category_id)
    "catalog_product_category_id_category_sort_order_b8206596_uniq" UNIQUE CONSTRAINT, btree (category_id, category_sort_order)
    "catalog_product_created_by_id_4e458b98" btree (created_by_id)
    "catalog_product_last_edited_by_id_05484fb6" btree (last_edited_by_id)
    -- ...

It's easy to get lost in all the information here, but notice that we have two indexes that are prefixed by category.

The first index is the unique constraint we just created:

class Product(models.Model):
    class Meta:
        constraints = [
            models.UniqueConstraint(
                name='product_category_sort_order_uk',
                fields=('category', 'category_sort_order'),
            ),
        ]

The second index is not that obvious:

class Product(models.Model):
    # ...
    category = models.ForeignKey(
        to=Category,
        on_delete=models.PROTECT,
        related_name='products',
    )

Where is the index, you ask? The answer lies in the official documentation of the ForeignKey field:

A database index is automatically created on the ForeignKey.

When you define a foreign key, Django implicitly creates an index behind the scenes. In most cases, this is a good idea, but in this case, this field is already (sufficiently) indexed. Reading further in the documentation:

A database index is automatically created on the ForeignKey. You can disable this by setting db_index to False

If we believe that we don't need the index, we can instruct Django not to create it by setting db_index to False:

@@ -35,6 +35,8 @@ class Product(models.Model):
     category = models.ForeignKey(
         to=Category,
         on_delete=models.PROTECT,
         related_name='products',
+        # Indexed in unique constraint.
+        db_index=False,
     )

Queries using category can use the unique index instead.

⚠️ Implicit Behavior

ForeignKey field implicitly creates an index unless explicitly setting db_index=False.

To actually remove the index, we first need to generate a migration:

$ ./manage.py makemigrations
Migrations for 'catalog':
  demo/catalog/migrations/0003_alter_product_category.py
    ~ Alter field category on product

But before we move on to apply this migration, there is another small thing we need to take care of.

Identifying Blocking Migrations

Let's have a look at the migration we just generated to remove the index from the foreign key:

class Migration(migrations.Migration):
    dependencies = [
        ('catalog', '0002_alter_product_unique_together_and_more'),
    ]

    operations = [
        migrations.AlterField(
            model_name='product',
            name='category',
            field=models.ForeignKey(
                db_index=False,
                on_delete=django.db.models.deletion.PROTECT,
                related_name='products',
                to='catalog.category',
            ),
        ),
    ]

The migration looks harmless. Let's dig deeper and review the actual SQL generated by the migration:

$ ./manage.py sqlmigrate catalog 0003
BEGIN;
--
-- Alter field category on product
--
SET CONSTRAINTS "catalog_product_category_id_35bf920b_fk_catalog_category_id" IMMEDIATE;
ALTER TABLE "catalog_product" DROP CONSTRAINT "catalog_product_category_id_35bf920b_fk_catalog_category_id";
ALTER TABLE "catalog_product" ADD CONSTRAINT "catalog_product_category_id_35bf920b_fk_catalog_category_id"
    FOREIGN KEY ("category_id") REFERENCES "catalog_category" ("id") DEFERRABLE INITIALLY DEFERRED;
COMMIT;

For the inexperienced eye, this might look OK, but if you really pay attention you'll notice that something very dangerous is going on here.

πŸ’‘ Takeaway

Always check the SQL generated by migrations.

When we set db_index=False, what we wanted to do is to drop the index but keep the constraint. Unfortunately, Django is unable to detect this nuanced change, so instead, Django is re-creating the entire foreign key constraint without the index!

Re-creating the constraint works in two steps:

1. Dropping the existing constraint:

ALTER TABLE "catalog_product" DROP CONSTRAINT "catalog_product_category_id_35bf920b_fk_catalog_category_id";

The database will drop the index on the field and also stop validating the constraint. Dropping the index requires a lock on the table while the index is dropped. This can block some operations on the table.

2. Creating the constraint:

ALTER TABLE "catalog_product" ADD CONSTRAINT "catalog_product_category_id_35bf920b_fk_catalog_category_id"
    FOREIGN KEY ("category_id") REFERENCES "catalog_category" ("id") DEFERRABLE INITIALLY DEFERRED;

The database first obtains a lock on the table. Then, to make sure the constraint is valid, it needs to make sure all the values in the column have a matching field in the referenced table. In this case, it checks that the product's category exists in the category table. Depending on the size of the tables, this can take some time and interfere with ongoing operations against the table.

⚠️ Implicit Behavior

When making changes to ForeignKey Django may implicitly re-create the constraint. This can require an extended locks and block certain operations on the table.

All of this is really not necessary though. We don't have anything against the constraint, what we really want is to keep the constraint as-is and only drop the index.

Safely Migrating Foreign Key

There are some types of changes to ForeignKey fields Django is (currently?) unable to detect. As a result, Django may end up re-creating the constraint in the database. This can lead to extended locking and may impact live systems. To avoid that, we need to change the way Django applies the migration. To understand how we can do that, we need to understand how Django generates migrations in the first place:

"Django migrations"
"Django migrations"

Generate migrations using makemigrations:

  1. Render a state from the existing migrations
  2. Compare to the desired state of the models in models.py
  3. Generate migration operations from the difference

Applying migrations using migrate:

  1. Generate SQL from the migration operations (sqlmigrate)
  2. Apply to SQL to the database

Let's break down the case of changing a foreign key:

Django identified the change to the field, but it's unable to generate a migration operation to just drop the index.

If the migration and the SQL generated by Django is not exactly what we want, there is a special operation called SeparateDatabaseAndState we can use:

A highly specialized operation that lets you mix and match the database (schema-changing) and state (autodetector-powering) aspects of operations.

Using SeparateDatabaseAndState we can provide one set of operations to execute against Django's internal state, and another set of operations to execute against the database:

@@ -11,9 +11,18 @@ class Migration(migrations.Migration):
     ]

     operations = [
-        migrations.AlterField(
-            model_name='product',
-            name='category',
-            field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='catalog.category'),
-        ),
+        migrations.operations.SeparateDatabaseAndState(
+            state_operations=[
+                migrations.AlterField(
+                    model_name='product',
+                    name='category',
+                    field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='catalog.category'),
+                ),
+            ],
+            database_operations=[
+                migrations.RunSQL(
+                    'DROP INDEX catalog_product_category_id_35bf920b',
+                ),
+            ],
+        )
     ]

The SeparateDatabaseAndState migration operation accepts two arguments:

To demonstrate, this is what will be executed when we apply this migration:

$ ./manage.py sqlmigrate catalog 0003
BEGIN;
--
-- Custom state/database change combination
--
DROP INDEX catalog_product_category_id_35bf920b;
COMMIT;

Exactly what we want!

Applying the migration:

$ ./manage.py migrate
Operations to perform:
  Apply all migrations: catalog
Running migrations:
  Applying catalog.0003_alter_product_category... OK

So are we done...?

Reversible Migration Operations

Say you applied the migration to drop the index and it went OK. A few minutes go by and then you realize you made a horrible mistake. You rush back to your laptop to reverse the migration:

$ ./manage.py migrate catalog 0002
Rendering model states... DONE
Unapplying catalog.0003_alter_product_category...
django.db.migrations.exceptions.IrreversibleError:
Operation <RunSQL ''> in catalog.0003_alter_product_category is not reversible

Oh no! You now have some explaining to do...

The reason you can't un-apply this migration is that the RunSQL command to drop the index did not include an opposite operation. This is an easy fix:

index 962c756..7ad45e0 100644
--- a/demo/catalog/migrations/0003_alter_product_category.py
+++ b/demo/catalog/migrations/0003_alter_product_category.py
@@ -22,6 +22,7 @@ class Migration(migrations.Migration):
             database_operations=[
                 migrations.RunSQL(
                     'DROP INDEX catalog_product_category_id_35bf920b',
+                    'CREATE INDEX "catalog_product_category_id_35bf920b" ON "catalog_product" ("category_id")',
                 ),
             ],
         )

The second argument to RunSQL is the reverse operation - what to execute to undo the migration. In this case, to reverse dropping an index is to create an index!

But, where do you get this SQL from? Usually from the migration that added it. In this case, the initial migration:

$ ./manage.py sqlmigrate catalog 0001
...
BEGIN;
...
--
-- Create model Product
--
...
CREATE INDEX "catalog_product_category_id_35bf920b" ON "catalog_product" ("category_id");
...

Now if you found out you made a horrible mistake and you want to reverse the migration:

$ ./manage.py migrate catalog 0002
Rendering model states... DONE
Unapplying catalog.0003_alter_product_category... OK

Great!

πŸ’‘ Takeaway

Provide reverse operations whenever possible - you don't know when you're going to need it.

Concurrent Index Operations

You now made sure to only drop the index without recreating the constraint and you provided a reverse operation in case you made a mistake and want to "undo". That's all great, but just one more thing... from PostgreSQL documentation on DROP INDEX:

A normal DROP INDEX acquires an ACCESS EXCLUSIVE lock on the table, blocking other accesses until the index drop can be completed.

To drop the index, PostgreSQL acquires a lock on the table which blocks other operations. If the index is tiny, that's probably fine, but what if it's a very big index? Dropping a big index can take some time and we can't lock a live table for very long.

PostgreSQL provides an option to create an index without acquiring restrictive locks on the index:

@@ -5,6 +5,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
+    atomic = False

     dependencies = [
         ('catalog', '0002_alter_product_unique_together_and_more'),
@@ -21,7 +22,7 @@ class Migration(migrations.Migration):
             ],
             database_operations=[
                 migrations.RunSQL(
-                    'DROP INDEX catalog_product_category_id_35bf920b',
+                    'DROP INDEX CONCURRENTLY catalog_product_category_id_35bf920b',
-                    'CREATE INDEX "catalog_product_category_id_35bf920b" ON "catalog_product" ("category_id")',
+                    'CREATE INDEX CONCURRENTLY "catalog_product_category_id_35bf920b" ON "catalog_product" ("category_id")',
                 ),
             ],

Dropping an index concurrently works in two phases. First the index is marked as "deleted" in the dictionary table. During this time, ongoing transactions can still use it. Next, the index is actually dropped. This way of dropping indexes requires minimal locking but can take a bit more time.

πŸ’‘ Takeaway

Use concurrent index operations in busy systems. Concurrent operations can take a bit more time but they don't block operations to the table while they execute.

Databases such as PostgreSQL that support transactional DDL, can execute CREATE, DROP and ALTER commands inside a database transaction. This is a very useful feature because it allows you to execute schema changes atomically (it also allows you to do some wild things like making indexes "invisible"). Unfortunately, concurrent operations cannot be executed inside a database transaction. This means we need to set the entire migration to be non-atomic by setting atomic=False.

In an atomic migration, if something fails in the middle, the entire transaction is rolled-back and it's like the migration never ran. In a non-atomic migration however, if something fails in the middle, you can end up with an incomplete execution and an inconsistent state. To reduce the risk of getting stuck with a half-applied migration, if you have operations such as drop/create index concurrently in the migration, it's best to split the migration and move these operations to a separate migration.

πŸ’‘ Takeaway

To avoid incomplete migrations, move operations that can't be executed atomically to a separate migration.

This is the final migration:

class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('catalog', '0002_alter_product_unique_together_and_more'),
    ]

    operations = [
        migrations.operations.SeparateDatabaseAndState(
            state_operations=[
                migrations.AlterField(
                    model_name='product',
                    name='category',
                    field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='catalog.category'),
                ),
            ],
            database_operations=[
                migrations.RunSQL(
                    'DROP INDEX CONCURRENTLY catalog_product_category_id_35bf920b',
                    'CREATE INDEX CONCURRENTLY "catalog_product_category_id_35bf920b" ON "catalog_product" ("category_id")',
                ),
            ],
        )
    ]

In this migration we prevented Django from re-creating the entire foreign key and instead only drop the index. This migration also uses concurrent index operations so it's safe to execute on a live system, and if you make a mistake it is also reversible.

Indexes on Foreign Keys

So far we handled the foreign key on category, so let's move on to the next foreign key in the model:

class Product(models.Model):
    #...
    created_by = models.ForeignKey(
        to=User,
        on_delete=models.PROTECT,
        related_name='+',
    )

Django will implicitly add an index on a ForeignKey field unless explicitly stated otherwise. Since we didn't define db_index=False on this field, Django created an index on created_by. But, do we really need it?

To answer this question, we need first to ask how this field is being used:

From these two use-cases, it seems like no one is actually going to query the table by created_by, so this index is most likely unnecessary. However, there is another use for this index, which is not as obvious.

To demonstrate, let's create a user:

>>> haki = User.objects.create_user(
...    username='haki',
...    first_name='haki',
...    last_name='benita',
... )
<User: haki>

Now turn on SQL logging on and delete the user we just created a second ago:

>>> haki.delete()
(0.438) SELECT * FROM "catalog_product" WHERE "catalog_product"."created_by_id" IN (102); args=(102,)
(0.002) SELECT * FROM "catalog_product" WHERE "catalog_product"."last_edited_by_id" IN (102); args=(102,)
(0.000) BEGIN;
(0.002) DELETE FROM "django_admin_log" WHERE "django_admin_log"."user_id" IN (102); args=(102,)
(0.001) DELETE FROM "auth_user_groups" WHERE "auth_user_groups"."user_id" IN (102); args=(102,)
(0.001) DELETE FROM "auth_user_user_permissions" WHERE "auth_user_user_permissions"."user_id" IN (102); args=(102,)
(0.001) DELETE FROM "auth_user" WHERE "auth_user"."id" IN (102); args=(102,)
(0.368) COMMIT;
(1, {'auth.User': 1})

A lot of things are happening here! Let's break it down:

  1. Django checks if there are products that were created by or last edited by this user.
  2. Django deletes any admin logs, group memberships and permissions associated with this user.
  3. Django actually deletes the user from the user tables.
  4. Django commits the transaction and then the database does all of these checks too!

This brings us to the next, less obvious way, indexes on foreign keys are being used - to validate the foreign key constraint. If the foreign key is defined with on_delete=PROTECT, the index is used to make sure there are no related objects referencing a specific object. In our case, products that reference the user we are about to delete. If the foreign key is defined with on_delete=CASCADE, the index is used to delete the related objects. In our case, deleting the user may also delete products referencing the user.

You may have noticed that Django delete() function returns a counter-like structure that keeps how many objects were deleted for each type of model. This is why despite the fact the database also does all of these checks, Django is also doing them. The indexes on the foreign key are working extra-hard here.

πŸ’‘ Takeaway

Indexes on foreign keys are used indirectly when deleting related objects. Removing these indexes may cause unexpected and hard to debug performance issues with deletes.

Now that we know this index is in-fact necessary, we explicitly set db_index on the field and add an appropriate comment so the next developer understands why we decided to keep it:

@@ -44,7 +44,8 @@
 class Product(models.Model):
     created_by = models.ForeignKey(
         to=User,
         on_delete=models.PROTECT,
         related_name='+',
+        # Used to speed up user deletion.
+        db_index=True,
     )

When someone else (or you in a couple of months) encounters this comment, they won't have to go through the entire process again.

πŸ’‘ Takeaway

Always explicitly set db_index on ForeignKey and add a comment on how it's being used.

Partial Foreign Key Indexes

So far we covered two of three foreign keys - category and created_by. Here is the last one:

class Product(models.Model):
    [...]
    last_edited_by = models.ForeignKey(
        to=User,
        on_delete=models.PROTECT,
        related_name='+',
        null=True,
    )

Just like the index on created_by, this index is used mostly for audit purposes. Nobody wants to query for products last edited by some user. However, we've been down this road before and we know this index is used when users are deleted, so we'll keep it. But, before we call it a day, there is something we can still do with this index.

To demonstrate we first need to add some data.

Create 100 users:

from django.contrib.auth.models import User

users = [
    User.objects.create_user(
        username=f'user{i}',
        email=f'user{i}@email.com',
        first_name=f'User {i}',
    ) for i in range(100)
]

Create 50 categories:

from catalog.models import Category

categories = [
    Category.objects.create(
        name=f'Category {i}',
    ) for i in range(50)
]

Create 1,000,000 products:

import random
from django.utils import lorem_ipsum
from catalog.models import Product

random.seed(8080)

Product.objects.bulk_create((
    Product(
        name=f'Product {i}',
        description=lorem_ipsum.words(100),
        category=random.choice(categories),
        category_sort_order=i,
        created_by=random.choice(users),
        last_edited_by=random.choice(users) if i % 1000 == 0 else None,
    ) for i in range(1_000_000)),
    batch_size=100_000,
)

Notice that only one in every 1,000 products has been edited.

Now that we have some data, let's have a look at the indexes:

catalog=# \di+ *product*
 Schema β”‚                  Name                        β”‚  Size
────────┼──────────────────────────────────────────────┼─────────
 public β”‚ catalog_product_created_by_id_4e458b98       β”‚ 6440 kB
 public β”‚ catalog_product_last_edited_by_id_05484fb6   β”‚ 6320 kB
 public β”‚ catalog_product_pkey                         β”‚ 21 MB
 public β”‚ product_category_sort_order_uk               β”‚ 32 MB

There is one strange thing going on. If you don't spot this right away that's fine, most people don't.

Consider this query to check how many users we have in created_by and last_edited_by:

catalog=# SELECT
    COUNT(created_by_id) AS created_by,
    COUNT(last_edited_by_id) AS last_edited_by
FROM catalog_product;

 created_by β”‚ last_edited_by
────────────┼────────────────
    1000000 β”‚           1000

Out of 1M rows, all products have a value in created_by, but only 1,000 rows have a value for last_edited_by - that's ~99.9% empty values! If that's the case, how come indexes on both these fields are the same size:

catalog=# \di+ *product*
 Schema β”‚                  Name                        β”‚  Size
────────┼──────────────────────────────────────────────┼─────────
 public β”‚ catalog_product_created_by_id_4e458b98       β”‚ 6440 kB
 public β”‚ catalog_product_last_edited_by_id_05484fb6   β”‚ 6320 kB
 public β”‚ catalog_product_pkey                         β”‚ 21 MB
 public β”‚ product_category_sort_order_uk               β”‚ 32 MB

Both indexes are ~6MB, but one has 1M values and the other only 1K values. The reason both indexes are the same size is that in PostgreSQL, null values are indexed!

πŸ’‘ Takeaway

Null values are indexed (In all major databases except Oracle).

I started my career as an Oracle DBA, where null values are not indexed. It took me some time (and a lot of expensive storage) until I realized that in PostgreSQL null values are indexed.

We have a foreign key column which is mostly used to validate the constraint and it is 99.9% empty. What if we could only index the rows which are not null? From PostgreSQL documentation on "partial index":

A partial index is an index built over a subset of a table; the subset is defined by a conditional expression [...]. The index contains entries only for those table rows that satisfy the predicate.

Exactly what we need! Let's replace the index on the ForeignKey with a partial B-Tree index:

@@ -22,6 +22,13 @@ class Product(models.Model):
    class Meta:
+        indexes = (
+            models.Index(
+                name='product_last_edited_by_part_ix',
+                fields=('last_edited_by',),
+                condition=models.Q(last_edited_by__isnull=False),
+            ),
+        )
@@ -53,5 +60,7 @@ class Product(models.Model):
     last_edited_by: User | None = models.ForeignKey(
         on_delete=models.PROTECT,
         related_name='+',
         null=True,
+        # Indexed in Meta.
+        db_index=False,
     )

We start by setting db_index=False to instruct Django we don't want the default index. We also make sure to add a comment saying that the field is indexed in Meta.

Next, we add a new index in Meta. What makes this index partial is the condition we added in the index definition:

models.Index(
    name='product_last_edited_by_part_ix',
    fields=('last_edited_by',),
    condition=models.Q(last_edited_by__isnull=False),
)

This will make the index include only values which are not null. If we generate and apply the migration, these are the sizes of the indexes:

catalog=# \di+ *product*
 Schema β”‚                  Name                  β”‚  Size
────────┼────────────────────────────────────────┼─────────
 public β”‚ catalog_product_created_by_id_4e458b98 β”‚ 6440 kB
 public β”‚ product_last_edited_by_part_ix         β”‚ 32 kB
 public β”‚ catalog_product_pkey                   β”‚ 21 MB
 public β”‚ product_category_sort_order_uk         β”‚ 32 MB

The full index is ~6.4MB, the partial index only 32 kB. That's ~99.5% smaller.

πŸ’‘ Takeaway

Use partial indexes when you don't need to index all the values in a column. Nullable foreign key columns are usually great candidates. If you are not convinced check out the story about The Unexpected Find That Freed 20GB of Unused Index Space.

Using Built-in Concurrent Index Operations

In the previous section we managed to save some $$$ by switching to a partial index. But with all the excitement we forgot something very very important - always check the generated SQL before applying migrations!

This is the migration:

class Migration(migrations.Migration):
    dependencies = [
        ('catalog', '0005_alter_product_created_by'),
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
    ]

    operations = [
        migrations.AlterField(
            model_name='product',
            name='last_edited_by',
            field=models.ForeignKey(
                db_index=False,
                null=True,
                on_delete=django.db.models.deletion.PROTECT,
                related_name='+',
                to=settings.AUTH_USER_MODEL
            ),
        ),
        migrations.AddIndex(
            model_name='product',
            index=models.Index(
                condition=models.Q(('last_edited_by__isnull', False)),
                fields=['last_edited_by'],
                name='product_last_edited_by_part_ix',
            ),
        ),
    ]

Looks about right. The SQL:

$ ./manage.py sqlmigrate catalog 0006
BEGIN;
--
-- Alter field last_edited_by on product
--
SET CONSTRAINTS "catalog_product_last_edited_by_id_05484fb6_fk_auth_user_id" IMMEDIATE;
ALTER TABLE "catalog_product" DROP CONSTRAINT "catalog_product_last_edited_by_id_05484fb6_fk_auth_user_id";
ALTER TABLE "catalog_product" ADD CONSTRAINT "catalog_product_last_edited_by_id_05484fb6_fk_auth_user_id"
    FOREIGN KEY ("last_edited_by_id") REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED;
--
-- Create index product_last_edited_by_part_ix on field(s) last_edited_by of model product
--
DROP INDEX IF EXISTS "product_last_edited_by_part_ix";
CREATE INDEX "product_last_edited_by_part_ix" ON "catalog_product" ("last_edited_by_id")
    WHERE "last_edited_by_id" IS NOT NULL;
COMMIT;

Oh no! It's recreating the foreign key from scratch again. We already know what to do - use SeparateDatabaseAndState to only drop the index:

@@ -13,10 +13,20 @@ class Migration(migrations.Migration):
operations = [
-     migrations.AlterField(
-         model_name='product',
-         name='last_edited_by',
-         field=models.ForeignKey(db_index=False, null=True, on_delete=django.db.models.deletion.PROTECT, # ...
+     migrations.operations.SeparateDatabaseAndState(
+         state_operations=[
+             migrations.AlterField(
+                 model_name='product',
+                 name='last_edited_by',
+                 field=models.ForeignKey(db_index=False, null=True, on_delete=django.db.models.deletion.PROTECT, # ...
+             ),
+         ],
+         database_operations=[
+             migrations.RunSQL(
+                 'DROP INDEX CONCURRENTLY catalog_product_last_edited_by_id_05484fb6;',
+                 'CREATE INDEX CONCURRENTLY catalog_product_last_edited_by_id_05484fb6 ON public.catalog_product USING btree (last_edited_by_id)',
+             ),
+         ],

The migration will now drop the index instead of re-creating the constraint.

Next, we want to create the partial index concurrently to not interrupt any live system. So far we used RunSQL for concurrent operations, but this time, we can use one of the built-in Django concurrent operations instead:

@@ -3,9 +3,11 @@
+from django.contrib.postgres.operations import AddIndexConcurrently

 class Migration(migrations.Migration):
+    atomic = False

     dependencies = [
         ('catalog', '0005_alter_product_created_by'),
@@ -28,7 +30,7 @@ class Migration(migrations.Migration):
                 ),
             ],
         ),
-        migrations.AddIndex(
+        AddIndexConcurrently(
             model_name='product',
             index=models.Index(
                condition=models.Q(('last_edited_by__isnull', False)),
                fields=['last_edited_by'],
                name='product_last_edited_by_part_ix',
            ),
         ),

Django offers two special drop-in concurrent migration index operations for PostgreSQL:

These operations are only available for PostgreSQL. For other databases you still need to use RunSQL.

The reason we couldn't use these operations for the implicit index on ForeignKey is that these operations can only be used for indexes defined in Meta.indexes, and not for implicitly created indexes or indexes created outside Django's context.

Order Migration Operations

So far we prevented the migration from re-creating the foreign key and made sure all index operations are concurrent. This is the current migration:

class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('catalog', '0005_alter_product_created_by'),
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
    ]

    operations = [
        migrations.operations.SeparateDatabaseAndState(
            state_operations=[
                migrations.AlterField(model_name='product', name='last_edited_by', field=models.ForeignKey(db_index=False, null=True, on_delete=django.db.models.deletion. #...
            ],
            database_operations=[
                migrations.RunSQL(
                    'DROP INDEX catalog_product_last_edited_by_id_05484fb6;',
                    'CREATE INDEX catalog_product_last_edited_by_id_05484fb6 ON public.catalog_product USING btree (last_edited_by_id)',
                ),
            ],
        ),
        AddIndexConcurrently(
            model_name='product',
            index=models.Index(condition=models.Q(('last_edited_by__isnull', False)), fields=['last_edited_by'], # ...
        ),
    ]

If you pay close attention, this migration can still impact a live system. Consider the current order of migration operations:

  1. Drop full index
  2. Create partial index

Between the first and second steps, the system is left with no index. Keep in mind that we used concurrent operations, so we had to make the migration non-atomic. This means that all changes take effect immediately, and not at the end of the migration. This order of operations can have two consequences:

  1. From the time the full index is dropped until the partial index is created, the system doesn't have an index. This can cause queries using the index to become slower.

  2. If the migration fails between the first and second steps, the system will be left without an index until the migration is attempted again.

This solution to this headache is to simply switch the order of operations:

@@ -15,6 +15,10 @@ class Migration(migrations.Migration):
     operations = [
+        AddIndexConcurrently(
+            model_name='product',
+            index=models.Index(condition=models.Q(('last_edited_by__isnull', False)), fields=['last_edited_by'], #...
+        ),
         migrations.operations.SeparateDatabaseAndState(
             state_operations=[
                 migrations.AlterField(
@@ -30,8 +34,4 @@ class Migration(migrations.Migration):
                 ),
             ],
         ),
-        AddIndexConcurrently(
-            model_name='product',
-            index=models.Index(condition=models.Q(('last_edited_by__isnull', False)), fields=['last_edited_by'], #...
-        ),
     ]

First create the partial index and then drop the full index. This way, the system is never left without an index, and if the migration fails at any point, we still have at least one index.

πŸ’‘ Takeaway

Adjust the order of migration operations to reduce the impact on a running application during the migration. It is usually better to create first, and drop after.

This wraps up the migrations part, on to actual business logic!

Locking Across Relations

Say we want to add a the ability to edit a product with the following requirements:

A naive implementation can look like this:

class Product(models.Model):
    # ...
    def edit(self, *, name: str, description: str, edited_by: User) -> None:
        if self.created_by.is_superuser and not edited_by.is_superuser:
            # Only superusers can edit products created by other superusers.
            raise errors.NotAllowed()

        self.name = name
        self.description = description
        self.last_edited_by = edited_by
        self.save()

The edit function is implemented as an instance method on the Product model. It accepts the name and description to update, and the editing user. It performs the necessary permission check and moves on to set the required fields and save.

In some cases this is fine, and you can stop here. However, blindly operating on an instance like this is not always ideal:

If you are not convinced that concurrency is a real concern, check out How to Get or Create in PostgreSQL and Handling Concurrency Without Locks.

A solution more resilient to concurrent edits using a pessimistic approach can look like this:

from django.db import transaction

@classmethod
def edit(cls, id: int, *, name: str, description: str, edited_by: User) -> Self:
    with transaction.atomic():
        product = (
            cls.objects
            .select_for_update()
            .get(id=id)
        )

        if product.created_by.is_superuser and not edited_by.is_superuser:
            # Only superusers can edit products created by other superusers.
            raise errors.NotAllowed()

        product.name = name
        product.description = description
        product.last_edited_by = edited_by
        product.save()

    return product

These are the main differences:

All this locking business can be pretty distracting. So distracting in-fact, that you can end up missing the most obvious optimization in Django, the optimization that appears in every "top 10 Django optimizations" list. I'm taking of course, about the all mighty select_related!

To implement the permission check we access the user that created the product:

if product.created_by.is_superuser and not edited_by.is_superuser:
    # Only superusers can edit products created by other superusers.
    raise errors.NotAllowed()

The user is not fetched in advance, so when we access it, Django will go the database and fetch it. Since we know in advance that we are going to access the user, we can tell Django to fetch it along with the product using select_related:

product = (
    cls.objects
    .select_for_update()
    .select_related('created_by')
    .get(id=id)
)

This will reduce the number of queries by one. However, there is a little gotcha here that can easily go unnoticed.

Consider the following scenario with two session operating at the same time:

>>> # Session 1
>>> with transaction.atomic():
...     product = (
...          Product.objects
...         .select_for_update()
...         .select_related('created_by')
...         .get(id=1)
...     )
...
...
...     # transaction is still ongoing...
>>> # Session 2
>>>
>>>
>>>
>>>
>>>
>>> u = User.objects.get(id=97)
>>> u.is_active = False
>>> u.save()
# Blocked!

In this scenario, session 1 is in the process of editing a product. At the same time, session 2 is attempting to update the user and gets blocked. Why did session 2, which is not updating a product, gets blocked?

The reason session 2 got blocked is that SELECT ... FOR UPDATE locks rows from all the tables referenced by the query! In this case, when we added select_related we added a join to the query, to fetch both the product and the user who created it. As a result, the rows of both the product and the user who created it are locked! If someone happens to try to update a user while we update a product they created, they can get blocked.

⚠️ Implicit Behavior

By default, select_for_update locks the rows from all the referenced tables.

To avoid locking all the rows you can explicitly state which tables to lock using select_for_update:

@@ -80,6 +80,7 @@ class Product(models.Model):
    product: Self = (
        cls.objects
        .select_related('created_by')
+       .select_for_update(of=('self', ))
        .get(id=id)
    )

self is a special keyword that evaluates to the queryset's model, in this case, product.

πŸ’‘ Takeaway

Always explicitly state which tables to lock when using select_for_update. Even if you don't have select_related right now, you might have in the future.

Permissive No Key Locks

Previously we used select_for_update(of=('self', )) to avoid locking the user when we only want to lock and update the product. Now let's look at another scenario. This time, we want to insert a new product:

>>> Product.objects.create(
...     name='my product',
...     description='a lovely product',
...     category_id=1,
...     category_sort_order=999997,
...     created_by_id=1,
...     last_edited_by_id=None,
... )
<Product 1000001>

This works.

Now let's do it again, but this time, while another session is trying to update user 1:

>>> # Session 1
>>> with transaction.atomic():
...   user = (
...     User.objects
...     .select_for_update(of=('self',))
...     .get(id=1)
...   )
...   # transaction is still ongoing...
...
...
...
...
...
...
...
...
>>> # Session 2
>>>
>>>
>>>
>>>
>>>
>>>
>>> Product.objects.create(
...   name='my product',
...   description='a lovely product',
...   category_id=1,
...   category_sort_order=999998,
...   created_by_id=1,
...   last_edited_by_id=None,
... )
... # Blocked!

Session 1 locks user 1 for update. At the same time, Session 2 attempts to insert a new product which is created by the same user and it gets blocked! Why did it get blocked?

Imagine you are the database. You have one session locking the user and another trying to reference it. How can you be sure the first session is not going to change the primary key for this user? How can you be sure it's not going to delete this user? If Session 1 is going to do any of these things, Session 2 should fail. This is why the database has to lock Session 2 until Session 1 completes.

The two scenarios the database is concerned about are valid, but very rare in most cases. How often do you actually update the primary key of an object or a unique constraint that may be referenced by a foreign key? probably never. In PostgreSQL, there is a way to communicate that to the database, and obtain a more permissive lock when selecting for update, using FOR NO KEY UPDATE:

>>> # Session 1
>>> with transaction.atomic():
...   user = (
...     User.objects
...     .select_for_update(no_key=True, of=('self',))
...     .get(id=1)
...   )
...   # transaction is still ongoing...
...
...
...
...
...
...
...
>>> # Session 2
>>>
>>>
>>>
>>>
>>>
>>>
>>> Product.objects.create(
...   name='my product',
...   description='a lovely product',
...   category_id=1,
...   category_sort_order=999998,
...   created_by_id=1,
...   last_edited_by_id=None,
... )
... <Product 1000002>

By setting no_key=True, we tell the database we are not going to update the primary key of the user. The database can then acquire a more permissive lock, and the second session can now safely create the product.

πŸ’‘ Takeaway

Use select_for_update(no_key=True) to select a row for update when not updating primary keys or unique constraints that are referenced by foreign keys. This will require a more permissive lock and prevent unnecessary locks when operating on referencing objects.

Going back to our Product model, to edit a product we used select_for_update to prevent concurrent updates from updating the product at the same time. By locking the row, we also accidentally prevent other referencing models from creating objects while we have the lock. In the case of model like Product, this can have a huge impact on the system.

Imagine you run an e-commerce website using this catalog. When a user makes a purchase you create an Order instance that references the product. Now imagine that every time someone updates a product, your system can't create orders. This is unacceptable!

To allow the system to create orders while a product is being updated, add no_key=True:

--- a/demo/catalog/models.py
+++ b/demo/catalog/models.py
@@ -80,7 +80,7 @@ class Product(models.Model):
             product: Self = (
                 cls.objects
                 .select_related('created_by')
-                .select_for_update(of=('self', ))
+                .select_for_update(of=('self', ), no_key=True)
                 .get(id=id)
             )

Now we can safely edit a product without interfering with a live system.

The Final Model

It's been quite a ride from the naive model we started with, but here is the final model:

class Product(models.Model):
    class Meta:
        constraints = [
            models.UniqueConstraint(
                name='product_category_sort_order_uk',
                fields=(
                    'category',
                    'category_sort_order',
                ),
            ),
        ]
        indexes = (
            models.Index(
                name='product_last_edited_by_part_ix',
                fields=('last_edited_by',),
                condition=models.Q(last_edited_by__isnull=False),
            ),
        )

    id = models.BigAutoField(
        primary_key=True,
    )
    name = models.CharField(
        max_length=50,
    )
    description = models.TextField()

    category = models.ForeignKey(
        to=Category,
        on_delete=models.PROTECT,
        related_name='products',
        # Indexed in unique constraint.
        db_index=False,
    )
    category_sort_order = models.IntegerField()

    created_by = models.ForeignKey(
        to=User,
        on_delete=models.PROTECT,
        related_name='+',
        # Used to speed up user deletion.
        db_index=True,
    )

    last_edited_by = models.ForeignKey(
        to=User,
        on_delete=models.PROTECT,
        related_name='+',
        null=True,
        # Indexed in Meta.
        db_index=False,
    )

    @classmethod
    def edit(
        cls,
        id: int,
        *,
        name: str,
        description: str,
        edited_by: User,
    ) -> Self:
        with db_transaction.atomic():
            product: Self = (
                cls.objects
                .select_related('created_by')
                .select_for_update(of=('self', ), no_key=True)
                .get(id=id)
            )

            if product.created_by.is_superuser and not edited_by.is_superuser:
                # Only superusers can edit products created by other superusers.
                raise errors.NotAllowed()

            product.name = name
            product.description = description
            product.last_edited_by = edited_by
            product.save()

        return product

This model and the accompanying migrations are safe, resilient, and most importantly, production ready!


Takeaways

Here is a recap of the takeaways from this article:

The Mandatory AI Angle

Since it's 2025 you can't really have a serious article about technology that doesn't mention AI. So here is a funny story about AI and this exact article.

When I first presented this article in a talk at DjangoCon EU, I showed the final slide with all the takeaways and made a joke along the lines of "show me the LLM that can do that". Most people seemed to appreciate this little joke.

After the talk, a friend came to me and said "come man, let me show you something". He pulled his laptop and opened Cursor. He took my takeaways from the slide and put them in a rules file. He then had Cursor refactor one of his models.py files with my takeaways as guidelines... I can only say I had mixed feelings about the result.

15 Jul 2025 2:00am GMT

14 Jul 2025

feedDjango community aggregator: Community blog posts

Happy Birthday to Django!

Over the weekend Django celebrated being 20 years since the first public commit. This is an incredible achievement for a community led project. Django is behind some tiny projects to those that scale globally and personally my career wouldn't be where is it without Django.

Most of my career existed with only a vague awareness of the community, but since getting involved on Discord and beyond has been great for my soul and enjoy those that share the passion of seeing Django succeed.

So as we celebrate 20 year's would be to get involved if you use Django, go to an event, donate to the DSF or join us in the community (online and in person). If you work for a company that use's Django lobby them to donate as well! I am excited for the next decade of slow but sure progress and the community being healthier than ever before.

14 Jul 2025 5:00am GMT

11 Jul 2025

feedDjango community aggregator: Community blog posts

Django News - Django's Ecosystem - Jul 11th 2025

News

Django's Ecosystem

The official Django website has added a new page called "Django's Ecosystem" with a list of resources and 3rd party packages.

djangoproject.com

Python Release Python 3.14.0b4

It's the final 3.14 beta! Beta release previews are intended to give the wider community the opportunity to test new features and bug fixes and to prepare their projects to support the new feature release.

python.org

Updates to Django

Today 'Updates to Django' is presented by Pradhvan from the Djangonaut Space!πŸš€

Last week we had 5 pull requests merged into Django by 5 different contributors - including 2 first-time contributors! Congratulations to Roelzkie and matthews-noriker for having their first commits merged into Django - welcome on board! πŸŽ‰

This week's Django highlights 🌟

Improved staticfiles manifest reproducibility, fixing nondeterministic file ordering for consistent deployments.

Enhanced composite primary key infrastructure, fixing __in tuple lookups on database backends lacking native tuple comparison support.

That's all for this week in Django development! 🐍 πŸ¦„

Django Newsletter

Wagtail CMS

10 Underrated Django Packages

10 underrated Django packages according to the annual Django Developers Survey.

wagtail.org

Sponsored Link 1

Scout Monitoring: Logs, Traces, Error (coming soon). Made for devs who own products, not just tickets.

scoutapm.com

Articles

Speed Up Django Queries with values() over only()

Django developers can dramatically improve ORM query performance on large datasets by replacing only with values to reduce model instantiation overhead and memory usage.

johnnymetz.com

Rate Limiting for Django Websites

Implement Nginx rate limiting with zones and bursts to protect Django endpoints from abusive traffic and ensure consistent performance under load.

djangotricks.com

Loopwerk: Handling static and media files in your Django app running on Coolify

Configure Django applications on Coolify to handle static and media files using WhiteNoise for static files and a combination of Coolify's Persistent Storage, Caddy web server, and Supervisor for media files.

loopwerk.io

Django-Tailwind Just Got Better with a Unified Dev Command and daisyUI

Django-Tailwind v4.2.0 adds a unified tailwind dev command powered by Honcho, optional daisyUI integration, and streamlined Tailwind plugin installation support.

timonweb.com

Third-party packages in Django's documentation

Nginx rate limiting is implemented in Django deployments using zone, burst, and nodelay configurations to mitigate bot traffic and server overload.

better-simple.com

What Really Happened to Django CMS? A Platform That Could Have Rivaled WordPress

An in-depth look at Django CMS, its history and early promise, lost momentum to WordPress due to a developer-heavy learning curve, and optimism for the future around the recent Django CMS 5.0 release in May that positions it for a comeback in the headless and enterprise CMS market.

linkedin.com

Events

DjangoCon Africa Tickets

DjangoCon Africa 2025 ticket portal offers early bird registration, detailed event information, and support contacts for Django developers to connect and engage.

djangocon.africa

International Travel to DjangoCon US 2025

Are you attending DjangoCon US 2025 in Chicago, Illinois, but you are not from US and need some travel information? Here are some things to consider when planning your trip, including visa tips.

djangocon.us

Panel Discussion: Two Decades of Django: The Past, Present and Future

A panel at DjangoCon US 2025 examines Django's evolution, discussing technical challenges, community organization and governance strategies for sustaining its future growth.

djangocon.us

Accessibility and Inclusivity Survey for DjangoCon US

The DjangoCon US organizers are looking for feedback about how DjangoCon US is doing and what could be improved with regard to accessibility and inclusivity. The responses are anonymous.

google.com

Tutorials

Building a Multi-tenant App with Django

This tutorial explains how to implement a multi-tenant web app in Django using the django-tenants and django-tenant-users packages.

testdriven.io

Videos

uv: Making Python Local Workflows FAST and BORING in 2025

πŸ“Ί Must watch. Optimize production Python workflows with uv to streamline dependency management, environment setup, and project automation techniques adaptable for Django development.

youtube.com

DjangoCon Videos

One more time about Β΅Django - Maxim Danilov

A standard Django project involves working with multiple files and folders from the start. Let's see how the work with a Django project when we have only one file in project. This solution automatically transforms Django into a microservice-oriented async framework with "batteries included" philosophy.

djangotv.com

Supporting Adult Career Switchers: The Unbootcamp Method - Mykalin

Learning new skills as an adult can be tricky. Boot camps and courses can be helpful, but many still struggle to land a job. This talk will go over ways to support adults looking for a new career with Python and the results of an unconventional group class setup I've been experimenting with.

djangotv.com

How to get Foreign Keys horribly wrong in Django - Haki Benita

This talk presents some lesser known gotchas and implicit behaviors of Foreign Keys in Django. We'll talk about what you need to pay attention to when defining FKs, how to change FKs without bringing your system to a halt and how to optimize for space, performance and heavy load.

djangotv.com

Django News Jobs

Full Stack Engineer at LevPro πŸ†•

Backend Engineer at 7Learnings πŸ†•

Senior Backend Python Developer at Gravitas Recruitment

Senior/Staff Software Engineer at Clerq

Full Stack Software Engineer at Switchboard

Senior Software Engineer at Simons Foundation

Django Newsletter

Projects

adamchainz/inline-snapshot-django

Extensions for using inline-snapshot to test Django projects.

github.com

kdpisda/django-rls

Row Level Security for Django.

github.com


This RSS feed is published on https://django-news.com/. You can also subscribe via email.

11 Jul 2025 3:00pm GMT

10 Jul 2025

feedDjango community aggregator: Community blog posts

What if Django was written in a new language..

So this is a bit of a follow on from Day 277 and taking the premise of the idea presented with it and pushing it further. What if we, as a community decided to (re-)write Django in another language?

It's not as wild as you might think, Lily Foote is currently tackling an implementation of the Django template language in Rust and someone else suggested that URL resolving might benefit from a similar treatment. However that is not the goal of this push forward, but it is again Django as design pattern or set of API's. If we wanted to allow someone to migrate Django (or even part of it) to a new language, some comphrensive API documentation outside the codebase and inside the codebase would be a good start.

And as I write this I realise that we do have this, it's the amazing test suite that helps to make Django stable (that's all 17898 tests and counting), but even then a test suite is never the whole story.

Today was more of a pondering thought and not a complete one at that, but more of a thought experiment and a consideration (to myself more than anyone) of what Django is and what can be going forward.

10 Jul 2025 5:00am GMT

Python Leiden meetup: Handling crash reports with Bugsink - Klaas van Schelven

(One of my summaries of the fourth Python meetup in Leiden, NL).

Bugsink is a tool/website for collecting error messages from (for instance) your websites. You get an email or other notification and you can visit the error page. You'll see the exact error and a traceback and the environment variables and the library versions.

But... isn't that just like sentry? Yes. Mostly.

But... why bugsink? It is self-hostable. Originally, 20 years ago, sentry was also self-hostable, but it isn't anymore.

There's a bit of workflow, too. You can mark issues as "fixed in the next release". Bugsink will then ignore new occurrences of the error until it notices you made a release.

It is not just in the same market as sentry, it is even sentry-compatible. If you already have sentry set up, moving to bugsink only means changing the SENTRY_DSN setting. The API is the same.

Self-hosting? You can run a docker. He showed that pip install bugsink also works. It is just a plain regular django site with migrate and runserver. A single instance can easily handle 1.5 million errors per day.

Some generic tricks (whether sentry or bugsink):

  • Just use your error tracker in development, too! The nice thing is that it preserves history. When you notice an unrelated error while working on some feature, you can continue working on your feature and go back to the error later.

    Using a local bugsink instance for this can help prevent filling up your production

  • Do a pip install dsnrun. (See github). Run a failing python script with dsnrun and any traceback will be posted to your error tracker.

While building bugsink, he made a couple of surprising architectural decisions:

  • Some tasks need to be run outside the main loop. With django, you often use celery. But that means installing and running a second program, which was a bit at odds with his aim of making bugsink easily self-hostable.

    So... he wrote his own library for that: snappea.

  • Sqlite is the default database. Though often said to be only for small use cases, others say it can handle much bigger tasks. He uses sqlite and it can handle 1.5 million issues per day just fine.

10 Jul 2025 4:00am GMT

Python Leiden meetup: Deploying python apps with django-simple-deploy - Michiel Beijen

(One of my summaries of the fourth Python meetup in Leiden, NL).

Michiel discovered django simple deploy via the django podcast.

Deploying? Previously, heroku was often used as an example in books, but they ditched their free tier and are pretty expensive now. So most examples nowadays just show you how to run django locally. Deploying is harder.

There are several hosting parties that provide relatively easy django hosting like heroku. https://fly.io, https://platform.sh and https://heroku.com . "Easy" is relative, as he ran into some problems with platform.sh and he didn't have an ssh connection to fix it. And yesterday they nuked his account so he couldn't re-try it for today's demo.

Since recently there is also a generic "vps" plugin: just running on some random virtual server with ssh access that you can rent virtually anywhere. https://github.com/django-simple-deploy/dsd-vps . "Random" should be debian-like, btw.

He demoed it on a digitalocean VM (="droplet"). With some environment variables he got it working within 15 minutes and some manual fixes. The longest wait was the "apt-get upgrade" call.

The VPS version of simple deploy has its drawbacks. It needs a root password, for instance, and at the moment it doesn't accept ssh key authentication. He previously tried it on a transip.nl host, which doesn't have a root user with a password: you get a regular user with sudo privileges. The VPS plugin doesn't like that.

A second "manage.py deploy" (after a trivial template update) also did not work. One of the items it generated the first time was (of course) already created, so it failed the second time. Oh...

Anyway, the VPS version of django-simple-deploy doesn't seem to work yet.

10 Jul 2025 4:00am GMT