Django from a first-timer’s perspective

There’s a particular pleasure in picking up an “old boring technology” that’s been solid for two decades: every issue you hit has been solved many times over, and you can just build. I finally started using Django for a real project a few months ago, and these are the notes from that experience.

Explicitness beats convention

I tried Rails back in 2020 and wanted to like it, but there was a real friction point: leaving a Rails project alone for months made it hard to pick back up. A line like resources :topics in routes.rb doesn’t tell you where the topic routes actually live — you have to remember the convention or go look it up. Since almost all my projects get abandoned and revisited, that’s a dealbreaker for me.

Django feels easier in this respect because things are more explicit. In my small project, there are basically five main files beyond settings: urls.py, models.py, views.py, admin.py, and tests.py. If I need to find anything else — an HTML template, say — it’s usually referenced directly from one of those files.

The built-in admin is a genuine feature

For this project I needed an admin interface to view and manually edit database records. Django’s admin comes out of the box, and a small amount of code makes it behave the way I want. For instance, one of my admin classes defines which columns appear in the list view, the search field, and the default ordering:

@admin.register(Zine)
class ZineAdmin(admin.ModelAdmin):
    list_display = ["name", "publication_date", "free", "slug", "image_preview"]
    search_fields = ["name", "slug"]
    readonly_fields = ["image_preview"]
    ordering = ["-publication_date"]

The ORM is more fun than I expected

My past stance on ORMs was “who needs them, I’ll write SQL.” I’ve changed my mind a bit after using Django’s. The way Django uses __ to express a JOIN is neat:

Zine.objects
    .exclude(product__order__email_hash=email_hash)

That query touches five tables: zines, zine_products, products, order_products, and orders. All I had to do was declare two ManyToManyField relationships — one between orders and products, another between zines and products — and Django figured out how to connect them.

I could write that SQL by hand, but typing product__order__email_hash is far less work and easier to read. Honestly, constructing that query manually would have taken me a while since it does more than just those joins. I’m not worried about ORM query performance for this workload, so I’m happy with it for now.

Automatic migrations

Migrations are the other big win from the ORM. When I add, delete, or alter a field in models.py, Django generates a migration file automatically, like migrations/0006_delete_imageblob.py. I’ve just run the generated scripts as-is; editing them is possible but hasn’t been necessary. Given that I’m still shaping the data model frequently, having migrations handled for me is important right now — it really does feel like magic.

Documentation worth reading

I have a known habit of skipping docs, but Django’s have been genuinely enjoyable. That’s not accidental: Jacob Kaplan-Moss gave a talk at PyCon 2011 about Django’s documentation culture. The models introduction, for example, lists the common fields you’ll likely need when using the ORM.

SQLite for small sites

After struggling to operate Postgres without fully understanding what was happening, I decided to run my small websites on SQLite. It’s been much smoother. Backups are just a VACUUM INTO and copying the resulting single file. I followed these guidelines for running SQLite with Django in production. It should be fine: I expect at most a few hundred writes per day, far fewer than Mess with DNS, which has many more writes and works well (though its writes spread across three SQLite databases).

Batteries included, including email

Django ships with a lot built in — CSRF protection, a Content-Security-Policy, email sending, and more. I wanted to avoid sending real email while developing, so I saved messages to a file with a tiny bit of config in settings/dev.py:

EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = BASE_DIR / "emails"

Production email got similar minimal setup in settings/production.py:

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.whatever.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "xxxx"
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_API_KEY')

Having core web features available in the framework itself is a nice feeling — if I need something basic, there’s likely an easy, built-in way to do it.

The settings file intimidates slightly

Django’s settings system relies on setting global variables in a file, which gives me some pause. What happens if I typo a variable name, like writing WSGI_APPLICATOIN instead of WSGI_APPLICATION? How would I know? I’ve come to depend on a Python language server catching typos, so it’s disorienting to lose that safety net.

Still a long way to go

I haven’t really used a proper web framework for a serious project before — most of my sites are either a single Go binary or static pages. I’m curious how this settles in. I haven’t yet explored Django’s form validation or authentication systems, so there’s plenty still to learn.