How to Understand a Django Project: Settings to Signals

A step-by-step route through an unfamiliar Django codebase, with the manage.py commands that reveal its structure and the places behavior hides.

By The OpenVisio Team · · 10 min read

Django is one of the more readable frameworks to inherit, because it has strong conventions: every project has settings, a root URLconf, a list of installed apps, and each app tends to have models.py, views.py, urls.py and admin.py. If you know where to look, a Django codebase tells you a lot about itself before you read any business logic.

The catch is that Django also offers several ways to run code at a distance. Middleware touches every request, signals fire when models are saved, AppConfig.ready() runs at startup, custom managers change what Model.objects returns, and management commands and Celery tasks run outside the request cycle entirely. This guide gives you an order for reading a Django project that surfaces those first, along with the manage.py commands that answer structural questions faster than reading files.

Key takeaways

  • Find the settings module first. DJANGO_SETTINGS_MODULE decides which settings are live, and many projects split settings across several files.
  • INSTALLED_APPS is the table of contents. Separate Django's own apps, third-party apps and the project's apps.
  • Read URLconfs from ROOT_URLCONF down through every include(). That gives you every public endpoint.
  • Models are the center of a Django project. Read them before views, and look at custom managers, save() overrides and Meta options.
  • Check middleware order, signal receivers and AppConfig.ready() early. They run code you will not see by reading views.
  • Use manage.py check, showmigrations, diffsettings and shell to confirm what you think you read.

Step 1: Find the project package and the settings

The root of a Django repository usually has manage.py and a project package with the same name as the project. Open manage.py; it sets the default settings module:

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local')

Also check wsgi.py, asgi.py, Dockerfiles, .env files and deployment configs, because production often overrides that default. Common layouts include a single settings.py, a settings/ package with base.py, local.py and production.py, or settings driven by environment variables via django-environ or pydantic-settings.

To see what is actually different from Django's defaults in the environment you are running, use:

python manage.py diffsettings

It prints every setting that differs from the defaults, which is a much shorter read than the settings files themselves. In settings, pay attention to INSTALLED_APPS, MIDDLEWARE, ROOT_URLCONF, DATABASES, AUTH_USER_MODEL, CACHES, CELERY_* and anything under REST_FRAMEWORK.

Confirm the project is in a sane state before you go further:

python manage.py check
python manage.py check --deploy   # production-readiness warnings

Step 2: Read INSTALLED_APPS as the table of contents

Split INSTALLED_APPS into three groups. Django's contrib apps (django.contrib.admin, auth, contenttypes, sessions) tell you which built-ins are used. Third-party apps tell you the major dependencies: rest_framework for an API, django_filters, corsheaders, allauth, celery integrations, django_extensions. The project's own apps are where the code you care about lives.

For each project app, write one line describing its responsibility. A healthy project has apps with clear names like billing, accounts, catalog. A project with one giant core app holding forty models is also common; in that case, the module structure inside core is your real map.

Note AUTH_USER_MODEL. If it is set to something like accounts.User, the project has a custom user model and every foreign key to users points there.

Step 3: Walk the URLconfs

Start at the module named in ROOT_URLCONF and follow every include():

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

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    path('accounts/', include('accounts.urls')),
    path('', include('catalog.urls')),
]

Patterns are tried in order and the first match wins, so a broad pattern near the top can shadow later ones. Older code uses url() with regexes or re_path(); newer code uses path() with converters like <int:pk> and <slug:slug>.

Reading URLconfs by hand works, but listing them is faster. The django-extensions package provides:

python manage.py show_urls

which prints every URL pattern with its view and name. If the project uses Django REST Framework, look for routers. A DefaultRouter with router.register('orders', OrderViewSet) generates list, create, retrieve, update and delete endpoints that do not appear as separate path() lines, so show_urls is especially useful there.

Step 4: Read the models first

In Django, the models are the domain. Read every project app's models.py (or models/ package) before reading views. For each model, note:

  • Fields and relationships: ForeignKey, OneToOneField, ManyToManyField, and the on_delete behavior for each.
  • Meta options: ordering (which silently affects every query), unique_together or constraints, indexes, abstract = True for base classes, proxy = True for proxy models.
  • Custom managers: objects = ActiveManager() means Model.objects may not return all rows.
  • save() and delete() overrides, which run logic on every write.
  • Properties and methods that encode business rules.

A quick way to see the model graph is django-extensions again:

python manage.py graph_models -a -g -o models.png   # needs pygraphviz or pydot

Or explore interactively. python manage.py shell (or shell_plus from django-extensions, which auto-imports every model) lets you check assumptions directly:

