Django took me from tutorials to real projects

August 2, 2026 code djangolearningpython

Django is the first framework where a tutorial project turned into something I kept building on afterward instead of deleting the folder.

I'd done the classic "build a blog with Django" tutorial like everyone does, closed the folder, and moved on. What actually stuck came later: a CRM I built to manage some fake client data (customers, notes, follow-ups), a live chat app, and a restaurant menu site. Three separate projects, three different reasons Django's batteries-included approach stopped feeling like overkill and started feeling like the reason I didn't have to build my own auth system for the fourth time.

the CRM list page that took four seconds for no reason

The CRM's customer list page shows each customer alongside how many notes are on their file. With about two hundred fake customers in the test database, that page took over four seconds to load, which felt absurd for something that should be a single database query away from instant.

# views.py, the version that was slow
def customer_list(request):
    customers = Customer.objects.all()
    return render(request, "crm/customer_list.html", {"customers": customers})
{% for customer in customers %}
  <tr>
    <td>{{ customer.name }}</td>
    <td>{{ customer.notes.count }}</td>
  </tr>
{% endfor %}

Looks completely reasonable. customer.notes.count in the template, once per customer. Nothing here screams "this is the bug."

what I actually found when I went looking

I'd heard of Django Debug Toolbar but never bothered installing it until this page made me actually curious. It showed something like this at the bottom of the page:

SQL queries: 201 (198 similar)

Two hundred and one queries, to render two hundred customers. One query to get the customer list, and then one more query per customer, triggered by that innocent-looking customer.notes.count in the template. Each iteration of the loop hit the database again, separately, because Django's ORM is lazy: customer.notes isn't fetched until something actually asks for it, and the template asking for it inside a loop means asking two hundred separate times.

what I googled, and the term I didn't know I needed

"django template loop slow multiple database queries" got me to the actual name for this almost immediately: the N+1 query problem. One query to get N rows, then N more queries to get related data for each row, one at a time, instead of one query to get everything up front. Once I had the name, the fix was one line.

# views.py, the fixed version
from django.db.models import Count

def customer_list(request):
    customers = Customer.objects.annotate(note_count=Count("notes"))
    return render(request, "crm/customer_list.html", {"customers": customers})
{% for customer in customers %}
  <tr>
    <td>{{ customer.name }}</td>
    <td>{{ customer.note_count }}</td>
  </tr>
{% endfor %}

annotate with Count does the counting inside the single database query, as a GROUP BY, instead of once per row in Python afterward. Same two hundred customers, one query total, page load dropped from four seconds to something I couldn't actually perceive as taking any time at all.

the ORM stopped being a black box around here

Coming from writing raw SQL in smaller scripts, Django's ORM initially felt like it was doing something magic and slightly suspicious, right up until the moment I actually needed to add a field to the CRM's customer model after there was already real data in the database, and migrations did the thing they're supposed to do without me having to hand-write an ALTER TABLE.

python manage.py makemigrations
Migrations for 'crm':
  crm/migrations/0004_add_customer_notes.py
python manage.py migrate

Running migrations, then starting the dev server

That's the moment Django stopped being "a lot of files I don't understand yet" and started being a tool I trusted not to quietly destroy my data, on top of being the thing that had just quietly cost me two hundred extra queries a page load ago.

the live chat app broke my mental model for an entire evening

Django's normal request-response cycle assumes a request comes in, you do something, you send a response, done. A live chat needs a connection that stays open, which meant learning Channels and async consumers. First attempt, I just ran it with the regular dev server:

python manage.py runserver

Which happily starts, happily serves normal pages, and then does absolutely nothing useful the moment a client tries to open a WebSocket connection. No error, no crash, just a connection that never upgrades. Django's default WSGI dev server doesn't know what to do with a protocol upgrade request at all, it's built for the request-response model and nothing else.

# consumers.py
class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        await self.channel_layer.group_add("chat", self.channel_name)
        await self.accept()

    async def receive(self, text_data):
        await self.channel_layer.group_send(
            "chat", {"type": "chat.message", "message": text_data}
        )

    async def chat_message(self, event):
        await self.send(text_data=event["message"])

The consumer code itself was fine, more or less on the first real attempt. The actual fix was realizing I needed to run it through Daphne, an ASGI server, instead of the normal WSGI dev server, since ASGI is the protocol that actually understands long-lived connections in the first place.

daphne -p 8001 myproject.asgi:application

Once I had the right server actually speaking the right protocol, the consumer that had looked broken for an entire evening worked on the first real test. I hadn't broken anything fundamental about the framework. I'd just been running it through the one server that was never going to work for this.

the restaurant menu project taught me the admin panel is underrated

Nothing complicated here, just menu items, categories, prices, a public page. The interesting part was realizing the built-in admin panel meant I never had to build a CMS for a client to update prices themselves.

# admin.py
from django.contrib import admin
from .models import MenuItem, Category

admin.site.register(Category)
admin.site.register(MenuItem)

Two lines, and suddenly there's a working, permissioned admin interface for editing every menu item, no separate CMS build required. Felt like cheating the first time, in the good way.

the demo data almost went out to a real person

Small, slightly mortifying aside: the fake CRM data I used for testing included a customer named "Test Testerson" with a note field that just said "this is fake, ignore." I once nearly sent a screen recording of the CRM to someone as a demo without checking which customer record was on screen first. Caught it before sending, but it's now a permanent habit to actually scan the visible rows before recording anything, not just trust that test data stays contained to my own screen.

what I'd tell someone starting Django after Flask

Flask, which is where my earlier CLI and GUI todo apps mostly lived, makes you build everything, which teaches you a lot. Django assumes you want the batteries included, faster for a real project and slower to actually understand at first, since there's more framework standing between you and the request. Both are right for different reasons. I just needed all three of the CRM, the chat app, and the menu site, plus one genuinely bad afternoon of unexplained slowness, before Django's version of "faster" started feeling earned instead of like magic I hadn't paid for yet.

what I'd do differently

Install Django Debug Toolbar on day one of any new Django project, not after a page has already made me suspicious enough to go looking for it. The N+1 problem was sitting there from the very first version of that view. I just didn't have a way to see it until I went and got one.

1 comment

Log in to comment.

mariann_ok August 5, 2026

The jump from following along to building something nobody handed you is the hardest step and the least talked about. This helped, thanks for writing it.

Log in

No account?