Django: save() skips full_clean() — the validation lifecycle

Django: save() skips full_clean() — the validation lifecycle

Calling obj.save() after defining validators and a clean() method on the model gives the impression that validation is guaranteed. It is not. Django does not call full_clean() during a save(), and this behavior is intentional. Understanding why changes how you architect validation in a project. What save() actually does The lifecycle of a save() call is shorter than you might expect: pre_save signal sent field.pre_save() called on each field (auto_now, auto_now_add, etc.) INSERT or UPDATE SQL based on whether a pk exists post_save signal sent No validation appears anywhere in this sequence. No blank check, no max_length, no call to clean(). The same applies to Model.objects.create() and Model.objects.bulk_create(): all three methods persist without validating. (bulk_create() is covered in depth in Django in_bulk() and bulk_create() if you work with bulk inserts.) ...

May 18, 2026 · 5 min · Anthony
Materialized views vs Django cache for slow queries

Materialized views vs Django cache for slow queries

The instinctive response to a slow reporting endpoint is often cache. A @cache_page, a cache.set(), and the problem seems to vanish until the next expiration. This approach has a structural limitation that PostgreSQL materialized views solve at the root. The problem with cache on analytics endpoints Django cache stores the result of a Python view. The expensive SQL query still runs on every cache expiration. For a report built on multiple JOINs and aggregations, this means the first user after each cache miss waits several seconds. ...

May 13, 2026 · 4 min · Anthony
Optimizing Django ORM Queries with defer(), only() and Prefetch()

Optimizing Django ORM Queries with defer(), only() and Prefetch()

By default, Django loads every field of a model on every query. On a list view showing 50 posts, that means fetching the full content, excerpt, metadata, and translation fields 50 times, even when only the title and date are displayed. Four QuerySet methods let you control exactly what gets loaded: defer(), only(), values_list(), and Prefetch(). The result: 2 SQL queries instead of N+2, with only the necessary columns. Django defer(): Exclude Heavy Fields from the QuerySet Django defer() tells the ORM to exclude specific fields from the initial query. Excluded fields remain accessible on the instance, but each access triggers an additional query. ...

May 8, 2026 · 6 min · Anthony
Django select_for_update(): row-level locking for concurrent transactions

Django select_for_update(): row-level locking for concurrent transactions

Two concurrent requests read a product’s stock, both see one unit remaining, and both confirm the order. Stock drops to -1. This kind of race condition is nearly impossible to reproduce in development and devastating in production. select_for_update() is Django’s answer: acquire a SQL lock at read time so no other transaction can modify the row before the current operation finishes. What select_for_update() does in SQL select_for_update() generates a SELECT ... FOR UPDATE. The lock is acquired as soon as the queryset is evaluated and held until the end of the transaction.atomic() block. Any other transaction that tries to acquire a lock on the same rows is blocked until the lock is released. ...

May 6, 2026 · 4 min · Anthony
Renaming Django ORM fields with F() in values()

Renaming Django ORM fields with F() in values()

When exposing data from a Django model to an API or serializer, the model’s field names don’t always match what you want to return. The usual approach: fetch instances, then rename in Python. There’s a better option: let the database do the work using F() inside values(). The problem: model field names dictate output class Task(models.Model): name = models.CharField(...) created_at = models.DateTimeField(...) If you want to return task_name instead of name, the typical approach is to fetch the data and rename in Python, either with a dict comprehension or inside the serializer. Either way, the transformation happens after the fact, in memory. ...

May 5, 2026 · 2 min · Anthony
Django Window Functions vs GROUP BY: Chainable QuerySets

Django Window Functions vs GROUP BY: Chainable QuerySets

Django ORM gives you two ways to add a computed value across a set of rows: annotate() with a classic aggregation (Max, Count, Sum…) or annotate() with a Window function. On the surface they look similar. In practice, they behave in fundamentally different ways — and picking the wrong one can break your entire filtering chain. GROUP BY with annotate(): rows that collapse When you combine values() and annotate() with an aggregation, Django generates a GROUP BY in SQL. The result: rows get merged, and you end up with one row per group. ...

May 4, 2026 · 4 min · Anthony
Django in_bulk(): why it beats filter() for bulk lookups

Django in_bulk(): why it beats filter() for bulk lookups

When you have a list of identifiers and want to retrieve the corresponding instances, the usual reflex in Django is filter(pk__in=[...]). It works — one SQL query. But in_bulk() is an often-overlooked ORM optimization: it returns a dictionary {id: instance} instead of a QuerySet, which fundamentally changes how you access results. Where filter() forces an O(n) traversal to find an object by ID, in_bulk() gives direct O(1) access. in_bulk() signature and behavior QuerySet.in_bulk(id_list=(), *, field_name='pk') id_list: list of identifiers to retrieve. If omitted (called without arguments), returns all objects in the table. field_name: field used as the dictionary key. Must have unique=True, otherwise Django raises a ValueError. The generated SQL is a simple WHERE pk IN (...) clause — one query regardless of list size. ...

May 4, 2026 · 4 min · Anthony

Newsletter

Get new articles delivered straight to your inbox.

No spam. Unsubscribe in one click.