>>> from billing.models import Invoice
>>> [f.name for f in Invoice._meta.get_fields()]
>>> Invoice.objects.filter(status='open').query  # print the SQL

Then look at migrations. python manage.py showmigrations lists every migration and whether it has been applied. Data migrations (RunPython operations) often contain one-off business logic and history worth knowing about.

Step 5: Read views and how they are wired

Views come in a few styles, and a project often mixes them:

StyleWhat to look for
Function-based viewsDecorators like @login_required, @require_POST, @transaction.atomic
Class-based viewsThe base class (ListView, UpdateView), get_queryset(), get_context_data(), mixins like LoginRequiredMixin
DRF APIView / ViewSetpermission_classes, authentication_classes, serializer_class, get_queryset(), perform_create()

With class-based views and DRF, the behavior is spread across the class hierarchy. When a view seems to do nothing, it is usually inheriting everything. Check the method resolution order to see where a method comes from:

>>> from orders.views import OrderViewSet
>>> [c.__name__ for c in OrderViewSet.__mro__]

In DRF projects, serializers carry a lot of logic: validation in validate_<field>() and validate(), nested writes in create() and update(), and computed fields via SerializerMethodField. Treat serializers.py as part of the view layer.

Also read forms.py for HTML views, since clean() methods hold validation, and admin.py, because the Django admin is often the internal back office and can contain custom actions that change data.

Step 6: Check middleware, signals and startup code

These three are where Django code runs without being called from where you are reading.

Middleware. MIDDLEWARE in settings is an ordered list. On the way in, requests pass through it top to bottom; on the way out, responses pass bottom to top. Project middleware commonly sets the current tenant, enforces auth on certain paths, logs requests or adds headers. If a request behaves differently from what the view says, read the custom middleware.

Signals. Find every receiver:

grep -rn "@receiver\|\.connect(" --include=*.py . | grep -v migrations

post_save, pre_save, post_delete and m2m_changed receivers run whenever a model is written, from anywhere, including the admin and management commands. They are a frequent source of surprises: sending emails, updating denormalized counters, creating related objects. Note that QuerySet.update() and bulk_create() do not fire save signals, which is itself a common bug.

AppConfig.ready(). Signals usually get registered by importing a signals module inside the app's AppConfig.ready() in apps.py. Anything else in ready() runs at startup for every process: workers, web servers, tests and management commands alike.

Step 7: Find work outside the request cycle

Not all of a Django project runs in response to HTTP.

Management commands live at <app>/management/commands/<name>.py and are invoked as python manage.py <name>. List them:

python manage.py help   # commands grouped by app
find . -path '*/management/commands/*.py' -not -name '__init__.py'

Each one defines a Command(BaseCommand) class with a handle() method. These are often cron jobs, imports, backfills and maintenance scripts, and they frequently encode important operational knowledge.

Background tasks. Look for tasks.py files and @shared_task or @app.task decorators (Celery), or django-rq, Dramatiq or Huey equivalents. Then find where tasks are enqueued with .delay() or .apply_async(). Celery beat schedules, in settings as CELERY_BEAT_SCHEDULE or in the database via django-celery-beat, tell you what runs periodically.

Cron and deployment. Check Procfile, Kubernetes CronJobs, or a crontab file for anything that calls manage.py.

Step 8: Trace one request end to end

With the map in place, pick one important endpoint, such as creating an order, and trace it: URL pattern, view or viewset, permission checks, serializer validation, model save(), signal receivers that fire, tasks enqueued, and the response. Running it with the debug toolbar (django-debug-toolbar) or logging SQL with django.db.backends set to DEBUG shows every query the request makes, which is often where you discover N+1 patterns and missing select_related() or prefetch_related() calls. The general approach is in tracing a request through a web app.

A first-day checklist

  1. Find DJANGO_SETTINGS_MODULE for each environment and run diffsettings.
  2. Run check and showmigrations.
  3. Group INSTALLED_APPS and describe each project app in one line.
  4. List URLs with show_urls and note DRF routers.
  5. Read every model, including managers, Meta and save() overrides.
  6. Read custom middleware in order.
  7. List signal receivers and read each app's AppConfig.ready().
  8. List management commands and Celery tasks, and find their schedules.
  9. Trace one write request with SQL logging on.

If the repository is a monorepo with several Django services or shared packages, navigating a Python monorepo covers how import paths and package boundaries work there. For a visual overview of which apps import which, Codebase Knowledge by OpenVisio can index the project in your browser and show the app-level dependency graph in Explore, which is a quick way to spot an app that everything else depends on.

DjangoPythonweb frameworkscode reading

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading