Testing APIs
Testing is essential if you want to make sure your API actually works, handles edge cases properly, and doesn’t break things that used to work (this is called a “regression”). Django comes with its own built-in testing tool (built on top of Python’s unittest), but a lot of developers prefer pytest instead, because it’s cleaner to write and comes with some really handy features. This guide focuses on testing behavior - in other words, what your API actually does - not the tiny details of how you built it.
In this section, we’ll cover two big topics: Automated Testing and Performance Testing. We’ll learn how to write tests that check the API’s behavior from the user’s point of view, and how to measure and improve your API’s performance.
Testing Behavior vs. Implementation
Section titled “Testing Behavior vs. Implementation”This is one of the most important ideas in this whole guide, so let’s slow down and really understand it with a simple example first.
The Microwave Analogy
Section titled “The Microwave Analogy”Imagine you want to test if a microwave works properly.
Correct Way (Test Behavior):
- Press the start button
- Check that the screen shows a running timer for the time you asked for
- Make sure the heating stops once the timer reaches zero
- You’re testing what the user actually sees and expects
Wrong Way (Test Implementation):
- Open up the microwave’s casing
- Check the electrical signal on every single transistor
- Check the wiring on the internal circuit board
- You’re testing tiny internal details that the user never sees and doesn’t care about
The Same Idea Applies to Software
Section titled “The Same Idea Applies to Software”A lot of developers struggle with automated testing because they end up testing implementation details instead of behavior. Here’s why that’s a problem:
- Implementation changes a lot: You might swap a function-based view for a class-based view, split one model into two, or merge two models together
- Tests become fragile: If your tests check internal details, they’ll break every time you refactor your code, even if nothing is actually broken
- It becomes a maintenance headache: You end up spending your time fixing tests instead of fixing real bugs
- It gives you false confidence: Tests that check internal details don’t actually prove the API works the way users expect
So what’s the fix? Test the API’s behavior - what users actually see and experience - instead of testing exactly how you built it on the inside.
What to Test?
Section titled “What to Test?”Focus on Behavior, Not Implementation
Section titled “Focus on Behavior, Not Implementation”When you’re testing your API, ask yourself this: “How should this API behave, from the user’s point of view?”
Your tests should check things like:
- What HTTP status code comes back
- What data is inside the response
- How the API reacts to bad or invalid input
- Whether permission rules are actually being enforced
- Whether the feature works the way a user would expect
Your tests should NOT check things like:
- Whether you used a function-based view or a class-based view
- How data moves through your internal helper functions
- Whether you’re using Django’s ORM, raw SQL, or some outside service
- The internal structure of your database or how it’s optimized
Example: Testing an API Endpoint
Section titled “Example: Testing an API Endpoint”Let’s say your API has a POST /collections/ endpoint that’s used to create new collections. Here’s how you’d test its behavior (not its implementation):
Scenario 1: Unauthenticated Request
- A client sends a POST request without logging in (no auth token)
- Expected behavior: The API returns 401 Unauthorized
- Why: The API should reject any request that isn’t logged in
Scenario 2: Authenticated but Unauthorized Request
- A client sends a POST request with a valid login token, but the user isn’t an admin
- Expected behavior: The API returns 403 Forbidden
- Why: Only admins should be allowed to create collections
Scenario 3: Missing Required Data
- An admin sends a POST request, but forgets to include the collection’s name
- Expected behavior: The API returns 400 Bad Request, along with an error message in the response
- Why: The API should check that required fields are actually filled in
Scenario 4: Valid Request
- An admin sends a POST request that includes the collection name
- Expected behavior: The API returns 201 Created, along with the new collection’s ID in the response
- Why: The API should be able to successfully create the resource
Notice what we did NOT test:
- Whether the view is function-based or class-based
- How the serializer checks the data behind the scenes
- The exact SQL queries that get run
- Where exactly the response gets built inside the code
We only tested how the API behaves from the outside - which is exactly what we should be testing.
Reference Code for Testing Example
# models.pyfrom django.db import modelsclass Collection(models.Model): name = models.CharField(max_length=255) created_at = models.DateTimeField(auto_now_add=True)
class Product(models.Model):name = models.CharField(max_length=255)collection = models.ForeignKey(Collection, related_name='products', on_delete=models.CASCADE)created_at = models.DateTimeField(auto_now_add=True)# serializers.pyfrom rest_framework import serializersfrom .models import Collection, Product
class CollectionSerializer(serializers.ModelSerializer): class Meta: model = Collection fields = ['id', 'name']
class ProductSerializer(serializers.ModelSerializer): class Meta: model = Product fields = ['id', 'name', 'collection_id']# permissions.pyfrom rest_framework import permissionsclass IsAdminOrReadOnly(permissions.BasePermission): def has_permission(self, request, view): if request.method in permissions.SAFE_METHODS: return True return request.user and request.user.is_staff# views.pyfrom rest_framework import status, viewsetsfrom .serializers import CollectionSerializerfrom .models import Collection, Productfrom .permissions import IsAdminOrReadOnly
class CollectionViewSet(viewsets.ModelViewSet): queryset = Collection.objects.all() serializer_class = CollectionSerializer permission_classes = [IsAdminOrReadOnly]
class ProductViewSet(viewsets.ModelViewSet): queryset = Product.objects.all() serializer_class = ProductSerializer permission_classes = [IsAdminOrReadOnly]# urls.pyfrom django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom .views import CollectionViewSet, ProductViewSet
router = DefaultRouter()router.register(r'collections', CollectionViewSet, basename='collection')router.register(r'products', ProductViewSet, basename='product')urlpatterns = [ path('', include(router.urls)),]# /collections/# /collections/{id}/# /products/# /products/{id}/Pytest
Section titled “Pytest”Django’s built-in testing tool is solid, but many developers prefer pytest instead, because it’s cleaner to write, gives you clearer error messages, and comes with handy features like fixtures and parameterization (we’ll cover both of these soon).
Setting Up Pytest
Section titled “Setting Up Pytest”-
Install
pytest:Terminal window uv add --dev pytest pytest-django -
Create a
pytest.inifile in your project’s root folder, with the following content:[pytest]DJANGO_SETTINGS_MODULE = your_project_name.settings -
Follow pytest’s naming conventions (the standard way pytest expects things to be named):
- Directory structure: Put your tests in a
tests/folder inside each app, or in one top-leveltests/folder. - Test file naming: Name your test files starting with
test_(for example,test_views.py,test_models.py). - Test function naming: Name your test functions starting with
test_(for example,test_create_collection(),test_unauthorized_access()). - Test classes: You can group related tests inside classes that start with
Test(for example,class TestCollectionAPI:), though this part is optional.
- Directory structure: Put your tests in a
-
The
AAAPattern: Follow the Arrange-Act-Assert pattern in your tests, so they stay clear and easy to read:- Arrange: Set up your test data and the environment you need
- Act: Actually perform the thing you’re testing (like making an API request)
- Assert: Check that you got the result you expected (like checking the response’s status code and data)
-
Use these handy
pytestfeatures:- Fixtures: Use
@pytest.fixtureto build reusable test data and setup code (more on this below) - Parameterization: Use
@pytest.mark.parametrizeto run the exact same test multiple times, with different input values - Markers: Use your own custom markers (for example,
@pytest.mark.slow) to group tests and control which ones actually run
- Fixtures: Use
-
Run your tests using
pytest:Terminal window uv run pytest# or for specific test fileuv run pytest path/to/test_views.py# or for specific test classuv run pytest path/to/test_views.py::TestCollectionAPI# or for specific test functionuv run pytest path/to/test_views.py::test_create_collection# or for specific test under a classuv run pytest path/to/test_views.py::TestCollectionAPI::test_create_collection# or a test having a text in its function nameuv run pytest -k "anonymous" -
Continuous Testing: Use
pytest-watchto automatically rerun your tests whenever a file changes. Run it in its own separate terminal:Terminal window uv add --dev pytest-watchuv run ptw
Example
Section titled “Example”from rest_framework.test import APIClientfrom rest_framework import statusimport pytest
@pytest.mark.django_dbclass TestCollectionAPI: def test_if_user_is_anonymous(self): # Arrange client = APIClient()
# Act response = client.post('/collections/', {'name': 'My Collection'})
# Assert assert response.status_code == status.HTTP_401_UNAUTHORIZEDSkipping Tests
Section titled “Skipping Tests”If you have tests that don’t make sense to run in certain environments (for example, tests that need an outside service to be available), you can skip them using the @pytest.mark.skip decorator:
@pytest.mark.skip(reason="Requires external service")def test_external_service_integration(): # Test code that interacts with an external service passVS Code Pytest Integration
Section titled “VS Code Pytest Integration”VS Code has great built-in support for pytest. You can run and debug your tests right from inside the editor. Here’s how to set it up:
- Install the Python extension for VS Code.
- Open the command palette (Ctrl+Shift+P) and choose “Python: Configure Tests”.
- Pick
pytestas your testing framework. - Follow the prompts to set your test folder and pattern (Recommended choice:
. Root Directory). Once it’s set up, you can run any test by clicking the “Run Test” or “Debug Test” link that shows up right above each test function in the editor.
Why use pytest in VS Code:
- Run a single test or a whole test class with just one click
- Debug tests with breakpoints, stepping through the code line by line
- See test results and error messages right in the built-in terminal
Writing Pytest Tests
Section titled “Writing Pytest Tests”Now let’s actually use pytest to test our API endpoints.
Simple Testing
Section titled “Simple Testing”Here’s an example of how to write a test for the POST /collections/ endpoint using pytest:
from rest_framework.test import APIClientfrom rest_framework import statusimport pytest
@pytest.mark.django_dbclass TestCollectionAPI: def test_if_user_is_anonymous(self): # Arrange client = APIClient()
# Act response = client.post('/collections/', {'name': 'My Collection'})
# Assert assert response.status_code == status.HTTP_401_UNAUTHORIZEDAuthenticate User
Section titled “Authenticate User”To test an endpoint that needs the user to be logged in, create a real user, and then log that user into your test client:
The django_user_model fixture comes from pytest-django. It gives you the actual user model your project uses, so you can create real user records inside your tests. This is helpful because your test ends up working with the database and login system in the exact same way a real request would.
from rest_framework import statusimport pytestfrom rest_framework.test import APIClient
@pytest.mark.django_dbclass TestCollectionAPI: def test_if_user_is_authenticated_but_not_admin(self, django_user_model): # Arrange client = APIClient() user = django_user_model.objects.create_user( username='regular-user', password='password123', ) client.force_authenticate(user=user)
# Act response = client.post('/collections/', {'name': 'My Collection'})
# Assert assert response.status_code == status.HTTP_403_FORBIDDENHere, we create a real user, and then log the client in as that user. Since the user isn’t a staff member, the API should send back 403 Forbidden.
Multiple assertions
Section titled “Multiple assertions”You can also check multiple things in a single test, to verify different parts of the response all at once:
from rest_framework import statusimport pytestfrom rest_framework.test import APIClient
@pytest.mark.django_dbclass TestCollectionAPI: def test_create_collection(self, django_user_model): # Arrange client = APIClient() admin_user = django_user_model.objects.create_user( username='admin-user', password='password123', is_staff=True, ) client.force_authenticate(user=admin_user)
# Act response = client.post('/collections/', {'name': 'My Collection'})
# Assert assert response.status_code == status.HTTP_201_CREATED assert 'id' in response.data assert response.data['id'] > 0 assert response.data['name'] == 'My Collection'Here, we create a real staff user and log the client in as them. This makes the example match how a real admin would actually use the API, and clearly shows why the request should succeed.
Fixtures
Section titled “Fixtures”Pytest fixtures are a clean, simple way to share setup code between different tests. Use them whenever the same test data, client setup, or login step shows up in more than one test. They help you avoid copy-pasting the same code over and over, and make your tests easier to read.
You use a fixture by adding it as a parameter to your test function. Pytest runs the fixture first, and automatically hands the result over to your test.
Good times to use a fixture:
- When several tests all need the same API client
- When many tests all need a logged-in user
- When the setup is long, or it keeps repeating across many tests
- When you want to keep your test functions short and focused
You don’t need a fixture for every tiny test. If something is only used once, plain setup code right inside the test is often simpler to follow.
Creating a Fixture
Section titled “Creating a Fixture”# conftest.pyfrom rest_framework.test import APIClientimport pytest
@pytest.fixturedef api_client(): return APIClient()Using a Fixture in a Test
Section titled “Using a Fixture in a Test”from rest_framework import statusimport pytest
@pytest.mark.django_dbclass TestCollectionAPI: def test_if_user_is_anonymous(self, api_client): # Act response = api_client.post('/collections/', {'name': 'My Collection'})
# Assert assert response.status_code == status.HTTP_401_UNAUTHORIZEDIn this example, the api_client fixture builds one single APIClient for the test to use. This keeps the test short, and lets you reuse that same setup in other tests too.
Fixture with Dependencies
Section titled “Fixture with Dependencies”You can also build a fixture on top of another fixture. This comes in handy when one setup step depends on another. For example, you might want a helper that gives you back an API client that’s already logged in:
# conftest.pyfrom rest_framework.test import APIClientimport pytest
@pytest.fixturedef api_client(): return APIClient()
@pytest.fixturedef create_collection(api_client): def _create_collection(name='My Collection'): return api_client.post('/collections/', {'name': name}) return _create_collection
@pytest.fixturedef authenticate(api_client, django_user_model): def _authenticate(is_staff=False): user = django_user_model.objects.create_user( username='admin-user' if is_staff else 'regular-user', password='password123', is_staff=is_staff, ) api_client.force_authenticate(user=user) return api_client return _authenticatefrom rest_framework import statusimport pytest
@pytest.mark.django_dbclass TestCollectionAPI: def test_if_user_is_anonymous(self, create_collection): # Act response = create_collection(name='Test Collection')
# Assert assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_collection_as_admin(self, authenticate, create_collection): # Arrange authenticate(is_staff=True)
# Act response = create_collection(name='Test Collection')
# Assert assert response.status_code == status.HTTP_201_CREATED assert 'id' in response.data assert response.data['name'] == 'Test Collection'This pattern is handy whenever one test step needs another one to happen first. Here, authenticate depends on api_client and django_user_model, so this fixture can create a real user and log the client in, before the actual test runs.
Testing GET Endpoints with model_bakery
Section titled “Testing GET Endpoints with model_bakery”When you’re testing a GET endpoint, you usually need some data sitting in the database first. Instead of creating that data by hand every single time, you can use model_bakery. It quickly creates model records with sensible default values, which makes your tests shorter and easier to keep up with.
Docs: https://model-bakery.readthedocs.io/en/latest/
uv add --dev model_bakeryExample: Retrieve a Single Collection
Section titled “Example: Retrieve a Single Collection”from rest_framework import statusfrom model_bakery import bakerimport pytestfrom some_app.models import Collection
@pytest.mark.django_dbclass TestCollectionAPI: def test_get_collection(self, api_client, authenticate): # Arrange authenticate(is_staff=True) collection = baker.make(Collection)
# Act response = api_client.get(f'/collections/{collection.id}/')
# Assert assert response.status_code == status.HTTP_200_OK assert response.data == { 'id': collection.id, 'name': collection.name, }Understanding _quantity in baker.make
Section titled “Understanding _quantity in baker.make”from rest_framework import statusfrom model_bakery import bakerimport pytestfrom some_app.models import Collection, Product
@pytest.mark.django_dbclass TestProductAPI: def test_get_product(self, api_client, authenticate): # Arrange authenticate(is_staff=True) collection = baker.make(Collection) products = baker.make(Product, collection=collection, _quantity=10) product = products[0]
# Act response = api_client.get(f'/products/{product.id}/')
# Assert assert response.status_code == status.HTTP_200_OK assert response.data == { 'id': product.id, 'name': product.name, 'collection_id': collection.id, }If you don’t pass collection into baker.make(Product, _quantity=10), model_bakery might automatically create separate Collection records on its own, which means different products could end up linked to different collections.
If you do pass collection=collection, every product you create gets linked to that same collection. This is useful when you want to test relationships and filtering using consistent, related data.
Performance Testing with Locust
Section titled “Performance Testing with Locust”Locust is a powerful, Python-based tool used to test how your API performs by simulating lots of real users using it at the same time. Instead of writing test scripts in some special testing language, you just describe user behavior using regular Python code. This makes it especially handy if you’re a backend developer (like you, working with Django/DRF), since you already know Python.
In simple terms, Locust helps you answer questions like:
- How many users can my API handle at the same time?
- What happens if traffic suddenly spikes?
- Which endpoints are slow, or failing, when under heavy load?
- Where exactly are the bottlenecks - the database, the network, or the code itself?
How Locust Works
Section titled “How Locust Works”Think of Locust as creating a bunch of virtual (fake) users, where each one keeps repeating actions (called tasks), like this:
User ├── wait (thinking time) ├── call API endpoint (/collections/) ├── wait ├── call API endpoint (/collections/{id}/) ├── wait └── repeat...Each virtual user acts completely on its own, and Locust runs a whole bunch of them at the same time.
Knowing What to Test
Section titled “Knowing What to Test”Before you write any performance test, you need to figure out what actually matters most in your system.
For a Django REST API, the usual important areas are:
-
Read-heavy endpoints
- Listing collections
- Viewing a single resource
- Searching/filtering
-
Write-heavy endpoints
- Creating collections/products
- Updating data
-
Authentication endpoints
- Login
- Token refresh
-
Complex queries
- Endpoints that involve joins, filtering, or pagination
Example Strategy
Section titled “Example Strategy”If your API has these endpoints:
/collections/-> list/collections/{id}/-> detail/collections/(POST) -> create
Then realistic real-world usage probably looks something like:
80% -> read (GET)20% -> write (POST)So your performance test should try to match that same kind of split.
Installing Locust
Section titled “Installing Locust”uv add --dev locustConventions for Locust in Django
Section titled “Conventions for Locust in Django”To keep your tests clean, easy to scale up, and simple to debug, follow these habits:
-
one file per scenario
locust/├── browse_collections.py├── create_collection.py -
one action per task: Each
@taskshould represent just one single user action, nothing more. -
use
wait_time: This simulates the natural pause a real user would take while thinking:wait_time = between(1, 5) -
use
on_start: This is a special function that runs once, right when a user “starts up”. Use it for setup steps like logging in or grabbing a token.Runs once per user when they start:
- login
- setup tokens
-
use
namefor grouping: This helps keep your reports clean and readable in the UI:name="/collections/{id}/"
Running Locust
Section titled “Running Locust”-
Run Locust:
Terminal window uv run locust -f locust/browse_collections.py -
Open your browser at: http://localhost:8089
-
Fill in these fields:
- Users:
50 - Spawn rate:
5 - Host:
http://localhost:8000
- Users:
-
Start the test
Creating Locust Tasks
Section titled “Creating Locust Tasks”Below is a clean, beginner-friendly version of the code.
# locust/browse_collections.pyfrom locust import HttpUser, task, betweenfrom random import randint
class BrowseCollectionsUser(HttpUser): """ This user simulates read-heavy behavior: - Listing collections - Viewing a single collection """
wait_time = between(1, 5)
@task(3) # higher weight -> more frequent def browse_collections(self): """ Simulates user opening collection list page """ self.client.get("/collections/", name="/collections/")
@task(1) def browse_collection(self): """ Simulates user opening a single collection """ collection_id = randint(1, 20) # adjust to your DB self.client.get( f"/collections/{collection_id}/", name="/collections/{id}/" )# locust/create_collection.pyfrom locust import HttpUser, task, betweenimport randomimport string
class CreateCollectionUser(HttpUser): """ This user simulates write-heavy behavior: - Logging in - Creating collections """
wait_time = between(1, 5)
def on_start(self): """ Runs once when a user starts. Used for authentication. """
response = self.client.post( "/auth/login/", json={ "username": "admin", "password": "password123" } )
# Basic safety check if response.status_code != 200: raise Exception("Login failed")
data = response.json() access_token = data.get("access")
if not access_token: raise Exception("No access token received")
# Attach token to all future requests self.client.headers.update({ "Authorization": f"Bearer {access_token}" })
@task def create_collection(self): """ Simulates user creating a collection """
random_name = "".join( random.choices(string.ascii_letters, k=10) )
self.client.post( "/collections/", json={ "name": f"Test {random_name}" }, name="/collections/" )Understanding the Code
Section titled “Understanding the Code”1. HttpUser
Section titled “1. HttpUser”This is the base class you use to describe how a user behaves. Every instance of HttpUser represents one simulated user that will go through the tasks you’ve written against your API.
HttpUser ├── task() ├── task() └── wait_timeEach user keeps running its tasks over and over, in a loop.
2. @task(weight)
Section titled “2. @task(weight)”We use the @task decorator to mark what actions a user performs. The optional weight number controls how often that task runs, compared to the others.
@task(3) -> runs 3x more often@task(1) -> runs normallySo in our example:
browse_collections : browse_collection 3 : 13. wait_time
Section titled “3. wait_time”This simulates the natural pause a real human would take:
wait_time = between(1, 5)Without this:
- your API gets hit way harder than it would be in real life
- your results end up misleading
4. on_start()
Section titled “4. on_start()”A lifecycle hook (a special function): it runs exactly once, right when a user starts up. Use it for setup steps like logging in or grabbing a token. It’s also sometimes used to generate any dummy data needed before testing.
User starts ↓on_start() runs once ↓tasks start runningUsed for:
- login
- fetching tokens
- setting headers
5. client
Section titled “5. client”This is a wrapper around HTTP requests that Locust gives you. It lets you call your API endpoints, just like a real user’s browser or app would.
self.client.get(...)self.client.post(...)It automatically:
- tracks how long each response takes
- tracks failures
- sends all this data to the UI as metrics
6. name parameter
Section titled “6. name parameter”The name parameter inside self.client.get() is used to group similar requests together in the Locust UI. This matters a lot when your API has dynamic URLs (like /collections/{id}/), which would otherwise get counted as completely separate endpoints.
self.client.get(f"/collections/{id}/", name="/collections/{id}/")Without name, Locust treats every single ID as its own separate endpoint:
/collections/1//collections/2//collections/3/With name, it groups them all together as one:
/collections/{id}/7. json parameter
Section titled “7. json parameter”When making POST requests, you can use the json parameter to send JSON data inside the request body. This is a simple, convenient way to send structured data to your API.
self.client.post( "/collections/", json={ "name": "Test Collection" })How to Use Locust
Section titled “How to Use Locust”- Run:
uv run locust -f locust/browse_collections.py- Open:
http://localhost:8089- Fill in:
- Users:
10 - Spawn rate:
2 - Host:
http://localhost:8000
- Click Start Swarming
Understanding Metrics
Section titled “Understanding Metrics”Requests per second (RPS)
Section titled “Requests per second (RPS)”Higher = better throughputResponse time percentiles
Section titled “Response time percentiles”This tells you how fast (or slow) different groups of users experience your API:
- 50% -> the average user
- 95% -> the slower users
- 99% -> the worst-case scenario
Example:
50% -> 120ms95% -> 800ms ← WARNINGFailures
Section titled “Failures”Any response that isn’t in the 200 range counts as a failure:
500 -> server crash401 -> auth issue400 -> bad requestTesting Strategy
Section titled “Testing Strategy”- Start small:
5 users
- Check that everything is working correctly
- Slowly increase the load:
10 -> 20 -> 50 -> 100 -> 1000
- Keep an eye on:
- sudden jumps in response time
- an increase in failures
Django Silk (Profiling Tool)
Section titled “Django Silk (Profiling Tool)”Django Silk is a profiling tool that watches every single request, and shows you why your API is slow, by looking closely at the queries, how long they take, and where the bottlenecks are.
Installation & Setup
Section titled “Installation & Setup”uv add django-silk1. Add to INSTALLED_APPS:
if DEBUG: INSTALLED_APPS += ["silk"]2. Add Middleware:
if DEBUG: MIDDLEWARE += ["silk.middleware.SilkyMiddleware"]Put Silk’s middleware near the end of the list, so it gets to observe the entire request from start to finish.
3. Add URL Route:
# main urls.pyfrom django.urls import path, includefrom django.conf import settings
if settings.DEBUG: urlpatterns += [path("silk/", include("silk.urls", name="silk"))]4. Run Migrations:
python manage.py migrateUsing Silk
Section titled “Using Silk”- Start the server:
python manage.py runserver - Open the dashboard:
http://localhost:8000/silk/ - Generate some requests (for example, using Locust)
- Look through the requests, queries, and timing on the dashboard
Identifying N+1 Query Problems
Section titled “Identifying N+1 Query Problems”Here’s a real example of how to spot an N+1 problem using Silk. (An “N+1 problem” means your code runs one query, and then ends up running one extra query for every single item it loops through, instead of getting everything in one go.)
Without optimization (N+1 problem):
# Bad: Multiple queriesusers = User.objects.all() # Query 1for user in users: print(user.profile.bio) # Query N+1 (one query per user)Silk shows:
Query count: 11 queries (1 for users + 10 for profiles)Total time: 450msWith optimization:
# Good: Single query with prefetch_relatedusers = User.objects.prefetch_related('profile').all()for user in users: print(user.profile.bio) # No additional queriesSilk shows:
Query count: 2 queriesTotal time: 45msWorkflow
Section titled “Workflow”- Run your performance test (using Locust)
- Find the slow endpoints in the Silk dashboard
- Look at the queries to figure out what’s going wrong
- Turn off Silk in your settings (it adds extra overhead of its own)
- Fix your code (use
select_related,prefetch_related, and add database indexes where needed) - Run your tests again, without Silk, to see the real improvement
Optimizing ORM Queries
Section titled “Optimizing ORM Queries”- Use
select_related()for ForeignKey relationships (this does a single combined query, called a join) - Use
prefetch_related()for ManyToMany relationships and reverse ForeignKeys (this runs a few queries, but caches the results so they’re reused) - Check the raw SQL with
.queryto see exactly what Django is generating behind the scenes - Keep an eye on how many queries you’re running: fewer queries usually means a faster API
Performance Optimisation
Section titled “Performance Optimisation”Performance optimisation in a Django app is really about cutting out unnecessary work, at every single layer of your app:
Request -> Django ORM -> SQL Query -> Database -> ResponseIf even one of these layers is slow or wasteful, your whole API ends up feeling slow. The goal is to keep database load, memory use, and response time as low as possible, especially on endpoints that get hit a lot.
Optimising Django ORM Queries
Section titled “Optimising Django ORM Queries”Django’s ORM is powerful, but if you’re not careful, it can end up generating SQL that’s pretty inefficient.
1. Avoid N+1 Queries
Section titled “1. Avoid N+1 Queries”This happens when Django runs one query for the main object, and then runs extra queries, one by one, for each related object.
# BAD (N+1 problem)products = Product.objects.all()for p in products: print(p.category.name) # triggers extra query per productFix using select_related (for ForeignKey / OneToOne)
Section titled “Fix using select_related (for ForeignKey / OneToOne)”products = Product.objects.select_related("category").all()- Runs one combined SQL query (a JOIN)
- Grabs the related object in that same query
Fix using prefetch_related (for ManyToMany / reverse FK)
Section titled “Fix using prefetch_related (for ManyToMany / reverse FK)”products = Product.objects.prefetch_related("tags").all()- Runs a few separate queries
- Then combines the results together in Python
- Works efficiently for many-to-many relationships
2. Load Only Required Fields
Section titled “2. Load Only Required Fields”Try not to load data you don’t actually need.
Using only
Section titled “Using only”products = Product.objects.only("id", "title")- Loads only the fields you asked for
- Every other field gets skipped (deferred) automatically
Using defer
Section titled “Using defer”products = Product.objects.defer("description")- Skips fields that are large or that you’re not going to use
- Basically the opposite of
only
3. Use values() and values_list()
Section titled “3. Use values() and values_list()”products = Product.objects.values("id", "title")Returns:
[ {"id": 1, "title": "A"}, {"id": 2, "title": "B"}]products = Product.objects.values_list("id", "title")Returns:
[(1, "A"), (2, "B")]Why is this faster?
Model instance -> heavy (methods, state, ORM overhead)Dict/List -> lightweight (less memory, faster)Use these when:
- You don’t need any model methods (like
saveordelete) - You only care about the raw data itself
4. Efficient Counting
Section titled “4. Efficient Counting”# BADlen(Product.objects.all())- This loads every single record into memory first
# GOODProduct.objects.count()- This runs
SELECT COUNT(*)directly in the database - Much faster, and uses way less memory
5. Bulk Operations
Section titled “5. Bulk Operations”Bulk Create
Section titled “Bulk Create”Product.objects.bulk_create([ Product(title="A"), Product(title="B"),])Bulk Update
Section titled “Bulk Update”Product.objects.bulk_update(products, ["title"])Why does this matter?
Loop create -> N database queriesBulk create -> 1 database queryWhen ORM Is Not Enough
Section titled “When ORM Is Not Enough”Sometimes Django’s ORM ends up generating SQL that’s just not efficient enough.
Rewrite Query Using Raw SQL
Section titled “Rewrite Query Using Raw SQL”from django.db import connection
with connection.cursor() as cursor: cursor.execute("SELECT ...") rows = cursor.fetchall()Use this when:
- The query is genuinely complex
- The ORM keeps generating slow SQL
- You’re comfortable writing SQL yourself
Database Optimisation
Section titled “Database Optimisation”If your queries are still slow even after all this, the real problem is often in how your database is designed.
1. Add Indexes
Section titled “1. Add Indexes”class Product(models.Model): title = models.CharField(max_length=255, db_index=True)- Speeds up filtering and searching
- Becomes essential once your tables get large
2. Optimise Schema
Section titled “2. Optimise Schema”- Organize your data properly (this is called normalization)
- Avoid joins you don’t actually need
- Use the right field types for your data
Caching (Use Carefully)
Section titled “Caching (Use Carefully)”Caching means storing results in memory, so you don’t have to keep hitting the database with the same query over and over.
First request -> slow (DB hit)Next requests -> fast (cache hit)Example Flow
Section titled “Example Flow”Request -> Check cache ├── Hit -> return data └── Miss -> query DB -> store in cache -> returnImportant Note
Section titled “Important Note”Caching is not always faster:
Cache server (Redis) -> network callDatabase -> local queryIf the query is simple enough, your database might actually be faster than reaching out to a cache server.
Use caching for:
- Queries that are genuinely expensive to run
- Data that gets accessed very often
Scaling the Application
Section titled “Scaling the Application”If you’ve already optimized everything, but performance still drops once traffic gets heavy:
1. Vertical Scaling
Section titled “1. Vertical Scaling”Upgrade the server itself:
More CPUMore RAMFaster disk2. Horizontal Scaling
Section titled “2. Horizontal Scaling”Add more servers instead:
Load Balancer ├── Server 1 ├── Server 2 └── Server 3- Lets you handle more users at the same time
- Adds more complexity to your setup
- Costs more money
Practical Strategy
Section titled “Practical Strategy”Don’t try to optimize absolutely everything.
Focus your time on:
High traffic endpointsCritical user pathsSlow queriesAvoid spending time on:
Admin reports used rarelyLow-impact featuresStress Testing with Locust
Section titled “Stress Testing with Locust”Stress testing means pushing your API past its normal limits on purpose, so you can see how it behaves under extreme conditions. It helps you find the exact breaking point, spot bottlenecks, and see how well your system recovers after something goes wrong.
It’s not strictly required, but stress testing is a good habit to build into your workflow once you’ve finished your regular performance testing and optimization. It lets you confirm that your optimizations actually hold up under heavy load, and helps you spot any weak spots that are still left.
In this section, you’ll use Locust to simulate a large number of users hitting your API at the exact same time, and watch how it performs under that kind of stress.
By the end, you’ll have a clear picture of where your API’s breaking point is, and how it behaves during high-traffic situations.
Important Note
The development server isn’t built for production use, and it might not handle heavy traffic well at all. For accurate stress testing, use an environment that closely matches production (like a staging server), so your results actually reflect how your real deployment would behave.