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

Tito AdeoyeTito Adeoye
16 min read

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, raises DoesNotExist if no object is found, and MultipleObjectsReturned 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 with ForeignKey and OneToOneField relationships. It performs a SQL JOIN 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 with ManyToManyField and ForeignKey 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(): returns True if any object matches the filter, False otherwise. This is more efficient than count() > 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 to get an object based on the given parameters; if not found, it creates 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 calling save() 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.

ConceptDjango ORMAdonisJS (Lucid ORM)
Define modelsPython classes with fieldsJavaScript/TypeScript classes extending BaseModel
Create table schemaModel + migrationsModel + migrations
Query dataModel.objects.filter(...)Model.query().where(...)
Save changes.save().save()
Migrationsmakemigrations + migratenode ace make:migration + node ace migration:run
RelationshipsForeignKey, 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 to False, ensure 'DIRS' is not empty. You can point to it like this: [BASE_DIR / "templates"]. Also, ensure templates/ is in your base directory, which should be on the same level with manage.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 the INSTALLED_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 your settings.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 the path() — 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' %} and reverse('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

ConceptDjangoAdonisJS
Route fileurls.pyroutes.ts
Route defpath('users/', view)Route.get('/users', ...)
Dynamic route<int:id>:id
Group routesinclude('app.urls')Route.group(...)
Route namesname='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 like username, 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 use self or cls

  • @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 to users/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.

0
Subscribe to my newsletter

Read articles from Tito Adeoye directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tito Adeoye
Tito Adeoye