Django Backend Foundations: Your First Project and Core Structure


If you’ve been eyeing Django/DRF with a mix of curiosity and confusion, you’re in the right place.
This isn’t a crash course. It’s a foundations course — for beginners, yes, but also for anyone who wants to truly understand what’s going on under the hood when you spin up a Django backend. You’ll learn what each piece of the puzzle does and why it exists, not just how to wire it all together.
We’ll cover everything you need to build a real API: models, views, serializers, routers, permissions, and how it all fits together. If you’ve done a bit of Python, great. Or if you’ve played around with other frameworks like Adonis.js, I’ll occasionally draw parallels to help bridge the mental model — just enough to connect dots, not overwhelm.
Think of this as your warmup before diving into the deeper waters of project work. By the end, my goal is for you to feel confident not just using Django & DRF, but understanding it — so when something breaks (and something always breaks), you won’t be lost.
Let’s get into it.
What even is a Django project?
Django is a high-level Python framework that emphasizes:
Rapid development
Security out of the box, and
A clean and pragmatic design
It follows the MTV architecture (Model-Template-View), Django’s version of MVC. You define:
Models: the data structure
Templates: how data gets displayed (HTML or otherwise)
Views: the logic that connects everything
Django encourages a clear separation of concerns and a strong project structure, making it ideal for beginners and production systems.
You don't just create a Django project; you create a project (your app container) and apps (modular components inside it).
# install django
pip install django
# Start a Django project
django-admin startproject mysite
cd mysite
# Start an app inside the project
python manage.py startapp users
Project Layout & Core Files
Here are the key files and folders you get out of the box:
manage.py
: CLI for interacting with your project. It’s a wrapper around Django’s django-admin
tool that lets you run things like:
python manage.py runserver # Start dev server
python manage.py makemigrations
python manage.py migrate # Apply migrations
python manage.py createsuperuser
Think of it as your gateway to managing and evolving the project.
Project folder(same name as project; mysite
): Contains the actual project settings and configuration.
__init__.py
: Makes the folder a Python package. It’s Python’s Package Marker. This file doesn’t do anything by itself unless you explicitly put code in it.
It just tells Python: “This folder is a package. You can import from it.”
Without it, you couldn’t do things like:
from blog.models import Post
or
from users.views import login_view
It’s required for things like:
Importing settings from
mysite/settings.py
Registering apps in
INSTALLED_APPS
Import resolution across apps
You’ll see __init__.py
in every Django app and the root project folder.
You can use it to initialize logic when the package loads, but 99% of the time, it’s just left empty.
settings.py
: your project's global config (installed apps, DB config, middleware, templates, e.t.c.). Its where Django looks to know: what database to connect to, which apps are installed, how to find templates, where to serve static files from, what middleware to apply, what domains are allowed to access your app, and a ton of other stuff.
Common variables in settings.py
include:
BASE_DIR
: This defines the root directory of your Django project (usually the outermost folder). It’s used to build paths to other folders liketemplates/
orstatic/
.BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY
: This is used for cryptographic signing — like for cookies, password resets, and tokens. Never expose it in public. In production, you load this from an environment variable.SECRET_KEY = 'your-super-secret-key'
DEBUG
:DEBUG = True # set to False in production!!
When
False
: Django hides error pages and serves static files differentlyWhen
True
: you see detailed error pages with very detailed traceback + metadata, hundreds of lines long, that you do not want out there.ALLOWED_HOSTS
:ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'yourdomain.com']
This is a list of sites on which your Django app is accessible. Django uses this to prevent Host header attacks. If someone sends a request from an unknown domain, it’ll be rejected.
The values can be complete domain names e.g. www.yourdomain.com, subdomain wildcards e.g .example.com, or a wildcard, *, to match anything.
By default, it’s an empty list (
[]
). WhenDEBUG = True
(development), ifALLOWED_HOSTS
is empty, Django automatically validates against['.localhost', '127.0.0.1', '[::1]']
. This allows you to develop locally without needing to set it explicitly.When
DEBUG=False
, settingALLOWED_HOSTS
becomes compulsory, as Django will refuse to serve any requests and you respond with a 400 Bad Request error.INSTALLED_APPS
: This tells Django what apps to look for when resolving models, templates, or routes and which apps should be registered with migrations, the admin panel, etc. You must add every app you create here.INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', ..., 'users', # your app ]
MIDDLEWARE
:MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', ... ]
Middleware are functions that run before and after every request, that alter Django’s input or output. They handle things like: setting up the session, protecting against CSRF, modifying the response, etc.
Django provides its own built-in middleware components e.g.
AuthenticationMiddleware
but you can write your own custom middleware too.ROOT_URLCONF
: This points to your rooturls.py
file — the entry point for all routing.ROOT_URLCONF = 'mysite.urls'
TEMPLATES
TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', # built-in template backend 'DIRS': [BASE_DIR / 'templates'], # global templates folder 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, } ]
This config tells Django where to look for
.html
files if you’re using the templating engine.OPTIONS
: These are extra parameters to pass to the template backend.DIRS
: list of folders with templates. Initial value =>[]
APP-DIRS
: You can also keep per-app templates inapp_name/templates/app_name/
and Django will find them when you set APP_DIRS toTrue
. Initial value =>True
blog/ ├── templates/ │ └── blog/ │ ├── post_list.html │ └── post_detail.html
How Django Loads Templates (Behind the Scenes)
You call
render(request, "some_template.html")
.Django looks in the folders specified in
DIRS
.If it can’t find it, and
APP_DIRS=True
, Django looks in:app_name/templates/app_name/
If still not found, raises
TemplateDoesNotExist
.
WSGI_APPLICATION
&ASGI_APPLCATION
WSGI_APPLICATION = 'mysite.wsgi.application' ASGI_APPLICATION = 'mysite.asgi.application'
This is the Python import path to your WSGI app. Web servers like Gunicorn and Uvicorn use this to start the app.
DATABASES
:DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } }
You define the engine (e.g., SQLite, PostgreSQL) and connection credentials here. You can switch to PostgreSQL by updating this.
AUTH_PASSWORD_VALIDATORS
: Used to enforce rules when users create or change passwords. These validators help make passwords more secure.AUTH_PASSWORD_VALIDATORS = [ { # prevents passwords that are too similar to the user's info (like username, email). 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { # default is 8 characters; you can set a custom length. 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': { # sets custom minimum length to 12 'min_length': 12, } }, { # blocks passwords that are too common (like “password123”). 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { # prevents passwords that are all numbers (e.g., 12345678). 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, # go crazy. create your own custom validator { 'NAME': 'users.validators.CustomPasswordValidator', # you have to create a validators file with a CustomPasswordValidator class 'OPTIONS': { 'min_length': 10, 'max_length': 20, } }, ]
CustomPasswordValidator
class should look something like this:# users/validators.py --> I import re from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ class CustomPasswordValidator: def __init__(self, min_length=8, max_length=20): self.min_length = min_length self.max_length = max_length def validate(self, password, user=None): if len(password) < self.min_length or len(password) > self.max_length: raise ValidationError( _(f"Password must be between {self.min_length} and {self.max_length} characters long."), code='password_length', ) if not re.search(r'[A-Z]', password): raise ValidationError( _("Password must contain at least one uppercase letter."), code='password_no_upper', )
How do we utilize this? In a real-world Django API setup, you usually check the password in your view (or serializer, if you're using Django REST Framework). We’ll get there soon.
For a comprehensive list of settings.py
configurations, visit here.
Bonus: How to Override per Environment
You don’t want the same settings in dev and prod. Common pattern:
# settings.py
from .base import *
try:
from .local import *
except ImportError:
pass
Then, in your settings/
, split your settings across:
base.py
local.py
(for dev)prod.py
(for production)
Or you could have two settings files, one for local and production which have local defaults which can be overriden by env. vars. and a second one for your test environment.
There are multiple ways to define your settings variables, across your chosen environments, choose what works best for you.
urls.py
: the app/root-level URL router — where you include app routes. You can think of it as Django’s map. It connects URLs (incoming HTTPS paths) to views (code that handles them).
There are two main types of urls.py:
Root-level — in your project folder (e.g.
mysite/urls.py
)App-level — inside each app (e.g.
blog/urls.py
)
Root urls.py
This is the entry point. It's where you register global routes or delegate to app routes:
# mysite/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('blog/', include('blog.urls')), # 👈 delegating to app (blog)
]
So, when someone visits /blog/
, Django hands off routing to blog/urls.py
.
App-level urls.py
You define the actual views for that app:
# blog/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.blog_list, name='blog_list'),
path('<int:id>/', views.blog_detail, name='blog_detail'),
]
# in blog/views.py; here you can access the param value
def blog_detail_view(request, id): # id is the value stored in <int:id>
...
This setup allows each app to manage its own routes — much like a modular Router
in Express or Route.group
in AdonisJS
.
You use angle brackets to capture URL route parameter values (also called path parameter) e.g. blog/21
, where 21
is the parameter value. You can add converter types as well to capture certain parameter value types. If no converter type is provided, the string made available, is matched ie. blog/ss
will direct you to the blog_detail
view if the int
converter type is not made available, and may break, depending on your view’s logic.
Other default path converters include str, slug, uuid, and path
. You can also register custom path converters in your URLconf
using register_converter()
.
Regular expressions may also be used to define URL patterns, with re_path
from django.urls import re_path
from . import views
urlpatterns = [
re_path(r'^articles/(?P<year>[0-9]{4})/$', views.article_year),
]
^articles/
— the URL must start witharticles/
(?P<year>[0-9]{4})
— captures a 4-digit number as a named groupyear
$
— ensures the URL ends after the year
And in views.py
,
# views.py
from django.http import HttpResponse
def article_year(request, year):
return HttpResponse(f"Articles from year: {year}")
Match a slug:
re_path(r'^blog/(?P<slug>[-a-zA-Z0-9_]+)/$', views.blog_detail)
Match an ID:
re_path(r'^item/(?P<id>\d+)/$', views.item_detail)
Optional parameters (use with caution):
re_path(r'^archive(/(?P<year>[0-9]{4}))?/$', views.archive)
Here are some helpful notes to have:
Use
re_path()
only whenpath()
(which uses simpler converters like<int:id>
) isn't enough.💡If you don’t absolutely need it, don’t use it. Not every dev on your team is going to know how to work regular expressions. It’s ugly, hard to read and 95% of the time, is not needed so it should be utilized for only edge cases (Yes, I’m biased🫠)Named groups (
(?P<name>...)
) are passed to the view as keyword arguments.Avoid overly complex regex when possible—maintain readability.
Regex cheat sheet here.
You may also access query parameters (?key=value
pairs at the end of a URL).
Example:
/search/?q=django&page=2 # q => key; django => value
Only the /search/
part is matched by your URL pattern.
# users/urls.py
from django.urls import path
from .views import search_view
urlpatterns = [
# ...,
path('search/', search_view),
]
# users/views.py
def search_view(request):
query = request.GET.get('q', '') # django; fallback to an empty string if empty
page = int(request.GET.get('page', 1)) # 2; type coercion + fallback to 1 if empty
# do something with the query params
print(f"Query: {query}, Page: {page}") # Query: django, Page: 2
return render(request, 'search.html', {'query': query, 'page': page})
request.GET
pulls everything after the?
. It will always return values as stringsUse
.get('key', default)
to avoidNone
issuesYou don’t use
<str:q>
or similar in the URL — that's for path parameters, not query params.
asgi.py
and wsgi.py
: Entry points for asynchronous and synchronous deployments.
WSGI (Web Server Gateway Interface_ is the standard Python interface between web servers and web apps. It is synchronous and designed for traditional request-response web apps.
When you're deploying your Django app, your Python code doesn't serve the app directly. Instead, it needs a web server (like Gunicorn or uWSGI) to talk to the outside world (browsers, APIs, etc.). But that server doesn’t know how to “speak Django” — it needs an interface, which is exactly what wsgi.py
is - the bridge between your server and your Django code.
Inside wsgi.py
, you’ll see something like this:
"""
WSGI config for mysite project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
application = get_wsgi_application()
Here’s what this does:
Sets up Django’s settings
Creates a callable object named
application
that the WSGI server (Gunicorn or uWSGI) uses to send HTTP requests into Django.This
application
is like an inbox that receives every HTTP request, runs it through Django (middleware → views → response), and sends it back to the client.
Let’s say you're deploying your app using Gunicorn, a popular Python WSGI server:
gunicorn mysite.wsgi:application
That line says:
“Hey Gunicorn, use the
application
object inside themysite/wsgi.py
file.”
You can check this out for more information on WSGI, uWSGI deployment and Gunicorn deployment.
To spin up your web server locally, you run this command:
python manage.py runserver
Behind the scenes, Django's runserver
uses wsgi.py
(or asgi.py
if you're doing async) as the entry point to your app — just like in production — but it wraps it with a built-in development server. Which is why you get the warning at the end when you run it:
How it all connects:
[Browser] → HTTP → [Django Dev Server (runserver)] → [WSGIHandler] → [Your Django App(your views, urls, etc)]
ASGI (Asynchronous Server Gateway Interface) is the newer standard that supports asynchronous Python, like async def
, await
, long-lived WebSockets, background tasks, etc. It is required if your app needs real-time features like chat, streaming, push notifications etc.
As it supports asynchronous operations, it can take a lot of requests (calls) at once and handles each without waiting for one to finish.
Here’s what a basic asgi.py
looks like:
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
application = get_asgi_application()
Same idea: it's a callable object (application
) but for ASGI servers like Uvicorn or Daphne.
You’d run:
uvicorn mysite.asgi:application
Now the server can send async requests through Django.
Let’s say you were building a live chat app,
Your message history, channels, and user profiles → classic sync views → use
wsgi.py
But your real-time chat? Needs WebSockets — which means
asgi.py
and ASGI.
You’d install Django Channels to extend asgi.py
with WebSocket handling.
import os
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from chat.routing import websocket_urlpatterns
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": URLRouter(websocket_urlpatterns),
})
Now your Django app can handle both HTTP and WebSocket traffic.
You choose your deployment strategy: traditional (wsgi.py
) or async-capable (asgi.py
). Many hosting platforms (Heroku, etc.) still default to WSGI unless you explicitly set up ASGI.
templates/
and static/
: You create these folders yourself when needed. They're not included by default.
templates/
Used for storing HTML templates. Like we discussed earlier, you can create a global templates/
folder and link it in your settings.py
, or each app can have its own.
# settings.py
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
...
},
]
static/
Used for static assets like CSS, JS, and images.
{% load static %}
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
Again, either global or app-level.
Conclusion
In this opening part of the Django Backend Foundations series, you've learned:
What Django is at its core and its philosophy.
How to set up your first Django project from scratch.
The purpose of some key project files like
settings.py
, andmanage.py
and folders like templates and static.The fundamental roles of ASGI and WSGI in serving your Django application.
Up Next: In Part 2: Building Blocks - Apps, Models, and Views, we'll start populating our newly created project. We'll explore the concept of Django "apps", the roles of Models (how Django interacts with your database), and understand how Views process requests and deliver responses. You'll begin to see how Django constructs the core logic of your application.
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
