Filter concepts by levelShowing all levels.

Django · Section 2

Django Project Structure

Level
beginner
Read
46 min
Concepts
15

What every file and directory startproject and startapp generate is actually for — the project-level entry points (manage.py, settings.py, urls.py, asgi.py, wsgi.py), an app's own anatomy (models.py, views.py, admin.py, tests.py, urls.py), and the three discoverable directories (migrations/, templates/, static/) plus one hand-added one (management/commands/).

This section

What is true here

  1. manage.py is django-admin with DJANGO_SETTINGS_MODULE pre-set — same commands, no flag needed.
  2. settings.py, the root urls.py, asgi.py and wsgi.py all live in the project's inner package, beside each other.
  3. Every app has one models.py, one views.py, one admin.py, one tests.py — other files import FROM these, never the reverse.
  4. migrations/, templates/, and static/ are all discovered automatically per-app; management/commands/ is the one hand-added exception.
  5. app_name namespaces an app's own urls.py; templates/<app>/ and static/<app>/ namespace the other two discoverable directories the same way.

What you will be able to do

  • Explain what manage.py automates that plain django-admin does not
  • Locate any of the five project-level files without checking a reference
  • Explain why an app's own urls.py needs app_name, and templates/static need the app-name subfolder
  • Generate a migration from a model change and explain why an applied one should never be deleted
  • Lay out a custom management command including both required __init__.py files

Project entry points

The three files every project has exactly one of, all living beside each other in the inner package.

manage.py

corebeginner

manage.py wraps django-admin, generated once by startproject. It auto-sets DJANGO_SETTINGS_MODULE, so `python manage.py runserver` needs no flags but bare `django-admin runserver` does.

Think of it as

django-admin is a generic tool that works on any Django project, as long as you tell it which one — manage.py is that same tool with the project already picked for you, baked in at generation time. It's the difference between a universal remote you have to point and code each time, versus one paired permanently to a single TV.

bash
python manage.py runserver
python manage.py migrate
python manage.py shell
python manage.py test

What we're doing: Compare running a command via manage.py against the equivalent django-admin invocation, to see exactly what manage.py automates.

terminalbash
python manage.py migrate

# without manage.py, the same command needs settings spelled out:
DJANGO_SETTINGS_MODULE=mysite.settings django-admin migrate
1
manage.py already knows this project is mysite — no environment variable or flag needed.
4
django-admin has no project of its own — it needs to be told which settings module to use every time.

Why this works: manage.py exists so a project is self-contained and runnable without remembering or re-typing which settings module it uses — the exact same convenience `settings`' own DJANGO_SETTINGS_MODULE mechanism provides, just pre-wired at project-generation time instead of set by hand.

Editing manage.py to add custom logic

Wrong

python
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys

# custom logic added directly here — hard to reuse, easy to forget
if os.environ.get("MAINTENANCE_MODE"):
    print("Site is down for maintenance")
    sys.exit(1)

def main():
    ...

Better

python
# leave manage.py as generated — put custom startup logic in a
# management command, a signal, or AppConfig.ready() instead

What you see: Custom logic in manage.py silently does not run when the project is deployed via a WSGI/ASGI server (gunicorn, uvicorn) instead of manage.py — those entry points never execute manage.py at all.

Why: manage.py is only one of several entry points into a Django project — wsgi.py and asgi.py are the other two, used in production, and neither one runs manage.py's code. Logic that must run regardless of entry point belongs somewhere all three paths reach, like AppConfig.ready().

manage.py as a pre-configured django-admin

django-admin

generic, needs --settings

startproject

generates manage.py

manage.py

settings already wired in

  1. django-admin — generic, needs --settings
  2. startproject — generates manage.py
  3. manage.py — settings already wired in

manage.py vs. plain django-admin

manage.py vs. plain django-admin
Propertymanage.pydjango-admin
Settingsauto-set to this projectmust pass --settings or set env var
Scopeone specific projectany project, system-wide install
Typical useday-to-day project commandsswitching between several projects

Together

bash
python manage.py runserver
# vs the equivalent, spelled out:
DJANGO_SETTINGS_MODULE=mysite.settings django-admin runserver

Remember: manage.py = django-admin with DJANGO_SETTINGS_MODULE pre-set to this project — same commands, no --settings flag needed.

See also: settings · settings py · wsgi py

settings.py

standardbeginner

settings.py lives inside the project's inner package (mysite/mysite/settings.py), generated once by startproject. It is the one file every other part of the project — models, middleware, templates — ultimately configures itself against.

Think of it as

If manage.py is the project's remote control, settings.py is the instruction manual every button in that remote refers back to — INSTALLED_APPS decides which apps exist, MIDDLEWARE decides what runs on every request, DATABASES decides where models actually store data. Nothing elsewhere in the project defines these independently; they all point at this one file.

text
mysite/
    manage.py
    mysite/
        __init__.py
        settings.py   # <- here
        urls.py
        asgi.py
        wsgi.py

What we're doing: Split a single settings.py into a base/development/production layout as a project grows past one environment.

config/settings/production.pypython
from .base import *   # everything shared: INSTALLED_APPS, MIDDLEWARE, ...

