Django is famous for its simplicity, flexibility, and robust features that made it a “batteries-included” framework offering comprehensive tools for web development, database operations, URL routing, HTML templating, and security. In this post and later posts (should time allow), I try to cover best practices for developing Django applications, with an emphasis on building performant, maintainable APIs, by gathering insights from authoritative sources and hands-on experience across multiple projects.
Let’s start with Django’s ORM, which serves as cornerstone of any application. As a developer, often, we hastily code the models without considering the long-term consequences. Our rushed decisions usually lead to future complications or even data corruption. So, it’s crucial to carefully plan and design Django models to ensure a robust foundation for your application.
This post covers some best practice advices in these topics :
- Optimal number of models in Django apps
- Django model inheritance
- Denormalization in django model
- Django Custom managers
- Empty values in Django char based fields
- Django model migrations consolidation
- Unique uuid identifier in Django model
- Django ORM advanced query tools
- Django model pk property for primary key fields
- Django model naming
- Update existing record in DB using Model.save()
- Avoid count() when it isn’t necessary
- Avoid unique foreign Key relation
- Order querysets in model level
- Use select_for_update wisely
- Keep Business Logic Inside the Model
- Enforce Data Consistency at the Database Level
1. Keep Number Of Models In Apps No MoreThan 10
If you’ve got 20 models in a single app , it’s time to slice and dice the app into smaller ones. To keep things manageable and avoid overloading, keep number of models no more than ten models per app. Using bounded context principles in Domain-Driven Design (DDD) can help in organizing separate apps and grouping related models.
2. Don’t Use Multi Table Inheritance
It’s a good practice to have base model. Typically, fields such as ‘created_at’ and ‘updated_at’ are ideal for go into the BaseModel. But, you should avoid using multi-table inheritance, because it leads to confusion and significant overhead. For each query on child it equires to join on parent model. Instead, opt for OneToOneFields and ForeignKeys. Multi-table inheritance does nothing but making troubles. In other word, in Django, subclassing creates new tables and involves numerous left joins, which can hamper performance, especially in high-demand environments like game backends. To avoid this, handle inheritance manually using techniques like null, OneToOne, or Foreign key.
Bad practice:
from django.db import models
class Employee(models.Model):
name= models.CharField(max_length=100)
class RegularEmployee(Employee):
salary= models.IntegerField()
bonus= models.IntegerField()
However, abstract models are still very useful. They allow you to eliminate redundant fields across multiple tables (e.g., created, updated, soft_delete). An abstract model doesn’t exist as a table in the database and cannot be instantiated. It simply serves as a reusable template for defining shared structure and behavior across your models:
class BaseModel(models.Model):
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
soft_delete = models.BooleanField(default=False)
class Meta:
abstract = True
class Product(BaseModel):
name= models.CharField(max_length=25)
company= models.CharField(max_length=25)
serial_number= models.CharField(max_length=25)
3. Denormalization Should be The Last Solution
We often see the denormalization as a first solution for challenges in our projects, unaware that it can cause complexity and increase the risk of data loss. It’s strongly recommended to consider other techniques such as caching before denormalization. Consider denormalization only when other techniques didn’t meet your needs.
4. Use Managers For Custom DB Queries
Django model manager provides a convenient mechanism for encapsulating complex query logic related to a model. Try to place frequently used operations associated with a particular model in the custom manager to have reusable code as well as improved code organization and readability. In the following example the get_published_posts() method filters blog posts with a status of “published”. We can use this method wherever we need to retrieve published posts, keeping our code DRY.
from django.db import models
class PostManager(models.Manager):
def get_published_posts(self):
"""Retrieve all published blog posts."""
return self.filter(status='published')
class BlogPost(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
status = models.CharField(max_length=10, choices=[('draft', 'Draft'), ('published', 'Published')])
objects = PostManager()
# Usage:
published_posts = BlogPost.objects.get_published_posts()
The Manager method should be dedicated to database-related tasks. Take, for example, the Manager method below, which filters product records based on their numeric serial number using their display serial number that starts with ‘SN’:
class DeviceManager(models.Manager):
def get_by_display_serial_number(display_serial_number:str):
numeric_serial_number = int(display_serial_number[2:])
products = Products.objects.filter(serial_number= numeric_serial_number)
return products
This approach falls short in terms of code clarity and single responsibility principle. It’s not a specialized database query or a complex operation; rather, it merely involves manipulating the input parameter, which is none of Manager method’s business. A more effective and cleaner approach would be creating a simple helper method to convert the display serial number to the numeric serial number, followed by querying it using Django’s model filter:
def get_numeric_serial_number(display_serial_number:str) -> int:
return int(display_serial_number[2:])
def some_service_or_view():
...
numeric_serial_number = get_numeric_serial_number(display_serial_number)
products = Product.objects.filter(serial_number=numeric_serial_number)
By segregating the conversion logic into a separate function, the code becomes more modular and comprehensible. This way, the purpose of each function is distinct, adhering to the single responsibility principle, that improves maintainability and readability.
5. Avoid null=True For Char Fields
Allowing both NULL and empty values in string-based fields is generally discouraged because it’s against data consistency. Allowing null values in such fields results in two possible representations for absence of data: NULL or an empty string. To maintain clarity and consistency, Django conventionally favors the use of an empty string to represent absence of data.
Bad practice:
from django.db import models
class Person(models.Model):
name = models.CharField(max_length=150, null=True, blank=True)
Best Practice:
from django.db import models
class Person(models.Model):
name = models.CharField(max_length=150, blank=True)
However, there is an exception to this guideline when the field is declared with unique=True. In such cases, two empty strings is considered as duplication, so you should change it to null=True instead of blank=True.
String-based fields includes:
- CharField
- TextField
- EmailField
- URLField
- SlugField
- FilePathField
- URLField (Repeated mention)
6. Consolidate Migrations for Cleaner Release
We may have more than on changes in models in a release. Consolidating all migrations into a single file for each app in each release simplifies the management and deployment of database schema changes. This approach bring a cleaner release process and reduces the risk of migration related issues. You can use Django squashmigrations to bring specific generated migrations to heel:
python manage.py squashmigrations <appname> <squashfrom> <squashto>
7. Use Two Unique Identifiers For Your Models :
In real world project, use two identifiers for records in a Django model, a private identifier, often the primary key (id), and a public ID, represented by a UUID (UID). This approach offers both security and convenience, as it prevents revealing sensitive information about the data while still allowing for unique identification of records. Maintaining enumerators as private is always advisable as they reveal sensitive information about our data, such as the number of records ( e.g. products or accounts) we have, which we prefer to keep confidential:
import uuid
from django.db import models
class YourModel(models.Model):
id = models.AutoField(primary_key=True)
uid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
name = models.CharField(max_length=100)
description = models.TextField()
def __str__(self):
return f'{self.name} - {self.uid}'
Don’t use UUID as primary key. The issue with UUID as primary key is the inefficiency in inserts due to the non-sequential nature of UUIDs. Because the primary key is often a clustered index by default in majority of databases. It means, databases need to resort physical storage when inserting new ID with lower ordinality. With UUID it will happen almost all the time. This can lead to significant delays in inserting new records, taking seconds or even minutes as the database grows.
8. Use Advanced Query Tools Instead Of Processing Records In Python
Instead of working with data in Python, let Django’s advanced query tools handle this task for you. By doing this, we can not only enhance performance but also achieve cleaner and more maintainable code. Let’s elaborate this concept with an example. Assume we need to have a list of all students whose Math scores are more than their English scores. Without Django ORM query expressions, it can be done with following code :
from models.students import Student
students = []
for student in Student.objects.iterator():
if student.math_score > student.english_score:
students.append(student)
The above code iterating through every Student record in the database using Python, one by one. It’s slow, memory consuming, and potentially leading to race conditions. Race conditions may arise when the script is executed concurrently with other user interactions with the same data. A more efficient and race-condition free approach is to use Django query expressions:
from django.db.models import F
from models.students import Student
students = Student.objects.filter(math_score__gt=F('english_score'))
This way, we leverage the database itself to perform the comparison, enhancing project performance and stability.
9. Use pk instead of id
In Django, the id field is the default primary key, automatically generating a unique integer for each database record. The pk property, however, refers to the model’s designated primary key field, whether it’s id, student_id, or something else. Using pk throughout your code offers flexibility, allowing you to change the primary key field without modifying your code. This makes your code more readable, self-explanatory, and consistent, regardless of the primary key’s name.
Example 1 – Using id:
student = Student.objects.get(id=42)
print(student.id)
Example 2 – Using pk:
student = Student.objects.get(pk=42)
print(student.pk)
I compared the performance of pk against id in a queryset filtering and retrieval operation with 100,000 sample records. The operation time increased by only 8% in 100,000 retrieve operations, a small trade-off for the benefits of readability and consistency.
10. Django Model Naming
- Model names should use singular nouns to represent individual entities. This helps clarify the relationships between models and minimizes potential confusion.
- Django models use CamelCase, a Python-based convention where each word in the name starts with an uppercase letter and no underscores are included.
- A ManyToManyField should be named with a plural noun that reflects the associated model. For example, if an Author model has a ManyToManyField linked to a Book model, the field might be called “books.” A OneToOneField should use a singular noun that mirrors the related model, showing a one-to-one relationship. For instance, a User model could have a one-to-one link with a Profile model, with the field named “profile.”
11. Use update_fields with save()
To update specific columns in a database record, it is better to use the update_fields parameter when calling the save() method. This approach allows you to specify precisely which fields should be updated.
product = Product.objects.get(id=1)
product.name = "new product name"
product.save(update_fields=['name'])
The resulting SQL query will be:
UPDATE "product"
SET "name" = 'new product name'
WHERE "product"."id" = 1
You can also update multiple fields at once by including additional field names in the update_fields list.
Using this method is more efficient because it limits the database operation to only the specified fields, reducing unnecessary overhead and improving performance. Another key reason to use update_fields is to prevent data conflicts during concurrent updates. Consider a scenario with the Product model: if one user sets the is_deleted flag to True while another user changes the product’s name, and these actions happen in separate processes, using the generic save() method can lead to issues. The second process might unintentionally overwrite the is_deleted value back to False because it retains the outdated value in memory.
While projects with high levels of concurrent data modification may require more robust conflict management strategies, using update_fields to update only the necessary fields significantly reduces the risk of unintended side effects.
12. Avoid count() when it is not necessary
Database counts are slow. Avoid using count if it’s not necessary. A common usage of count is cases where you only need to check if any results exist. In this case using queryset.count() is less efficient than queryset.exists(). The example below demonstrates a more efficient and readable approach:
from models import Hound
queryset = Hound.objects.filter(pk=1)
if queryset.exists():
return "Run away!"
else:
return "The coast is clear"
In contrast, the following approach using queryset.count() is both harder to read and less efficient:
from models import Hound
queryset = Hound.objects.filter(pk=1)
if queryset.count() > 0: # Unnecessary counting of all rows
return "Run away!"
else:
return "The coast is clear"
According to the Django documentation, you should use queryset.count() when you need the exact number of results, and queryset.exists() when you only need to determine if at least one result exists.
This distinction is crucial because queryset.count() triggers an SQL query that scans all rows in the database table to calculate the total count. In contrast, queryset.exists() optimizes the query by only checking for the presence of a single record, avoiding unnecessary overhead. It achieves this by:
- Removing ordering.
- Removing grouping.
- Clearing any developer-defined
select_relatedordistinctclauses in the queryset.
Note that, worse than using count() method, is checking if a queryset contains data like this:
if queryset:
# do smothing
QuerySets in Django are lazy, evaluating them in a boolean context triggers a database query, which can lead to performance overhead.
13. Avoid Unique Foreign Key relation
A one-to-one relationship links one record in a model to exactly one record in another. Instead of using ForeignKey(unique=True), which is less idiomatic, Django provides a more appropriate solution: OneToOneField.
Example:
❌ Avoid:
from django.contrib.auth.models import User
from django.db import models
class Profile(models.Model):
user = models.ForeignKey(User, unique=True)
phone_number = models.CharField(max_length=15, blank=True, null=True)
birth_date = models.DateField(blank=True, null=True)
address = models.TextField(blank=True, null=True)
✅ Use:
from django.contrib.auth.models import User
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
phone_number = models.CharField(max_length=15, blank=True, null=True)
birth_date = models.DateField(blank=True, null=True)
address = models.TextField(blank=True, null=True)
The OneToOneField field brings better readability, enforces the one-to-one constraint at the database level, and aligns with Django’s best practices.
14. Order Queryset at the Model Level
If a model is supposed to be sorted in the majority of cases — for example, in list views, API endpoints, or admin pages — it’s a good practice to define a default ordering in the model’s Meta class:
class Product(models.Model):
name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
This ensures consistent ordering across your project without the need to explicitly add .order_by() in every query. In other word, it helps avoid situations where developers forget to add .order_by() in new queries leading to inconsistent or unexpected results especially during pagination.
Note that, this should only be applied when it really makes sense — i.e., when most of your queries and views rely on a particular ordering.
15. Use select_for_update Wisely
When using select_for_update, it’s crucial to understand how it interacts with related models, especially when combined with select_related. While select_for_update is an excellent way to prevent race conditions by locking rows for update, it can have unintended consequences if you’re not careful.
If you use select_for_update in conjunction with select_related, Django will lock not only the rows from the base queryset model but also the rows from the related models fetched through select_related. This behavior can unintentionally escalate the scope of database locks and potentially lead to contention or even deadlocks.
Best Practice
Be explicit about which related models you include in select_related when using select_for_update. Only include the models you truly intend to lock. Avoid doing without arguments in such cases, as it may cause broader locking than needed.select_for_update()
# ❌ This may lock more than you expect
Book.objects.select_related("author","category").select_for_update().get(pk=1)
# ✅ Better: explicitly state the relation you want to lock
Book.objects.select_related("author").select_for_update(of=('self',)).get(pk=1)
# Note that self is a special keyword referring to queryset's model
# which here is Book
The later queryset avoids locking other tables unnecessarily, leading to safer concurrency handling. It’s recommended to always explicitly mention the tables to lock even if you don’t have select_related at this moment, because you might have in the future.
16. Keep Business Logic Inside the Model
One important principle when working with Django models is this: keep model-related business logic inside the model itself whenever possible. A common mistake is scattering model behavior across API views, serializers, forms, signals, or service functions. For example, generating a serial number for a new Device should not happen inside the API endpoint that creates the device. The model itself should own that responsibility.
By placing this logic in the model layer, every piece of code that creates a Device automatically follows the same rules, whether it comes from the Django admin, a management command, a Celery task, an API endpoint, or a test case. This improves consistency and significantly reduces the chance of bugs caused by duplicated or missing logic.
Following this rule aligns well with several software engineering principles:
- DRY (Don’t Repeat Yourself) : business rules live in one place.
- Single Responsibility Principle : views and serializers should focus on input/output handling, not domain behavior.
- Encapsulation : the model protects and manages its own state.
- Maintainability : future changes only need to happen in one location.
Example:
class Device(models.Model):
...
def save(self, *args, **kwargs):
self.full_clean()
if not self.serial_number:
max_serial_number = (
self.__class__.objects.aggregate(
models.Max("serial_number")
)["serial_number__max"]
or 0
)
self.serial_number = max_serial_number + 1
super().save(*args, **kwargs)
In this example, the Device model guarantees that every newly created instance receives a serial number if one is not provided explicitly. Consumers of the model do not need to know how serial numbers are generated, they simply create a Device, and the model handles the rest.
Keeping domain rules close to the data they belonged usually results in cleaner architecture, thinner views, and more predictable behavior across the application.
17. Enforce Data Consistency at the Database Level
Not every rule should live only in Python code. Some rules describe the shape of valid data itself, and those rules should be protected as close to the data as possible. Django validations, serializers, forms, and model clean() methods are great for catching problems early and returning helpful messages to users. But they only run when that specific part of the application is used. Once data can be written from multiple places, relying only on application code becomes risky.
There are many ways data can bypass application-level checks: direct database access, migrations, management commands, Celery tasks, other services, admin actions, or even future code paths that forget to call the right validation method. If a rule is truly required by the business domain, the database should enforce it too.
Assume we have a booking system where start_at and end_at must always happen on the same calendar day. We can first add a model-level validation to provide a clear error message:
from django.core.exceptions import ValidationError
from django.db import models
class Booking(models.Model):
start_at = models.DateTimeField()
end_at = models.DateTimeField()
def clean(self):
super().clean()
if self.start_at.date() != self.end_at.date():
raise ValidationError(
{
"end_at": "start_at and end_at must be on the same day"
}
)
This validation is useful, especially when the model is used through Django forms, Django admin, or when full_clean() is called manually. However, it is still application-layer validation. It can be skipped. The stronger solution is to add a database-level check constraint:
from django.db import models
from django.db.models import F, Q
class Booking(models.Model):
start_at = models.DateTimeField()
end_at = models.DateTimeField()
class Meta:
constraints = [
models.CheckConstraint(
condition=Q(start_at__date=F("end_at__date")),
name="ck_booking_same_day",
),
]
With this constraint, every write path must respect the rule. It does not matter whether the record is inserted from an API endpoint, Django admin, a background task, a migration, another service, or direct SQL access. The database itself rejects invalid data.
You should still keep application-level validation when it improves user experience. The best approach is usually both:
- Use serializer, form, or model validation for friendly error messages.
- Use database constraints for real data protection.
When the database constraint is violated, catch the database error at the appropriate layer and raise a domain-tailored exception:
from django.db import IntegrityError, transaction
class InvalidBookingDateRange(Exception):
pass
def create_booking(*, start_at, end_at):
try:
with transaction.atomic():
return Booking.objects.create(
start_at=start_at,
end_at=end_at,
)
except IntegrityError as exc:
if "ck_booking_same_day" in str(exc):
raise InvalidBookingDateRange(
"start_at and end_at must be on the same day"
) from exc
raise
Note that checking the constraint name from the error message can be database-dependent, so keep this logic close to your persistence or service layer and test it against your actual database engine. Database constraints are not only for uniqueness. They are one of the most reliable ways to protect important business invariants and prevent silent data corruption.
Final word
Finally, although these best practices offer pragmatic and tested solutions and focus on re-usability, readability and reliability of code in Django development, always there are alternative approaches for different scenarios and different situations that fits your requirement.
Happy coding!✌
References :



Could you please write best practice for API development using Django Rest Framework?
Could you elaborate on strategies for managing complex relationships between models while maintaining performance and readability?