Article

12 Things About Django I Wish I Knew Earlier

12 min read

Django becomes much easier once these twelve ideas click, from lazy QuerySets and hidden database work to validation, migrations, transactions, templates, and the admin.

When you first learn Django, some parts feel almost too easy. You write a model and get a database table. You register it and get an admin screen. You build a QuerySet and Django quietly turns it into SQL.

Then your project gets a little bigger. A page makes far more database queries than you expected. An object saves without running the validation you thought it would. An email goes out even though the database transaction fails. Django can suddenly feel magical in the less-helpful sense of the word.

The good news is that none of this is random. There are a few ideas underneath Django that make the whole framework easier to understand. These are the 12 things I wish someone had explained to me earlier.

1. A Django app is smaller than it sounds

Django uses the word ‘app’ in a slightly unusual way. It sounds like a complete product, but it is really just a home for one related part of your project. A blog can be an app. So can appointments, billing, or user accounts.

Technically, an app is a Python package. It may contain models, views, URLs, templates, static files, management commands, or only some of them. The official application documentation explains how apps fit inside a Django project.

The useful lesson is not to create a new app for every model or page. Group code around something your project actually does. A few clear apps are usually easier to work with than dozens of tiny ones tied together with imports and signals.

2. A QuerySet waits until you actually need the data

This line looks as if it fetches some posts, but it normally does not touch the database yet: posts = Post.objects.filter(is_published=True).

Instead, Django builds a QuerySet: a saved description of the query it can run later. You can keep adding filters without doing any database work. Django waits until you actually ask for the results.

A loop, list(), len(), or even a simple if check can finally run it. The QuerySet evaluation reference lists all the triggers. Once you know this, database queries stop appearing in such surprising places.

Use exists() when you only want to know whether a match exists. Use count() when you only need the number of rows. But if you are about to use every result anyway, it can be cheaper to evaluate the same QuerySet once and reuse what Django has already loaded.

3. One innocent loop can create a lot of queries

Imagine loading 30 blog posts and showing the author beside each one. Django may run one query for the posts, then 30 more as your loop asks for each author. The Python still looks clean, but the page now needs 31 database queries.

This is called the N+1 query problem. It often hides in templates, serializers, and admin list pages, far away from the line where you first built the QuerySet.

Use select_related() when each object has one related object, such as an author linked by a foreign key. Use prefetch_related() for collections such as tags, many-to-many fields, and reverse relationships. Both help Django fetch related data ahead of time, but they do it differently.

posts = (
    Post.objects
    .select_related("author")
    .prefetch_related("tags")
)
Load the authors with the posts, then fetch all the tags together in one extra batch.

4. The ORM helps, but the database still matters

The ORM is the part of Django that lets you work with your database using Python. It saves you from writing a lot of repetitive SQL, which is genuinely useful. But the database still has to do the work.

Joins, indexes, sorting, and the number of rows all still matter. It helps to see ORM code as a friendly way of describing a database query, not as a replacement for the database itself.

Start by checking how many queries run. When one query looks suspicious, QuerySet.explain() can show how the database plans to handle it. Filtering, counting, and updating data in the database is also often better than loading everything into Python first.

You do not need to become a database expert or replace clear Django code with raw SQL. A basic idea of what happens underneath the ORM is usually enough to make much better decisions.

5. save() does not check everything for you

This one catches a lot of people. Calling save() writes the model to the database, but it does not automatically run all of the model's validation first. Django's Model.save() does not call full_clean().

When data comes through a ModelForm, the form normally handles model validation for you. But a script, import, background task, or normal Python function can create and save the same object directly. In those places, you need to decide when validation should run. Calling full_clean() yourself can be the right answer.

I would not automatically add full_clean() to every model's save() method. That sounds convenient, but it can surprise existing code and make partial or bulk updates harder. Be clear about where validation happens, and use database constraints for rules that must never be broken.

article = Article(title=title, slug=slug)
article.full_clean()
article.save()
Ask Django to validate the object before saving it outside a ModelForm.

6. get_or_create() needs help from the database

The name get_or_create() sounds as if duplicate rows are impossible. They are not. If two requests arrive at almost the same time, both can look for a row, find nothing, and then create their own copy.

The Django QuerySet reference explains that this method is safe against that race only when the database also knows the values must be unique. If a duplicate would be invalid, say so with unique=True or a UniqueConstraint.

Python validation can give someone a friendly error message. The database constraint is what protects the rule when several pieces of code write at the same time.

class Membership(models.Model):
    team = models.ForeignKey(Team, on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["team", "user"],
                name="unique_team_user",
            ),
        ]
Tell the database that each user can have only one membership in a team.

7. Saving something does not always mean it is final

Most of the time, Django saves each database change straight away. But inside a transaction, that work can still be rolled back. A transaction is simply a group of changes that should either all succeed or all disappear together.

Use transaction.atomic() around the small piece of work that needs that guarantee. The easy-to-miss part is everything outside the database. You might send an email or start a background task, then have the database transaction fail. The email cannot be rolled back with the database.

transaction.on_commit() helps by waiting until the transaction succeeds before it runs your callback. Keep the transaction itself short. For most applications, a focused atomic() block plus on_commit() is a good place to start. If you later need stronger delivery guarantees, you may need an outbox or another durable queue pattern, but that is a more advanced problem.