DEBUG = False
ALLOWED_HOSTS = ["example.com"]
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", ...}}
1
from .base import * pulls in every setting common to all environments, so production only has to state what differs.
4
DEBUG and ALLOWED_HOSTS are overridden here — production.py wins because it is imported last, after base's values are already in scope.

Why this works: A single settings.py works fine for a small project, but DEBUG/DATABASES/ALLOWED_HOSTS genuinely need different values per environment — splitting into a base file plus one override file per environment keeps the shared 90% in one place instead of duplicated three times with tiny differences.

Committing production secrets directly into settings.py

Wrong

python
# settings.py, committed to version control
SECRET_KEY = "django-insecure-actual-real-key-abc123"
DATABASES = {"default": {"PASSWORD": "hunter2", ...}}

Better

python
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DATABASES = {"default": {"PASSWORD": os.environ["DB_PASSWORD"], ...}}

What you see: Secrets leak to anyone with repository access, and rotating a compromised key requires a code change and redeploy instead of just updating an environment variable.

Why: settings.py is plain Python (see `django.fundamentals.settings`), which means it can read from os.environ just as easily as it can hard-code a literal — reading secrets from the environment keeps them out of version control entirely, at essentially no extra cost.

Where settings.py sits, project vs. large-project layout

Where settings.py sits, project vs. large-project layout
LayoutLocation
startproject defaultmysite/mysite/settings.py — one file
Large-project splitconfig/settings/base.py + development.py/production.py

Together

python
# base.py — shared by every environment
INSTALLED_APPS = [...]

# production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = ["example.com"]

Remember: settings.py sits at mysite/mysite/settings.py — INSTALLED_APPS, MIDDLEWARE, and DATABASES all live here; large projects split it into base/dev/production files.

See also: settings · manage py · urls py

urls.py (project)

standardbeginner

The project's root urls.py lives beside settings.py and is what ROOT_URLCONF points at. In a well-structured project it stays short — mostly include() calls delegating to each app's own urls.py, not a flat list of every route in the site.

Think of it as

The root urls.py is a building's lobby directory, not a floor-by-floor map of every office. It says "billing → 3rd floor" and "support → 5th floor" (include("billing.urls"), include("support.urls")) and lets each floor's own directory (each app's urls.py) handle the detail from there — the lobby directory doesn't need updating every time an office rearranges its own rooms.

python
# mysite/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("billing/", include("billing.urls")),
]

What we're doing: Keep the root urls.py stable as new apps are added — each app owns its own prefix and its own urls.py.

mysite/urls.pypython
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("billing/", include("billing.urls")),
    path("support/", include("support.urls")),
]
7
Adding the "support" app to the project means adding exactly one line here — the app's own routes never need to be listed at this level.

Why this works: This is what project vs. application means at the routing layer specifically: each app is meant to be a self-contained, pluggable unit, and a root urls.py that only does include() per app keeps that boundary intact — an app can be dropped in or removed by changing one line, not by hunting through a long flat list of routes.

Listing every app's individual routes directly in the root urls.py

Wrong

python
# mysite/urls.py — every app's routes flattened into one file
urlpatterns = [
    path("billing/invoices/", billing_views.invoice_list),
    path("billing/invoices/<int:pk>/", billing_views.invoice_detail),
    path("support/tickets/", support_views.ticket_list),
    # ... grows without bound as the project grows
]

Better

python
urlpatterns = [
    path("billing/", include("billing.urls")),
    path("support/", include("support.urls")),
]

What you see: The root urls.py grows to hundreds of lines and imports views from every app in the project, coupling it to internals that should stay inside each app.

Why: Once every app's routes are inlined at the root, adding or removing an app means editing this shared file instead of touching only that app's own directory — the exact coupling include() exists to avoid, and the same reusability argument project vs. application makes for apps in general.

What typically lives in the root urls.py vs. an app's own

What typically lives in the root urls.py vs. an app's own
PropertyProject urls.pyApp urls.py
Holdsinclude() calls + adminthe app's actual path() patterns
Changes whena new app is addedthat app's routes change
ROOT_URLCONF points atthis file(not directly — reached via include())

Together

python
# mysite/urls.py — the root
urlpatterns = [
    path("admin/", admin.site.urls),
    path("billing/", include("billing.urls")),
    path("support/", include("support.urls")),
]

Remember: The root urls.py (ROOT_URLCONF) stays short — mostly include() per app — while each app's own urls.py holds its actual routes.

See also: url configuration · app urls py · settings py

Advertisement

Deployment and app configuration

The two production server entry points, and the file introducing an app to the registry.

asgi.py

standardintermediate

asgi.py, generated by startproject, exposes one `application` callable that ASGI servers (uvicorn, daphne) use to talk to your Django project. It is not used by runserver in development — it exists for async-capable production deployment.

Think of it as

asgi.py is a labeled door on the outside of the building for a specific kind of visitor — an ASGI server walks up, calls the `application` callable behind that door, and everything inside (routing, views, middleware) runs from there. runserver, by contrast, uses its own internal door during development and never touches this file at all.

bash
uvicorn mysite.asgi:application

What we're doing: See the default asgi.py content and how an ASGI server is pointed at it.

mysite/asgi.pypython
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
application = get_asgi_application()
4
The settings module is resolved the same way manage.py resolves it, just set directly here instead of auto-detected.
5
application is the ASGI callable itself — this is the exact name an ASGI server's "module:callable" reference points at.

