Django Backend Foundations: Mastering Its Out-of-the-Box Tools


Welcome back to Django Backend Foundations!
Having established your Django project's core environment in Part 1 and built its foundational structure with apps, models, and views in Part 2, you're now ready to unlock what makes Django such a powerful framework straight out of the box.
Before we even touch Django REST Framework (DRF), Django already gives you a complete toolkit for building robust web applications:
An ORM to interact with your database
A built-in templating engine
Clean, modular routing via
urls.py
An auto-generated admin panel
Full user authentication
Powerful middleware hooks
All of it is included — no third-party packages required.
Django is also opinionated, meaning it comes with a way it expects you to build apps and write code. (This is great for beginners; less decision fatigue.)
It follows the MVC pattern — although in Django parlance, it’s called MTV (Model-Template-View).
ORM(Object Relational Mapping)
ORM(Object Relational Mapping) is a programming technique that creates a bridge for data exchange between an OOP like Python and relational databases. Basically, it lets you query & manipulate data in your DB using your language. Django’s ORM allows you write Python classes to define your database schema(model), and use Python methods to query and manipulate that data.
It’s more natural to write with so instead of writing raw SQL, which is powerful but verbose, you can write easier to read commands in your own language.
Instead of writing this:
employee_list = []
sql = "SELECT * FROM employees WHERE department = 'Engineering'"
rows = execute_sql(sql)
for row in rows: employee = Employee()
employee.name = row['name']
employee.department = row['department']
employee.hire_date = row['hire_date']
employee_list.append(employee)
You write this:
employee_list = Employee.objects.filter(department="Engineering")
Same query. Less brain strain.
So it provides abstraction and readability, making easier to read, debug and refactor without breaking queries across your codebase.
Django’s ORM also provides Cross-Database Compatibility (DB agnosticism). Because you’re not writing raw SQL, Django’s ORM allows your app to support:
PostgreSQL ✅
MySQL ✅
SQLite ✅
Oracle (if you hate yourself) ✅
Switching databases becomes much easier — especially in early development.
The Manager (.objects
) and QuerySets: Interacting with Your Data
The objects
attribute (the default manager) on your models is your primary gateway to the database. It allows you to create, retrieve, update, and delete objects using Django's powerful ORM.
The Manager: Like we briefly discussed in Part 1, every models.Model
class automatically gets an objects
attribute. This is an instance of django.db.models.Manager
, and it provides methods for database queries.
Now, when you use manager methods like all()
, filter()
, or exclude()
, Django returns a QuerySet, which is a collection of database objects, but it's lazily evaluated. This means the database query isn't actually executed until you iterate over the QuerySet (e.g., with a for
loop), slice it, convert it to a list, or access its length. This lazy behavior is great for performance, as Django only fetches data when it absolutely needs it.
QuerySets are also chainable i.e. you can combine multiple methods to refine your query, and each method returns a new QuerySet, allowing for complex data filtering.
Common QuerySet methods include:
Retrieval:
Model.objects.all()
: this returns a QuerySet of all objects in the database for that model.Model.objects.get(pk=1)
: retrieves a single object matching the given lookup parameters, raisesDoesNotExist
if no object is found, andMultipleObjectsReturned
if more than one object matches.Model.objects.filter(field=value, another_field__gt=10)
: it returns a QuerySet containing objects that match the given lookup parameters. Supports various field lookups (e.g.,__exact
,__iexact
,__contains
,__startswith
,__gt
,__lt
,__date
).# this returns a queryset of adults adults = User.objects.exclude(age__gt=17, headline="Hello")
Model.objects.exclude(field=value)
: returns a QuerySet containing objects that do not match the given lookup parameters.Model.objects.select_related('foreign_key_field')
: used for performance optimization withForeignKey
andOneToOneField
relationships. It performs a SQLJOIN
and fetches the related objects in the same database query, avoiding subsequent database hits.Model.objects.prefetch_related('many_to_many_field')
: Used for performance optimization withManyToManyField
andForeignKey
relationships where the related objects are not directly joined. It performs separate lookups for each relationship and then "joins" them in Python, preventing the “N+1 query problem.”
Ordering:
Model.objects.order_by('field_name')
: orders the QuerySet by the specified field(s). Use-field_name
for descending order.# sorts array in ascending order using the age field adults = User.objects.order_by('age')
Limiting:
Model.objects.all()[0:5]
: slices a QuerySet to limit the number of results, similar to Python list slicing.
Counting:
Model.objects.count()
: returns the number of objects in the QuerySet.
Existence:
Model.objects.filter(name='Tito').exists()
: returnsTrue
if any object matches the filter,False
otherwise. This is more efficient thancount() > 0
if you only need to check for existence.
Creation:
Model.objects.create(field1=value1, field2=value2)
: instantiates an object, saves it to the database, and returns the created object.Model.objects.get_or_create(field=value, defaults={...})
: attempts toget
an object based on the given parameters; if not found, itcreate
s it. Returns(object, created_boolean)
.
Update:
Model.objects.filter(status='draft').update(status='published')
: updates fields for all objects in the QuerySet in a single database query. Way more efficient than looping through objects and callingsave()
on each.
Deletion:
Model.objects.filter(status='old').delete()
: Deletes all objects in the QuerySet. Be careful with this.
Your python
manage.py
shell
Example Explained:
python manage.py shell # Triggers an interactive Python console with your Django project loaded
from django.contrib.auth.models import User # import the built-in User model
from users.models import Profile # importing the custom Profile model
# create user
user = User.objects.create_user(username='tito1', password='secure123')
Here, User.objects
is the manager for the User
model. create_user()
is a specific method provided by Django's UserManager
(the default manager for User
models) that handles password hashing and other user creation specifics securely. This creates a User
instance and saves it to the database.
# create associated profile
profile = Profile.objects.create(user=user, bio="Backend engineer", location="Lagos")
Similarly, Profile.objects
is the manager for your Profile
model. create()
is a standard manager method that creates a new Profile
instance, sets its fields (including the user
field, which automatically links to the User
object created above), saves it to the database, and returns the newly created Profile
object.
# to access related data
print(user.username) # 'tito1' - accessing an attribute of the User object
print(profile.user.username) # 'tito1' - accessing the User object related to the Profile, then its username
print(user.profile.bio) # accessing the Profile object related to the User, then its bio
This demonstrates forward and reverse relationship traversal. You can navigate between related objects using simple dot notation (.
) because of the ORM.
create()
vs.save()
:Model.objects.create(...)
: This is a convenience method that does two things: it instantiates a model object and then saves it to the database in one go. It returns the newly created and saved object.instance.save
()
: This method is called on an already existing model instance (either one you just instantiated yourself, or one you retrieved from the database). It persists any changes made to that instance's fields to the database.
# using save() for a new instance
new_user = User(username='new_user', email='new@example.com') # instance is created, but not saved yet
new_user.set_password('mysecret') # sets password securely
new_user.save() # now it's saved to the database
# using save() for an existing instance
existing_user = User.objects.get(username='new_user')
existing_user.email = 'updated@example.com'
existing_user.save() # update the database record
Django ORM vs. AdonisJS ORM (Lucid)
If you’ve worked with AdonisJS, you’ve already seen how an ORM works — because Lucid (Adonis’s ORM) plays a very similar role to Django’s.
Concept | Django ORM | AdonisJS (Lucid ORM) |
Define models | Python classes with fields | JavaScript/TypeScript classes extending BaseModel |
Create table schema | Model + migrations | Model + migrations |
Query data | Model.objects.filter(...) | Model.query().where(...) |
Save changes | .save() | .save() |
Migrations | makemigrations + migrate | node ace make:migration + node ace migration:run |
Relationships | ForeignKey , ManyToManyField , etc. | hasOne , hasMany , belongsTo , etc. |
Templating Engine
Django’s templating engine lets you dynamically generate HTML (or other text formats) using plain HTML files + special Django tags and filters. This makes it easy to create web pages that include logic like loops, conditions, and variable substitution. Without it, you'd be building HTML with string concatenation or mixing Python into your HTML — messy, hard to maintain.
An example:
In your view:
# users/views.py
from django.shortcuts import render
def profile(request):
context = {'name': 'Tito'}
return render(request, 'users/profile.html', context)
In profile.html
:
<h1>Hello, {{ name }}!</h1>
Note: As mentioned in the Part 1, remember to set APP_DIRS
to True
and to dump your html file in users/templates/users
.
Since, we’d like to view this for testing purposes, in the root urls.py
file:
# mysite/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls), # ignoring this for now
path('users/', include('users.urls')),
]
In the app’s urls.py
:
# users/urls.py
from django.urls import path
from .views import profile_view
urlpatterns = [
path('profile/', profile_view),
]
Enter localhost:8000/users/profile
, and that’s it! You’ve got your template.
If you got the TemplateDoesNotExist
error, you probably missed something.
A few checks:
If you have
APP_DIRS
set toFalse
, ensure 'DIRS
' is not empty. You can point to it like this:[BASE_DIR / "templates"]
. Also, ensuretemplates/
is in your base directory, which should be on the same level withmanage.py
.# in your view, no need for the users prefix def profile_view(request): context = {"name": "Tito"} return render(request, "profile.html", context)
If you prefer the app-level approach, like in our example, you have to add namespacing while creating your templates folder:
users/templates/users/profile.html
, ensure your app is added to theINSTALLED_APPS
list, and properly reference it in your view.def profile_view(request): context = {"name": "Tito"} return render(request, "users/profile.html", context)
Most devs, including myself, prefer the project-level approach but as always do what works best for you and your team.
You can pass almost anything from your view’s context dictionary to your template: strings, numbers, booleans, lists / tuples, dictionaries, QuerySets (which act like lists of model instances), custom objects (as long as they have properties/methods you want to access).
Want to iterate over a list? Here’s how to do it:
def profile_view(request):
context = {"name": "Tito", "reading_tags": ['history', 'fantasy'], "followers": []}
return render(request, "users/profile.html", context)
<!-- profile.html -->
<h1>Hello, {{ name }}!</h1>
<ul>
{% for tag in reading_tags %}
<li>{{ tag }}</li>
{% empty %}
<li>No tags found</li>
{% endfor %}
</ul>
<ul>
{% for follower in followers %}
<li>{{ follower }}</li>
{% empty %}
<li>You need to make some friends</li>
{% endfor %}
</ul>
It looks a bit tacky doesn’t it? Let’s add some styling.
Using inline and internal CSS
<!DOCTYPE html> <html> <head> <style> h1 { color: navy; font-size: 2rem; } ul { padding: 0; list-style-type: none; } li.tag { background: #e0f7fa; padding: 8px; margin: 4px; border-radius: 4px; } li.follower { color: green; font-weight: bold; } li.empty { color: gray; font-style: italic; } </style> </head> <body> <!-- inline CSS --> <h1 style="color: navy; font-size: 2rem">Hello, {{ name }}!</h1> <ul> {% for tag in reading_tags %} <li class="tag">{{ tag }}</li> {% empty %} <li class="empty">No tags found</li> {% endfor %} </ul> <ul> {% for follower in followers %} <li class="follower">{{ follower }}</li> {% empty %} <li class="empty">You need to make some friends</li> {% endfor %} </ul> </body> </html>
Using external CSS
{% load static %} <link rel="stylesheet" href="{% static 'users/styles.css' %}">
Bear in mind, this is very similar to how you define templates on the app-level. You have to apply namespacing:
users/styles/users/styles.css
. And in yoursettings.py
file:STATIC_URL = '/static/'
Result:
If you’d rather use a utility framework like TailwindCSS or Bootstrap:
You can add their CDN link in the
<head>
Then use their utility classes (e.g.
text-lg text-blue-700
bg-gray-100
rounded
)
Some notes on what works in templates
You can use
{% for %}
,{% if %}
, filters like|length
,|join
, etc.You cannot run arbitrary Python code in templates — that’s by design for security and simplicity.
But you can access attributes and call simple methods (e.g.,
.upper()
) on objects.If you want complex logic, do it in the view or a custom template tag/filter.
Routing
As we covered earlier in the first part of this article, in Django, routing is how you connect a URL (like /blog/1/
) to a Python view function or class that handles the request and returns a response.
You can think of URLs as commands:
/users/profile/
→ “run the profile view”/blog/5/
→ “run the view that shows blog post #5”
In Django, this is handled with URL patterns.
path()
for simple URLs:
path('about/', about_view)
path('<int:id>/', ...)
for dynamic segments:
path('post/<int:id>/', post_detail_view)
# <int:id> matches numbers and passes them to your view like:
# views.py
def post_detail_view(request, id):
...
re_path()
if you want regex-based routes
Optionally, you can give routes names.
You can name your routes:
path('profile/', profile_view, name='profile')
When you name a route, you’re giving the route a label — in this case, "profile"
— that you can reference in templates, views, and redirects, instead of hardcoding the actual URL path.
Why you should do this:
Avoid Hardcoding URLs
In templates:
<!-- Not great --> <a href="/users/profile/">View Profile</a> <!-- Better --> <a href="{% url 'profile' %}">View Profile</a>
If you ever change
/users/profile/
to/users/account/
, you'd have to manually update every hardcoded link. With named routes, just change thepath()
— Django handles the rest.Cleaner Redirects in Views
# Without name return redirect('/users/profile/') # With name return redirect('profile')
Again — fewer hardcoded paths, more flexibility.
Reverse URL Resolution
In Python (not templates), you can reverse a URL using
reverse()
:from django.urls import reverse url = reverse('profile') # returns '/users/profile/'
This is great for: DRY code, generating URLs programmatically, and API responses or redirects
Safer Refactoring
Let’s say you change:
path('profile/', profile_view, name='profile') # to path('account/', profile_view, name='profile')
All
{% url 'profile' %}
andreverse('profile')
still work. If you hardcoded/profile/
everywhere, you're now in cleanup hell.Reusable URLs in Templates
If you're using
include()
for reusable apps:path('auth/', include('django.contrib.auth.urls')),
You can still use names like
'login'
,'logout'
,'password_reset'
in your templates without knowing their exact paths (Note: this is relevant for only backend pointing urls)<a href="{% url 'login' %}">Login</a>
Django vs. AdonisJS Routing
Concept | Django | AdonisJS |
Route file | urls.py | routes.ts |
Route def | path('users/', view) | Route.get('/users', ...) |
Dynamic route | <int:id> | :id |
Group routes | include('app.urls') | Route.group (...) |
Route names | name='profile' | .as('profile') |
They’re conceptually the same — Django just splits routing across files more strictly.
Admin
A full-featured, auto-generated CRUD interface for your models.
Literally:
Add/edit/delete records
Search and filter
Manage users, permissions
No coding required to get started
Here’s how you can use it:
1. Create a superuser:
python manage.py createsuperuser
If you get this error:
django.db.utils.OperationalError: no such table: auth_user
It is because Django hasn't applied its initial migrations, and skipped the models.py
section, which create all the built-in tables (e.g., for auth
, admin
, sessions
, etc.). This is a step often forgotten when creating a fresh project.
You can fix this in one step by running this:
python manage.py makemigrations # skip this for now. Not needed for initial migrations. Scans your models and creates migration files. Kinda like a todo list for your app
python manage.py migrate # applies those files to the database (runs the SQL). This actually runs the todo list
The second command will:
Run all pending migrations
Create all necessary tables in your database (like
auth_user
,django_admin_log
, etc.)
Then, you can try to create a superuser again with python manage.py createsuperuser
. You should get prompted to enter your username, email address and password.
2. Run server and visit: http://localhost:8000/admin/
Login with your superuser credentials and boom — instant dashboard.
3. Register your model:
In users/admin.py
, you can register models:
from django.contrib import admin
from .models import Profile
admin.site.register(Profile)
Now your model appears in /admin
.
Why it’s awesome
No extra UI work
Great for internal teams or clients
Works with your actual data models
Saves you from building separate admin panels in React or whatever
Django Auth
A complete authentication system, that eliminates weeks of boilerplate, including:
User model
Password hashing & validation
Login/logout views
Permissions and groups
Middleware for access control
We have some features out of the box:
User
model with fields likeusername
,email
,password
,is_staff
, etc.Authentication views at:
/accounts/login/
:LoginView
/accounts/logout/
:LogoutView
/accounts/password_change/
:PasswordResetView
All you need to do is to include this in
urls.py
:# mysite/urls.py path('accounts/', include('django.contrib.auth.urls')),
Now, to require login:
from django.contrib.auth.decorators import login_required
@login_required # add this to users/views.py to require logging in
def profile_view(request):
return render(request, 'users/profile.html')
If the user isn’t logged in, they get redirected to /accounts/login/
. Note: Ensure, you are already logged out of admin.
If everything is going right, you should get this error:
django.template.exceptions.TemplateDoesNotExist: registration/login.html
When you use Django’s built-in authentication system, especially views like:
from django.contrib.auth.views import LoginView
…Django expects you to provide the login template it needs.
By default, it looks for: registration/login.html
Why?
Because LoginView
(and others like PasswordResetView
, LogoutView
, etc.), are prebuilt views that assume you're following Django conventions, including that auth-related templates live under a registration/
folder.
So we do the obvious, create it.
<!-- registration/login.html; templates/registration/login.html -->
{% block content %}
<h2>Login</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Log in</button>
</form>
{% endblock %}
If you want the login view to use a different template:
from django.contrib.auth.views import LoginView
urlpatterns = [
path('accounts/login/', LoginView.as_view(template_name='users/login.html')),
]
This way you can name and place your template however you want. (I didn’t and that’s why it’s ugly)
And after entering my credentials:
Python Decorators:
@login_required
might look weird if you're not used to Python decorators, but once you get it, it becomes pretty straightforward.
A decorator is a special kind of function that modifies the behavior of another function - without changing it’s code. Kind of like a higher-order function. They receive a function as an argument, modify or extend it’s behavior, and return a new function.
You’ll recognize them by the @
syntax. Some common ones:
@login_required
– ensures auth@classmethod
– marks a method that belongs to the class, not the instance@staticmethod
– marks a method that doesn’t useself
orcls
@property
– turns a method into a read-only attribute
@login_required
is a decorator provided by Django that wraps a view function to restrict access to authenticated users only.
If someone isn’t logged in and tries to access that view:
Django will redirect them to the login page (by default:
/accounts/login/
)It’ll also attach the original path to the URL as
?next=...
so they’re sent back after logging in. This was why I got redirected tousers/profile
after successfully logging in.
if yes, it lets the function run.
It’s like doing:
profile_view = login_required(profile_view)
# which is basically
def login_required(func):
def wrapper(user, *args, **kwargs):
if user.is_authenticated:
return func(user, *args, **kwargs)
else:
print("Access denied. Please log in.")
return wrapper
This is how decorators work in Python.
Note: If your login page isn’t at /accounts/login/
, you can set a new one:
# settings.py
LOGIN_URL = '/login/' # or any custom route
Learn more here.
Permissions System: You also get a built-in way to restrict access:
from django.contrib.auth.decorators import permission_required
@permission_required('user.is_staff')
def report_view(request):
...
Or use user.is_superuser
, or user.has_perm()
directly. You may also use user_passes_test(attribute)
, for custom user attributes. Whatever you want, as long as it exists in your model.
Conclusion
You've just completed a comprehensive and practical journey through Django's out-of-the-box features. In this part of "Django Backend Foundations," you've gained practical, in-depth understanding of:
Django's ORM: how to interact with your database using Python objects, abstracting away complex SQL.
The Admin Interface: for quick data management and development.
User Authentication: for secure user management
Templating: building dynamic frontends directly with Django's template engine.
URL Routing: defining clean and logical pathways for your application.
Up Next: In our final installment, Part 4: Extending with Django REST Framework, we'll shift our focus to building powerful APIs. We'll explore what Django REST Framework (DRF) adds to Django and understand its essential components.
Please leave a like if this was helpful❤️. Comment also if you have questions.
Subscribe to my newsletter
Read articles from Tito Adeoye directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