from django.db import transaction

with transaction.atomic():
    order = Order.objects.create(customer=customer)
    transaction.on_commit(
        lambda order_id=order.pk: send_receipt(order_id)
    )
Wait until the order is safely committed before sending its receipt.

8. Old migrations see old versions of your models

A migration may look like a boring record of a database change. It is more important than that. Django can replay your migrations years later to build a fresh database from nothing.

To make that possible, a data migration must work with the version of a model that existed when the migration was created. Django keeps those older model shapes in its migration history. You get the right one through the migration's apps argument.

Importing your current model directly may work today, then fail when someone sets up the project after that model has changed. The historical models documentation explains why this happens.

I also prefer separate data and schema migrations when that makes the change easier to read or undo. And be careful when removing old fields, functions, or managers: an older migration may still refer to them.

def forwards(apps, schema_editor):
    Article = apps.get_model("blog", "Article")
    Article.objects.filter(status="live").update(is_published=True)
Ask the migration for the version of Article that existed at that point in history.

9. Bulk updates are fast because they skip some Django behavior

QuerySet.update(), bulk_create(), and bulk_update() can change many rows with very little code. They are fast because Django does not load every object and call save() on it one by one.

That speed comes with a trade-off. Django documents that bulk creation and update() skip each model's save() method. They also skip the usual pre-save and post-save signals, which are Django's automatic callbacks around a normal save.

This is expected behavior, not a bug. Use bulk methods when a direct database change is exactly what you want. If every object needs extra Python behavior, use a clear function or loop instead. It will do more work, but in that case the extra work is the point.

10. A template can query the database without looking like it

Django templates look pleasantly simple. That can make it easy to miss how much work one line is doing.

Templates do not use normal Python parentheses, but Django automatically calls a function or method when it needs no arguments. So {{ article.comments.count }} may call count() and query the database. Put that inside a loop and the same hidden work can happen again and again.

Prepare expensive data before rendering the page. Annotations and prefetching can help, and a query-count test can protect an important page from becoming slower later. A template can look simple without being cheap to render.

11. F() expressions let the database do the calculation

The name F() is not very descriptive, but the idea behind it is simple. It tells the database to use a value it already has while performing an update.

Imagine two requests increasing the same counter. If both read the old number before either saves, one increase can be lost. An F() expression lets the database increase its current value directly, without Django first loading that value into Python. Django's F() documentation explains how this avoids that race.

The earlier bulk-update rule still applies: QuerySet.update() does not call model save() hooks or save signals. This is a good tool when the direct database update is exactly the behavior you need.

from django.db.models import F

Counter.objects.filter(pk=counter_id).update(value=F("value") + 1)
Let the database increase its current value in one statement.

12. Django admin is excellent at one specific job

The Django admin gives you useful screens so quickly that it is tempting to keep bending it into every shape your project needs. Sometimes that works. Sometimes you end up fighting a tool that was built for a different job.

The official admin documentation describes it as a model-focused tool for trusted internal users, not as your entire front end. It is excellent for adding and editing records, searching, filtering, and other direct data-management work.

When the job turns into a guided approval process, a customer journey, or a workflow that should hide the underlying database structure, custom views are usually easier for everyone. An admin theme can make everyday admin work more comfortable, but it does not change what the admin is for. Knowing when to stop customizing it is part of using it well.

The bigger Django lesson

There is one idea running through all 12 lessons: ask where the work happens, and ask when it happens.

Has this query run yet? Did validation happen before the save? Is the transaction really finished? Does Python enforce this rule, or does the database? Did this faster shortcut skip something you were relying on?

Django gives you a lot of helpful shortcuts. You do not need to stop using them. You just need to know what they are doing for you. Once that clicks, Django feels much less magical and much more predictable.

A quick checklist for strange Django behavior

  • Ask what runs now and what Django saves for later.
  • Check how many database queries the complete page or task performs.
  • Decide whether a rule belongs in the form, model, application code, or database.
  • Check whether a group of related changes should succeed or fail together.
  • Make sure a bulk shortcut does not skip behavior you still need.
  • Ask whether the admin still suits the job you are trying to build.

Frequently asked questions

What should a Django developer learn after the basics?

Once you know the basics, learn when QuerySets actually run, how related objects affect query counts, where validation happens, how transactions work, and why migrations use older versions of your models. These ideas explain many Django problems that otherwise seem unrelated.

Does Django validate a model when save() is called?

No. Calling Model.save() does not automatically call full_clean(). A ModelForm normally handles model validation for you, but code that creates objects directly must choose when to validate them. Use database constraints for rules that must stay true no matter where the data comes from.

Why can a Django page be slow when the view contains only one QuerySet?

Related fields and template methods can run extra queries later. One loop may turn the original query into 30 or 100 more. Measure the complete page, then use select_related(), prefetch_related(), annotations, or a better query where they actually help.

Should Django admin be used as the complete application interface?

Usually not. Django admin is made for trusted people who need to manage data directly. It is very good at that job. A customer journey or step-by-step business process is normally clearer in custom views built for that specific task.

Back to blog

Expanded article image

Loading image…