Quick Answer

Django is a full-featured Python web framework that includes an ORM, database migrations, an auto-generated admin interface, authentication, and a template engine. You build a project (the site) that contains one or more apps (features). Most of the work is defining models, wiring URLs to views, and letting Django's built-in tools handle the rest.

Project versus app

Django splits code into two levels, and the naming trips people up. A project is the whole site and its settings. An app is one feature area - a blog, a store, accounts - that could in principle be reused elsewhere.

pip install django
django-admin startproject config .
python manage.py startapp library

The first command creates config/ (settings, root URLs, WSGI and ASGI entry points) and manage.py. The second creates library/ with models.py, views.py, admin.py, and a migrations/ folder.

A new app does nothing until you list it in INSTALLED_APPS in config/settings.py:

INSTALLED_APPS = [
    # ... Django's own apps ...
    "library",
]

Forget this line and Django will not find your models, run their migrations, or load their admin registrations - with no error, just silence. It is the first thing to check when makemigrations reports "No changes detected" for a model you definitely added.

Models and the ORM

A model is a Python class that maps to a database table. Each attribute is a column:

from django.db import models

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

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
    published = models.DateField()
    price = models.PositiveIntegerField()

The ForeignKey gives you the relationship in both directions: book.author is an Author, and author.books.all() is that author's books because of related_name="books". The on_delete argument is required - it decides what happens to books when their author is deleted.

You query through the model's objects manager:

Book.objects.count()
Book.objects.filter(price__gte=450)
Book.objects.get(title="The White Tiger")
Book.objects.order_by("-published")

The double-underscore syntax price__gte means "price greater than or equal to". Querysets are lazy - no SQL runs until you iterate, slice, or call something like count() or list().

Migrations track schema changes

You never write CREATE TABLE or ALTER TABLE by hand. Django generates schema changes from your models in two steps:

python manage.py makemigrations   # write migration files from model changes
python manage.py migrate          # apply them to the database

makemigrations compares your models to the existing migration files and writes a new file describing the difference. migrate runs any unapplied files against the database and records which have run.

The common beginner mistake is editing a model and only running migrate. Nothing happens, because no migration file was created - you skipped makemigrations. The app then crashes with a database error like "no such column" because the table no longer matches the model. In CI you can guard against forgetting: python manage.py makemigrations --check --dry-run exits with a non-zero status when a model change has no migration.

Commit migration files to version control. They are part of your codebase, and teammates and servers replay them to reach the same schema.

The admin site

Django generates a working data-management UI from your models. Register them in the app's admin.py:

from django.contrib import admin
from .models import Author, Book

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ("title", "author", "price", "published")
    list_filter = ("author",)
    search_fields = ("title",)

admin.site.register(Author)

Create a login and start the server:

python manage.py createsuperuser
python manage.py runserver

Visit /admin/ and you can create, edit, filter, and search records with no extra code. list_display sets the columns in the list view, list_filter adds a sidebar, and search_fields adds a search box.

The admin is aimed at trusted staff, not end users - it is a back office, not a customer-facing dashboard. It is genuinely useful during early development and for content teams, and it is one of the main reasons people reach for Django over a lighter framework.

URLs and views

A view is a function that takes a request and returns a response. URLs map paths to views in a urls.py:

from django.urls import path
from library import views

urlpatterns = [
    path("books/", views.book_list, name="book-list"),
    path("books/<int:pk>/", views.book_detail, name="book-detail"),
]

And the views:

from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from .models import Book

def book_list(request):
    books = Book.objects.all()
    data = [{"id": b.id, "title": b.title} for b in books]
    return JsonResponse({"books": data})

def book_detail(request, pk):
    book = get_object_or_404(Book, pk=pk)
    return JsonResponse({"id": book.id, "title": book.title})

<int:pk> captures a number from the URL and passes it as the pk argument. get_object_or_404 fetches the row or raises a 404 if it does not exist, so /books/9999/ returns a proper Not Found instead of a 500. The name= on each pattern lets templates and redirect() refer to a URL without hardcoding the path.

The N+1 query trap

Django's ORM makes it easy to write code that quietly runs hundreds of queries. This loop looks harmless:

for book in Book.objects.all():
    print(book.title, book.author.name)

The first line runs one query for the books. Then book.author lives in a separate table and was not fetched, so each iteration runs another query to load that author. Three books is four queries; three hundred books is three hundred and one. This is the N+1 problem, and it is the single most common cause of slow Django pages.

The fix is to tell the queryset to fetch the related rows up front. For a ForeignKey or OneToOne, use select_related, which does a SQL join:

for book in Book.objects.select_related("author"):
    print(book.title, book.author.name)   # one query, total

For many-to-many or reverse foreign keys, use prefetch_related instead. To catch these during development, install Django Debug Toolbar - it shows the query count for every page.

Frequently Asked Questions

Is Django overkill for a small project? It can be. Django gives you an ORM, admin, auth, and migrations out of the box, which is a lot of value if you need them and some weight if you do not. For a tiny JSON API, Flask or FastAPI is lighter. For anything with users, content, and a database, Django usually pays off quickly.
What is the difference between a Django project and an app? A project is the deployable site - settings, root URLs, one database configuration. An app is a feature module within it, such as blog or accounts. One project contains many apps, and a well-built app can be reused across projects.
Why does makemigrations say 'No changes detected'? Usually the app is not in INSTALLED_APPS, so Django never looks at its models. Less often, you ran makemigrations with an app label that does not match. Add the app to settings and run it again.
Do I commit migration files to git? Yes. Migrations are source code. Your teammates and your servers apply the same files to build an identical schema. Never delete or hand-edit an already-applied migration on a shared branch; add a new one instead.
Should I use Django REST Framework or plain Django for an API? For anything beyond a couple of read-only endpoints, Django REST Framework adds serializers, authentication classes, viewsets, and a browsable API that save real work. Plain JsonResponse views are fine for a handful of simple endpoints.