Why this works: ASGI servers are generic — they can serve any ASGI-compliant Python application, not just Django — so they need a predictable, documented name to import and call. `application` in asgi.py is that fixed contract point, the same role wsgi.py's `application` plays for synchronous deployment.

Assuming asgi.py alone makes views run asynchronously

Wrong

text
"I deployed behind uvicorn using asgi.py, so all my
views now run async and I can drop 'await' anywhere."

Better

text
Deploying via asgi.py makes the SERVER async-capable —
individual views still need "async def view(request):"
to actually run as coroutines.

What you see: Synchronous, blocking code inside a view still blocks the same as it always did, despite deploying through an ASGI server.

Why: asgi.py changes how the server talks to Django, not how any individual view is written — a regular `def view(request):` view still runs synchronously even under uvicorn. Getting async behavior requires actually writing `async def` views, on top of the ASGI deployment this file enables.

asgi.py at a glance

asgi.py at a glance
AspectValue
Locationmysite/mysite/asgi.py
Exposesa module-level `application` callable
Used byASGI servers — uvicorn, daphne, hypercorn
Referenced as"mysite.asgi:application"

Together

python
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
application = get_asgi_application()

Remember: asgi.py exposes an `application` callable for ASGI servers (uvicorn, daphne) — used in production, not by runserver, and required for real async view support.

See also: wsgi py · manage py · settings py

wsgi.py

standardintermediate

wsgi.py, generated by startproject, exposes an `application` callable that WSGI servers (gunicorn, uWSGI) use in production — the synchronous counterpart to asgi.py.

Think of it as

If asgi.py is the async-capable door into the building, wsgi.py is the traditional, always-been-there door — most production Django still runs behind a WSGI server (gunicorn is the most common), so this file, not asgi.py, is usually the one actually wired up to the outside world in a typical deployment.

bash
gunicorn mysite.wsgi:application

What we're doing: Deploy a project behind gunicorn, pointing it at wsgi.py's application callable.

terminalbash
gunicorn mysite.wsgi:application --bind 0.0.0.0:8000
1
mysite.wsgi:application resolves to the module-level `application` object generated in wsgi.py — the same fixed contract every WSGI server expects.

Why this works: gunicorn (and every other WSGI server) is generic — built to run any WSGI-compliant Python app, not specifically Django — so it needs a predictable name to import and call. wsgi.py's job is entirely to expose that one name in the shape a WSGI server expects.

Running gunicorn against manage.py or the project package directly

Wrong

bash
gunicorn mysite:application    # wrong module — no application here
gunicorn manage:application    # manage.py has no WSGI application object either

Better

bash
gunicorn mysite.wsgi:application

What you see: ModuleNotFoundError, or AttributeError: module 'mysite' has no attribute 'application'.

Why: The `application` callable lives specifically in the wsgi module generated for exactly this purpose — neither the project package's __init__.py nor manage.py defines one, so pointing gunicorn at anything else fails to find the object it needs.

wsgi.py at a glance

wsgi.py at a glance
AspectValue
Locationmysite/mysite/wsgi.py
Exposesa module-level `application` callable
Used byWSGI servers — gunicorn, uWSGI, mod_wsgi
Referenced as"mysite.wsgi:application" (gunicorn) or "mysite.wsgi.application"

Together

python
import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
application = get_wsgi_application()

Remember: wsgi.py exposes an `application` callable for WSGI servers (gunicorn, uWSGI) — the traditional, synchronous production deployment path, distinct from asgi.py.

See also: asgi py · manage py · settings py

App apps.py

standardbeginner

apps.py is generated by startapp inside every app. It defines one AppConfig subclass carrying the app's metadata (name, verbose_name) and its ready() hook.

Think of it as

If models.py is what an app HAS and views.py is what it DOES, apps.py is the app introducing itself — its formal name, a human-readable label, and one hook (ready()) for anything that needs to run once the whole app is loaded. It's the ID badge every app wears, generated once and rarely touched by hand afterward.

python
# <app>/apps.py
from django.apps import AppConfig

class MyAppConfig(AppConfig):
    name = "myapp"

What we're doing: Give an app a human-readable label in the admin, and confirm it does not need to be added anywhere beyond INSTALLED_APPS.

billing/apps.pypython
from django.apps import AppConfig

class BillingConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "billing"
    # without verbose_name, the admin would show "Billing" (title-cased "billing")
    verbose_name = "Billing & Invoices"
5
name = "billing" must match the app's actual Python path — this is the value Django uses to find and load the app.
7
verbose_name only affects display (e.g. the admin index) — it never changes how the app is imported or referenced elsewhere.

Why this works: BillingConfig does not need to be registered anywhere beyond INSTALLED_APPS = ["billing", ...] — Django's app-loading process automatically discovers the AppConfig subclass in an app's apps.py (or uses a generated default if none is customized), the same auto-discovery `django.fundamentals.app-registry` describes for the registry as a whole.

Setting name to a value that doesn't match the app's actual import path

Wrong

python
# apps/billing/apps.py — but the app is really "apps.billing"
class BillingConfig(AppConfig):
    name = "billing"   # wrong — missing the "apps." prefix

Better

python
# apps/billing/apps.py
class BillingConfig(AppConfig):
    name = "apps.billing"   # matches the real dotted import path

What you see: django.core.exceptions.ImproperlyConfigured: Cannot import 'billing'. Check that 'apps.billing.apps.BillingConfig.name' is correct.

Why: name has to be the exact dotted path Python would use to import the app — when a project nests apps under a parent package (apps/billing/ rather than billing/ at the root), the app's real path includes that prefix, and apps.py has to say so explicitly.

AppConfig attributes commonly set in apps.py

AppConfig attributes commonly set in apps.py
AttributePurpose
namethe app's full Python path — required
verbose_namehuman-readable label, shown in the admin
default_auto_fieldprimary key type for this app's models
ready()method — runs once, after the full registry is populated

Together

python
# billing/apps.py
from django.apps import AppConfig

class BillingConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "billing"
    verbose_name = "Billing & Invoices"

Remember: apps.py holds one AppConfig subclass per app — name is required and must match the real import path; ready() is where signal registration belongs.

See also: app registry · signals · app models py

Advertisement

App anatomy

The three files nearly every app has, and how they refer to each other.

App models.py

corebeginner

models.py is generated empty by startapp, one per app. Every model class goes here — the single file makemigrations reads, and the file every other module (views.py, admin.py) imports from.

Think of it as

Every app has exactly one models.py, the same way every person has exactly one birth certificate — it is the authoritative record of what data shapes this app owns. views.py, admin.py, and every other file in the app import FROM models.py; nothing imports data shape definitions the other way.

python
# <app>/models.py
from django.db import models

class MyModel(models.Model):
    name = models.CharField(max_length=100)

What we're doing: Add a model to a fresh app's models.py, then see how views.py and admin.py both import it independently.

billing/models.pypython
from django.db import models

class Invoice(models.Model):
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    paid = models.BooleanField(default=False)

# billing/views.py:      from .models import Invoice
# billing/admin.py:      from .models import Invoice
# billing/tests.py:      from .models import Invoice
4
Invoice is defined exactly once here — every other file in the app reaches it via the same relative import, `from .models import Invoice`.
7
Three different files import the same class for three different purposes — querying, admin registration, and testing — none of them redefine it.

Why this works: Having one models.py per app is what makes `python manage.py makemigrations billing` unambiguous — Django knows exactly which file to compare against the database's current migration state for that app, and every other file that needs Invoice imports the same single definition rather than each declaring its own.

Defining the same conceptual model in two different apps' models.py files

Wrong

python
# billing/models.py
class Customer(models.Model):
    name = models.CharField(max_length=100)

# support/models.py — a SEPARATE, unrelated Customer
class Customer(models.Model):
    name = models.CharField(max_length=100)

Better

python
# customers/models.py — one shared app owns Customer
class Customer(models.Model):
    name = models.CharField(max_length=100)

# billing/models.py and support/models.py both import it:
# from customers.models import Customer

What you see: Two separate database tables (billing_customer and support_customer) with no relationship to each other, even though they represent the same real-world entity.

Why: Each app's models.py defines its OWN tables — Django has no mechanism to merge two same-named classes in different apps into one table. A model genuinely shared across apps belongs in one app (or a dedicated shared app) that the others import from, not duplicated.

models.py as the app's single source of data shape

models.py

defines Invoice

views.py

imports and queries

admin.py

imports and registers

  1. models.py — defines Invoice
  2. views.py — imports and queries
  3. admin.py — imports and registers

What imports from models.py, and why

What imports from models.py, and why
FileImports models.py for
views.pyquerying and creating rows to build a response
admin.pyregistering a model with admin.site.register()
tests.pycreating test fixtures with Model.objects.create()
migrations/not imported directly — makemigrations reads this file to generate them

Together

python
# billing/models.py
from django.db import models

class Invoice(models.Model):
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    paid = models.BooleanField(default=False)

Remember: One models.py per app, generated empty — every model class an app owns goes here; makemigrations reads this file per app to detect schema changes.

See also: models · migrations dir · app admin py

App views.py

corebeginner

views.py is generated empty by startapp. It holds every view the app defines, imports models.py for data, and is referenced by that app's own urls.py.

Think of it as

If models.py is the app's warehouse inventory, views.py is the counter staff — it decides what to fetch from the shelf (models.py) and how to hand it to the customer (a response), but it owns no stock of its own. A large app sometimes splits views.py into a views/ package, the same way models.py can split into a models/ package.

python
# <app>/views.py
from django.shortcuts import render
from .models import MyModel

def my_view(request):
    return render(request, "app/template.html", {})

What we're doing: Add a view that queries the app's own model and renders it, then reference that view by name from urls.py.

billing/views.pypython
from django.shortcuts import render
from .models import Invoice

def invoice_list(request):
    invoices = Invoice.objects.all()
    return render(request, "billing/list.html", {"invoices": invoices})

# billing/urls.py:
# from . import views
# urlpatterns = [path("", views.invoice_list, name="list")]
2
from .models import Invoice — a relative import, since views.py and models.py always live in the same app directory.
9
billing/urls.py references views.invoice_list by attribute — the two files are linked only through this reference, not through any registration step.

Why this works: views.py sits deliberately between models.py and urls.py in the request flow — it is the only file in the app that both queries data AND gets referenced by a URL pattern, which is why it, not models.py or urls.py, is where request-specific logic like pagination or permission checks belongs.

Putting model-querying logic in urls.py instead of views.py

Wrong

python
# billing/urls.py — trying to skip views.py entirely
from .models import Invoice
urlpatterns = [
    path("", lambda request: HttpResponse(str(Invoice.objects.all()))),
]

Better

python
# billing/views.py
def invoice_list(request):
    invoices = Invoice.objects.all()
    return render(request, "billing/list.html", {"invoices": invoices})

# billing/urls.py
urlpatterns = [path("", views.invoice_list, name="list")]

What you see: urls.py grows real business logic mixed in with routing declarations, making both harder to test and reuse independently.

Why: urls.py exists purely to map a path to a view name — testing a lambda buried inline in urlpatterns is far harder than testing a named function in views.py, and the same view often needs reuse from more than one URL pattern, which an inline lambda cannot support.

views.py sits between models.py and urls.py

models.py

Invoice

views.py

invoice_list(request)

urls.py

path("", views.invoice_list)

  • models.py
    • Invoice
  • views.py
    • invoice_list(request)
  • urls.py
    • path("", views.invoice_list)

views.py's position between models.py and urls.py

views.py's position between models.py and urls.py
RelationshipDirection
views.py imports frommodels.py (this app's own, or another app's)
urls.py imports fromviews.py (by name, per path())
templates renderwhatever context a view in views.py passes them

Together

python
# billing/views.py
from django.shortcuts import render
from .models import Invoice

def invoice_list(request):
    invoices = Invoice.objects.all()
    return render(request, "billing/list.html", {"invoices": invoices})

Remember: views.py is generated empty per app — every view lives here, imports models from .models, and is referenced by name from that app's urls.py.

See also: views · app models py · app urls py

App admin.py

standardbeginner

admin.py is generated with one commented-out import by startapp. It is the only place a model needs to be registered for the admin site to manage it — an app with no admin.py content is simply invisible in /admin/, nothing else changes.

Think of it as

admin.py is an opt-in guest list, not an automatic feature — models.py defines what exists, but nothing shows up in the admin site until admin.py explicitly says so. An app can be fully functional (models, views, templates all working) with a completely empty admin.py; the admin site just won't know the app exists.

python
# <app>/admin.py
from django.contrib import admin
from .models import MyModel

admin.site.register(MyModel)

What we're doing: Register a newly added model so it appears in the admin site, starting from the empty file startapp generates.

billing/admin.pypython
from django.contrib import admin
from .models import Invoice

admin.site.register(Invoice)
1
The import line is the only content startapp generates by default — everything below it is added by hand.
4
admin.site.register(Invoice) is the single line separating "model exists" from "model manageable in /admin/".

Why this works: Registration in admin.py is opt-in on purpose — not every model belongs in a staff-facing management UI (a join table, a cache entry model), so Django never auto-registers anything, leaving the choice entirely to what this file explicitly lists.

Assuming a new model automatically appears in the admin

Wrong

python
# billing/models.py — new model added
class Invoice(models.Model):
    amount = models.DecimalField(max_digits=10, decimal_places=2)

# billing/admin.py — left untouched, still just the import line
from django.contrib import admin

Better

python
# billing/admin.py
from django.contrib import admin
from .models import Invoice

admin.site.register(Invoice)

What you see: /admin/ shows no trace of the new model — no error, nothing to click, easy to assume it failed silently when actually nothing was ever registered.

Why: A model existing in models.py and a model being manageable in the admin are two independent facts — makemigrations/migrate handle the first, admin.site.register() the second, and forgetting the second produces no error at all, just an absence.

admin.py before and after registering a model

admin.py before and after registering a model
StateEffect
Freshly generated (empty)model exists, invisible in /admin/
admin.site.register(Model) addedbasic CRUD interface appears in /admin/
@admin.register(Model) + ModelAdmincustomized list_display, filters, search

Together

python
# billing/admin.py
from django.contrib import admin
from .models import Invoice

admin.site.register(Invoice)

Remember: admin.py starts as one commented import; a model is invisible in /admin/ until admin.site.register(Model) explicitly lists it — no error if forgotten.

See also: admin · app models py · app apps py

Advertisement

App anatomy, continued

Testing an app in isolation, its own routing, and the record of every schema change it has made.

App tests.py

standardbeginner

tests.py is generated by startapp as the home for an app's tests. TestCase wraps each test in a transaction that rolls back afterward, so tests never leak data into each other.

Think of it as

TestCase gives every test its own sealed sandbox — data created in setUp() exists only for that one test, then the transaction rolls back as if it never happened, and the next test starts from the same clean slate. Without that isolation, test order would matter and one test's leftover data could silently break another.

python
# <app>/tests.py
from django.test import TestCase

class MyTestCase(TestCase):
    def test_something(self):
        self.assertTrue(True)

What we're doing: Write a test that creates data in setUp() and confirms it does not leak into a second, independent test.

billing/tests.pypython
from django.test import TestCase
from .models import Invoice

class InvoiceTestCase(TestCase):
    def setUp(self):
        Invoice.objects.create(amount=100, paid=False)

    def test_unpaid_invoice_exists(self):
        self.assertEqual(Invoice.objects.count(), 1)

    def test_starts_with_no_invoices(self):
        # a DIFFERENT test — setUp() runs again, fresh, before this one too
        Invoice.objects.all().delete()
        self.assertEqual(Invoice.objects.count(), 0)
4
setUp() runs before EVERY test method in the class — each test gets its own freshly created Invoice, not a shared one.
5
Invoice.objects.create(...) here is rolled back after test_unpaid_invoice_exists finishes — test_starts_with_no_invoices never sees it.

Why this works: Each test method runs inside its own database transaction that TestCase rolls back afterward, which is what makes setUp() safe to call once per test rather than once per class — without that isolation, test_starts_with_no_invoices could accidentally pass or fail depending on what ran before it, rather than on its own logic alone.

Subclassing plain unittest.TestCase for a database-touching test

Wrong

python
import unittest
from .models import Invoice

class InvoiceTestCase(unittest.TestCase):   # no transaction wrapping
    def test_create_invoice(self):
        Invoice.objects.create(amount=100)  # leaks into every later test

Better

python
from django.test import TestCase
from .models import Invoice

class InvoiceTestCase(TestCase):   # wraps each test in a rolled-back transaction
    def test_create_invoice(self):
        Invoice.objects.create(amount=100)

What you see: Tests pass individually but fail when run together, or fail depending on run order — data from one test silently persists into the next.

Why: django.test.TestCase is what wraps each test method in a transaction and rolls it back afterward — plain unittest.TestCase has no idea Django's database exists, so anything created during a test using it stays in the database for every test that runs after.

manage.py test invocation forms

manage.py test invocation forms
CommandRuns
python manage.py testevery discovered test in the project
python manage.py test billingevery test in the billing app
python manage.py test billing.tests.InvoiceTestCaseone test class

Together

python
from django.test import TestCase
from .models import Invoice

class InvoiceTestCase(TestCase):
    def setUp(self):
        Invoice.objects.create(amount=100, paid=False)

    def test_unpaid_invoice_created(self):
        invoice = Invoice.objects.get(amount=100)
        self.assertFalse(invoice.paid)

Remember: Always subclass django.test.TestCase, not plain unittest.TestCase, for database-touching tests — each test runs in its own rolled-back transaction.

See also: app models py · migrations dir · manage py

App urls.py

standardbeginner

An app's own urls.py is not generated by startapp — added by hand so its routes can be included() from the root urls.py. app_name sets a namespace so {% url %} reverses correctly even if two apps reuse a name.

Think of it as

The project's root urls.py is a building directory pointing at floors; each app's own urls.py is that floor's own internal room directory — self-contained, and reusable if the whole floor plan (the app) were ever moved into a different building (project). app_name is the floor's name on that internal directory, so 'room 3' on the billing floor never gets confused with 'room 3' on the support floor.

python
# <app>/urls.py
from django.urls import path
from . import views

app_name = "myapp"
urlpatterns = [
    path("", views.index, name="index"),
]

What we're doing: Create an app's own urls.py by hand, namespace it, and include it from the project root.

billing/urls.pypython
from django.urls import path
from . import views

app_name = "billing"
urlpatterns = [
    path("", views.invoice_list, name="list"),
    path("<int:pk>/", views.invoice_detail, name="detail"),
]
# mysite/urls.py: path("billing/", include("billing.urls"))
5
app_name = "billing" makes every name= in this file reachable as "billing:list", "billing:detail" — never bare "list" or "detail".
8
The comment shows the root urls.py side — include("billing.urls") is what makes these patterns reachable under /billing/.

Why this works: app_name exists specifically because urlpatterns' name= values are just strings, and two different apps picking the same obvious name ("list", "detail") is common — namespacing under app_name is what {% url %} and reverse() use to disambiguate which app's "list" a template actually means.

Reusing a URL name across two apps without app_name set

Wrong

python
# billing/urls.py — no app_name
urlpatterns = [path("", views.invoice_list, name="list")]

# support/urls.py — no app_name, same name
urlpatterns = [path("", views.ticket_list, name="list")]

Better

python
# billing/urls.py
app_name = "billing"
urlpatterns = [path("", views.invoice_list, name="list")]

# support/urls.py
app_name = "support"
urlpatterns = [path("", views.ticket_list, name="list")]

What you see: {% url 'list' %} resolves to whichever app's "list" URL was registered last, silently linking to the wrong page.

Why: Without app_name, every name= across the entire project shares one flat namespace — the last urlpatterns loaded with a given name simply wins, with no error to signal the collision. app_name scopes each app's names independently, the same way Python packages scope names independently of each other.

Namespacing two apps that reuse the same URL name

Namespacing two apps that reuse the same URL name
Without app_nameWith app_name
{% url 'list' %} — ambiguous if two apps both define "list"{% url 'billing:list' %} — unambiguous
reverse("list")reverse("billing:list")

Together

python
# billing/urls.py
from django.urls import path
from . import views

app_name = "billing"
urlpatterns = [
    path("", views.invoice_list, name="list"),
]

Remember: An app's urls.py is hand-added, not generated — app_name namespaces its route names so {% url 'app:name' %} stays unambiguous across apps.

See also: urls py · url configuration · app views py

migrations/

corebeginner

migrations/ is generated by startapp, one per app, holding numbered files — each a Migration class describing a schema change. makemigrations creates them; migrate applies them; commit both.

Think of it as

migrations/ is a database's change log, not a snapshot — each file is one dated diff ("add this column," "rename that table"), applied in order, the same way a series of git commits builds up to the current file state rather than one commit holding the whole history. Deleting migration files doesn't undo the database; it just deletes the record of how it got there.

bash
python manage.py makemigrations
python manage.py migrate

What we're doing: Add a field to a model, generate the migration file it produces, and apply it — seeing the three-step cycle migrations/ exists for.

terminalbash
# after adding due_date to Invoice in models.py:
python manage.py makemigrations billing
# Migrations for 'billing':
#   billing/migrations/0002_invoice_due_date.py
#     - Add field due_date to invoice
python manage.py migrate billing
2
makemigrations reads billing/models.py, compares it to the last applied migration, and writes exactly one new file describing the difference.
6
migrate billing applies that new file (and any other pending ones) to the actual database, in numeric order.

Why this works: The two-step split — generate, then apply — exists so a generated migration can be reviewed (and, if wrong, edited or deleted) before it ever touches a real database, and so the same migration file can be applied identically across a developer's laptop, staging, and production.

Deleting or editing an already-applied migration file

Wrong

bash
# 0002_invoice_due_date.py already applied in production
rm billing/migrations/0002_invoice_due_date.py   # "cleaning up"

Better

bash
# to undo an applied migration, write a NEW one that reverses it:
python manage.py makemigrations billing   # after removing the field from models.py
python manage.py migrate billing

What you see: Django's migration history table (django_migrations) still lists 0002 as applied, but the file describing what it did is gone — a fresh environment migrating from scratch fails or produces a different schema than production.

Why: Every environment (a teammate's laptop, CI, production) applies migrations by replaying these files in order from empty — deleting an already-applied one only breaks environments that have not run it yet, while doing nothing to the environments where it already ran. A schema change is undone by a new migration, not by erasing history.

From a model change to an applied migration

models.py

field added

makemigrations

writes 0002_....py

migrate

applies to the real table

  1. models.py — field added
  2. makemigrations — writes 0002_....py
  3. migrate — applies to the real table

makemigrations vs. migrate

makemigrations vs. migrate
CommandDoes
makemigrationscompares models.py to the last migration, writes a new file
migrateruns pending migration files against the actual database
makemigrations --dry-runshows what WOULD be generated, writes nothing
migrate <app> <number>migrates one app to a specific migration, forward or back

Together

bash
python manage.py makemigrations billing
python manage.py migrate billing

Remember: migrations/ holds numbered files generated by makemigrations from models.py, applied by migrate — commit them to version control, never delete an applied one.

See also: models · app models py · manage py

Advertisement

Discoverable directories

Two directories Django finds automatically per app, and one hand-added directory that needs its own package markers.

templates/

standardbeginner

Django looks for templates in each app's own templates/ (APP_DIRS, the default) plus any TEMPLATES DIRS for project-wide ones. Convention nests app templates deeper, templates/<app_name>/, to avoid collisions.

Think of it as

APP_DIRS discovery is like every app keeping its own labeled folder in a shared filing cabinet — Django checks each app's folder in turn. Without the app_name subfolder inside it, two apps' folders could each contain a file called detail.html and Django would only ever find whichever one it checked first — namespacing under the app's own name is what keeps them apart.

text
billing/
    templates/
        billing/
            list.html
            detail.html

What we're doing: Namespace an app's templates under its own name to avoid a collision with another app's identically-named file.

billing/views.pypython
from django.shortcuts import render

def invoice_detail(request, pk):
    invoice = Invoice.objects.get(pk=pk)
    # note the app-name prefix — matches templates/billing/detail.html on disk
    return render(request, "billing/detail.html", {"invoice": invoice})
5
"billing/detail.html" is not a URL path — it's the template lookup path, resolving to billing/templates/billing/detail.html on disk.

Why this works: The app-name prefix in both the on-disk path (templates/billing/) and the lookup string ("billing/detail.html") has to match, because APP_DIRS discovery merges every installed app's templates/ into one flat search space — without the prefix, a "support" app's own detail.html could shadow billing's.

Putting a template directly in templates/ instead of templates/<app_name>/

Wrong

text
billing/
    templates/
        detail.html    # no app-name subfolder

Better

text
billing/
    templates/
        billing/
            detail.html

What you see: Works fine alone, then breaks unpredictably once a second app also defines a detail.html — whichever app Django checks first silently wins for both.

Why: APP_DIRS discovery treats every installed app's templates/ directory as one shared namespace — a bare detail.html has nothing to distinguish it from another app's bare detail.html, while templates/billing/detail.html is unambiguous the moment the app-name subfolder is added.

Where Django looks for a template, in order

Where Django looks for a template, in order
SourceExample path
TEMPLATES[0]["DIRS"] (project-wide)BASE_DIR / "templates" / "base.html"
Each app's own templates/ (APP_DIRS)billing/templates/billing/detail.html

Together

python
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [BASE_DIR / "templates"],
    "APP_DIRS": True,
    ...
}]

Remember: APP_DIRS (default True) searches each app's own templates/; nest under templates/<app_name>/ to avoid two apps' files colliding in the shared search path.

See also: templates · static dir · settings py

static/

standardbeginner

Like templates/, static/ is discovered per-app automatically (via staticfiles) plus STATICFILES_DIRS for project-wide assets. Same namespacing convention: static/<app_name>/file.css.

Think of it as

static/ mirrors templates/'s exact discovery shape — one automatic per-app location, one setting for extra project-wide directories, and the same app-name-subfolder convention to avoid collisions. Learning where one lives is learning where the other lives; only the setting names (STATICFILES_DIRS vs. DIRS) and the tag ({% static %} vs. {% include %}) differ.

text
billing/
    static/
        billing/
            invoice.css
            invoice.js

What we're doing: Lay out an app's static assets namespaced the same way its templates are, then reference one from a template.

billing/templates/billing/detail.htmlhtml
{% load static %}
<link rel="stylesheet" href="{% static 'billing/invoice.css' %}">
<h1>{{ invoice.amount }}</h1>
1
{% load static %} must appear before {% static %} is used, exactly as `django.fundamentals.static-files` describes.
2
'billing/invoice.css' resolves against billing/static/billing/invoice.css — same app-name-subfolder shape as this template's own path.

Why this works: Both directories being discovered and namespaced the same way is deliberate, not coincidental — an app is meant to be a genuinely self-contained, pluggable unit (see project vs. application), and that only works cleanly if every kind of per-app asset (templates, static files, migrations) follows one consistent discovery and naming pattern.

Mixing static/ and templates/ namespacing conventions inconsistently

Wrong

text
billing/
    templates/
        billing/
            detail.html   # namespaced correctly
    static/
        invoice.css       # NOT namespaced — inconsistent with templates/

Better

text
billing/
    templates/
        billing/
            detail.html
    static/
        billing/
            invoice.css   # namespaced the same way

What you see: The project works today, then breaks the moment a second app adds a static file with the same name — the exact collision templates/<app_name>/ already avoids for templates.

Why: static/ and templates/ use the identical discovery mechanism (per-app automatic directory + a project-wide setting), so the same collision risk applies to both — there is no reason to namespace one and not the other, and every prior topic's content in this app follows the convention consistently for exactly this reason.

templates/ and static/ discovery, side by side

templates/ and static/ discovery, side by side
Propertytemplates/static/
Per-app, automaticAPP_DIRS = Truedjango.contrib.staticfiles (always on)
Project-wide settingTEMPLATES[0]["DIRS"]STATICFILES_DIRS
Namespacing conventiontemplates/<app_name>/static/<app_name>/
Merged for production by(not applicable — templates render live)collectstatic → STATIC_ROOT

Together

python
STATICFILES_DIRS = [BASE_DIR / "static"]   # project-wide, like TEMPLATES DIRS
STATIC_ROOT = BASE_DIR / "staticfiles"      # collectstatic's target

Remember: static/ discovers per-app automatically (like templates/), plus STATICFILES_DIRS project-wide — namespace under static/<app_name>/, same as templates.

See also: static files · templates dir · media files

management/commands/

standardintermediate

management/commands/ is not generated by startapp — created by hand, two levels deep, each needing its own __init__.py. Every other .py file inside commands/ becomes a discovered command.

Think of it as

This two-level directory is a folder structure Django specifically watches, the same way it watches templates/ and static/ — but unlike those, nothing is generated here by default, because not every app needs a custom command. Both management/ and management/commands/ need their own __init__.py, or Python (and therefore Django) never sees them as packages at all.

text
<app>/
    management/
        __init__.py
        commands/
            __init__.py
            <command_name>.py

What we're doing: Lay out a new custom command from scratch, including both required __init__.py files that are easy to forget.

directory layouttext
billing/
    management/
        __init__.py         # easy to forget — required
        commands/
            __init__.py      # also required
            closepoll.py     # becomes: python manage.py closepoll
2
management/__init__.py — without this, Python does not treat management/ as a package at all, and Django never looks inside it.
4
management/commands/__init__.py — the same requirement one level deeper, for the commands/ subdirectory specifically.

Why this works: Both __init__.py files are ordinary Python package markers, not Django-specific — Django's command discovery walks management.commands as a real Python package path, so if either level is missing the import simply fails silently from Django's perspective (the command just never appears), the same way any other un-packaged Python directory would be invisible to import machinery.

Forgetting one of the two required __init__.py files

Wrong

text
billing/
    management/
        commands/
            closepoll.py   # management/__init__.py is missing

Better

text
billing/
    management/
        __init__.py
        commands/
            __init__.py
            closepoll.py

What you see: `python manage.py closepoll` reports "Unknown command: 'closepoll'" even though the file clearly exists on disk.

Why: Without __init__.py at both levels, management and management.commands are not valid Python packages, so nothing inside them is importable — Django's command discovery has nothing to find, and the failure mode is "command doesn't exist" rather than any error pointing at the missing file.

The exact directory shape Django discovers commands from

The exact directory shape Django discovers commands from
PathRequired?
billing/management/__init__.pyyes — makes management/ a package
billing/management/commands/__init__.pyyes — makes commands/ a package
billing/management/commands/closepoll.pythe command file itself

Together

text
billing/
    management/
        __init__.py
        commands/
            __init__.py
            closepoll.py

Remember: management/commands/ needs __init__.py at BOTH levels (management/ and commands/) — every other .py file inside commands/ becomes a command named after itself.

See also: management commands · app apps py · manage py

Advertisement