Some notes on starting to use Django
Posted by ingve 1 day ago
Comments
Comment by senko 9 hours ago
I'll add a few of my own:
* Set up the project using uv
* I name the django project "project"; so settings are project/settings.py, main urls are project/urls.py, etc
* I always define a custom Django user model even if I don't need anything extra yet; easier to expand later
* settings.py actually conflates project config (Django apps, middleware, etc) and instance/environment config (Database access, storages, email, auth...); I hardcode the project config (since that doesn't change between environemnts) and use python-dotenv to pull settings from environment / .env; I document all such configurable vars in .env.example, and the defaults are sane for local/dev setup (such as DEBUG=true, SQLIte database, ALLOWED_HOSTS=*, and a randomly-generated SECRET_KEY); oh and I use dj-database-url to use DATABASE_URL (defaults to sqlite:///sqlite.db)
* I immediately set up up ruff, ty, pytest, pre-commit hook and GH workflow to run ruff/ty/pytest
Previously I had elaborate scaffolding/skeleton templates, or nowadays a small shell script and I tell Claude to adapt settings.py as per above instructions :)
Comment by Izkata 24 minutes ago
There is a convention to create "foo_settings.py" for different environments next to "settings.py" and start it with "from .settings import *"
You'll still want something else for secrets, but this works well for everything else, including sane defaults with overrides (like DEBUG=False in the base and True in only the appropriate ones).
Comment by bodge5000 8 hours ago
Saying that, I'm sure django-extensions does a lot more than shell_plus but I've never actually explored what those extra features are, so think I'll do that now
Edit: Turns out you can use bpython, ptpython or none at all with shell_plus, so good to know if you prefer any of them to ipython
Comment by el_io 8 hours ago
Django does this by default now. Since 5.0 if I'm remembering it correctly.
Comment by bodge5000 1 hour ago
Edit: Yep, you're right, wow thats pretty big for me
Comment by graemep 7 hours ago
I disagree strongly with this one. All you are doing is moving those settings to a different file. You might as well use a local settings file that reads the common settings.
On production keep things like API keys that need to be kept secret elsewhere - as a minimum outside the project directories and owned by a different user.
Comment by senko 3 hours ago
> On production keep things like API keys that need to be kept secret elsewhere - as a minimum outside the project directories and owned by a different user.
Curious what extra protection this gives you, considering the environment variables are, well, in the environment, and can be read by process. If someone does a remote code execution attack on the server, they can just read the environment.
The only thing I can imagine it does protect is if you mistakenly expose project root folder on the web server.
Comment by advisedwang 2 hours ago
Comment by stuaxo 8 hours ago
As a mostly-django-dev for the last 15 years, who's been exposed to FastAPI and various ORMs again recently, I should get round to write a doc about some Django bits.
Django is pretty nice, the changes between versions are small and can be managed by a human.
Part of the reason that you can have the big ecosystem is that there is a central place to register settings and INSTALLED_APPS, middleware etc.
That enables addons to bring their own templates and migrations.
There is a central place a bit further up in manage.py and that enables you to bring commandline extras to Django (and many of the things you install will have them).
Coming to a FastAPI app with alembic and finding a lot of that is build-it-yourself (and easily break it) is a bit of a shock.
The Django ORM at first can seem a little alien "why isn't this sqlalchemy" was my reaction a long time ago, but the API is actually pretty pragmatic and allows easy extension.
You can build up some pretty complex queries, and keep them optimised using the Django-Debug-Toolbar and its query viewer.
The ORM, Templates and other parts of Django pre-date many newer standards which is why they have their own versions. As a Django dev I only just discovered the rest of the world has invented testcontainers, and databases as a solution for a problem Django solved years ago with it's test database support.
I quite like the traditional setup where you have settings/common.py and then settings that extend that - e.g local.puy production.py
If you ever need a CMS in your Django project I strongly recommend Wagtail, it came after the initially most popular django-cms and learned a lot of lessons - feeling much more like a part of Django.
It has the same feeling of being productive as Django does when you first use it.
Comment by selcuka 22 minutes ago
I briefly played with FastAPI, and after the same shock I discovered Django-Ninja [1]. It's modeled after FastAPI and async-capable (if you are inclined, but warning, there be dragons). It plays nicely with all parts of Django, including the ORM.
Comment by Izkata 20 minutes ago
I believe it's now accurate to even say "decades ago".
Comment by appplication 6 hours ago
Testing an API with model-bakery + pytest-django is absolutely joyous. As a TDD nerd, the lack of any remotely similar dev ex in FastAPI is the main reason I’ve never switched over.
As an aside, as someone who loves ergonomic testing, test containers are not the way. Dockerized services for testing are fine but their management is best done external to your test code. It is far easier to emulate prod by connecting to a general DB/service url that just happens to be running in a local container than have a special test harness that manages this internally to your test suite.
Comment by interroboink 21 hours ago
Being able to abandon a project for months or years and then come back
to it is really important to me (that’s how all my projects work!) ...
It's perhaps especially true for a hobbyist situation, but even in a bigger environment, there is a cost to keeping people on hand who understand how XYZ works, getting new people up to speed, etc.I, too, have found found that my interactions with past versions of myself across decades has been a nice way to learn good habits that also benefit me professionally.
Comment by simonw 17 hours ago
It makes it so much easier to pick them up again in the future when enough time has passed that I've forgotten almost everything about them.
Comment by agumonkey 20 seconds ago
Comment by sriram_malhar 16 hours ago
I have a couple of android projects that are four years old. I have the architecture documented, my notes (to self) about some important details that I thought I was liable to forget, a raft of tests. Now I can't even get it to load inside the new version of Android Studio or to build it. There's a ton of indirection between different components spread over properties, xml, kotlin but what makes it worse is that any attempt to upgrade is a delicate dance between different versions and working one's ways around deprecated APIs. It isn't just the mobile ecosystem.
Comment by wink 8 hours ago
Comment by alansaber 11 hours ago
Comment by dotancohen 10 hours ago
Comment by alansaber 2 hours ago
Comment by appplication 6 hours ago
Comment by dgroshev 11 hours ago
Comment by skylurk 11 hours ago
If you need to travel, make sure you have someone reliable who can check on them, in case of a power outage.
Comment by selcuka 21 hours ago
Comment by jgavris 19 hours ago
Comment by hansonkd 18 hours ago
Its always a surprise when i went to Elixir or Rust and the migration story was more complicated and manual compared to just changing a model, generating a migration and committing.
In the pre-LLM world, I was writing ecto files, and it was super repetitive to define make large database strucutres compared to Django.
Comment by igsomething 16 hours ago
At least you can opt-in to automated migrations in Elixir if you use Ash.
Comment by limagnolia 22 minutes ago
Comment by wiredfool 14 hours ago
Comment by cuu508 11 hours ago
Comment by wiredfool 11 hours ago
There's a pre, do and post phase for the migrations. When you run a single migration, it's: pre, do, post. When you run 2 migrations, it's: pre [1,2], do: [1,2], post: [1,2].
So, if you have a migration that depends on a previous migration's post phase, then it will fail if it is run in a batch with the previous migration.
When I've run into this is with data migrations, or if you're adding/assigining permissions to groups.
Comment by advisedwang 2 hours ago
Comment by hansonkd 8 hours ago
Comment by brianwawok 10 hours ago
Comment by dnautics 18 hours ago
Comment by IceDane 17 hours ago
Comment by hansonkd 8 hours ago
The only thing to manually migrate are data migrations from one schema to the other.
Comment by frankwiles 11 hours ago
Comment by etchalon 17 hours ago
Comment by boxed 14 hours ago
Comment by ndr 11 hours ago
It requires a particular dance if you ever want to add/delete a field and make sure both new-code and old-code work with both new-schema and old-schema.
The workaround I found was to run tests with new-schema+old-code in CI when I have schema changes, and then `makemigrations` before deploying new-code.
Are there better patterns beyond "oh you can just be careful"?
Comment by tmarice 8 hours ago
* https://github.com/tbicr/django-pg-zero-downtime-migrations
* https://docs.gitlab.com/development/migration_style_guide/
* https://pankrat.github.io/2015/django-migrations-without-dow...
* https://www.caktusgroup.com/blog/2021/05/25/django-migration...
* https://openedx.atlassian.net/wiki/spaces/AC/pages/23003228/...
Generally it's also advisable to set a statement timeout for migrations otherwise you can end up with unintended downtime -- ALTER TABLE operations very often require ACCESS EXCLUSIVE lock, and if you're migrating a table that already has an e.g. very long SELECT operation from a background task on it, all other SELECTs will queue up behind the migration and cause request timeouts.
There are some cases you can work around this limitation by manually composing operations that require less strict locks, but in our case, it was much simpler to just make sure all Celery workers were stopped during migrations.
Comment by rorylaitila 10 hours ago
Comment by senko 10 hours ago
1. Make a schema migration that will work both with old and new code
2. Make a code change
3. Clean up schema migration
Example: deleting a field:
1. Schema migration to make the column optional
2. Remove the field in the code
3. Schema migration to remove the column
Yes, it's more complex than creating one schema migration, but that's the price you pay for zero-downtime. If you can relax that to "1s downtime midnight on sunday", you can keep things simpler. And if you do so many schema migrations you need such things often ... I would submit you're holding it wrong :)
Comment by ndr 9 hours ago
Adding a field needs a default_db, otherwise old-code fails to `INSERT`. You need to audit all the `create`-like calls otherwise.
Deleting similarly will make old-code fail all `SELECT`s.
For deletion I need a special 3-step dance with managed=False for one deploy. And for all of these I need to run old-tests on new-schema to see if there's some usage any member of our team missed.
Comment by jgavris 10 hours ago
Comment by aljarry 10 hours ago
1. Create new fields in the DB.
2. Make the code fill in the old fields and the new fields.
3. Make the code read from new fields.
4. Stop the code from filling old fields.
5. Remove the old fields.
Personally, I wouldn't use it until I really need it. But a simpler form is good: do the required schema changes (additive) iteratively, 1 iteration earlier than code changes. Do the destructive changes 1 iteration after your code stops using parts of the schema. There's opposite handling of things like "make non-nullable field nullable" and "make nullable field non-nullable", but that's part of the price of smooth operations.
Comment by m000 10 hours ago
When you add new stuff or make benign modifications to the schema (e.g. add an index somewhere), you won't notice a thing.
If the introduced schema changes are not compatible with the old code, you may get a few ProgramingErrors raised from the old pods, before they are replaced. Which is usually acceptable.
There are still some changes that may require planning for downtime, or some other sort of special handling. E.g. upgrading a SmallIntegerField to an IntegerField in a frequently written table with millions of rows.
Comment by ndr 9 hours ago
Comment by m000 7 hours ago
So, if some of your pods fail a fraction of the requests they receive for a few seconds, this is not considered downtime for 99% of the use cases. The service never really stopped serving requests.
The problem is not unique to Django by any means. If you insist on being a purist, sure count it as downtime. But you will have a hard time even measuring it.
Comment by jgavris 10 hours ago
Comment by dnautics 18 hours ago
Comment by dxdm 12 hours ago
You can run raw SQL in a Django migration. You can even substitute your SQL for otherwise autogenerated operations using `SeparateDatabaseAndState`.
You have a ton of control while not having to deal with boilerplate. Things usually can just happen automatically, and it's easy to find out and intervene when they can't.
https://docs.djangoproject.com/en/6.0/ref/django-admin/#djan...
https://docs.djangoproject.com/en/6.0/ref/migration-operatio...
Comment by gtaylor 16 hours ago
Comment by dnautics 2 hours ago
Comment by 3eb7988a1663 18 hours ago
That sounds like the path to madness, but I do believe it would work out of the box.
Comment by dnautics 18 hours ago
Comment by 3eb7988a1663 17 hours ago
Comment by dnautics 2 hours ago
maybe more concretely: if you have a table with a kajillion columns and you want performant views onto some column (e.g. "give me the metadata only and dont show me blobs columns") without pulling down the entire jungle in the sql request, There's that.
Comment by spapas82 2 hours ago
Comment by blorenz 1 hour ago
Comment by tmarice 11 hours ago
Re: Django is OK for simple CRUD, but falls apart on anything complex - this is just untrue. I have worked in a company with a $500M valuation that is backed by a Django monolith. Reporting, recommender systems, file ingestion pipelines, automatic file tagging with LLM agents -- everything lives inside Django apps and interconnects beautifully. Just because it's a Django app doesn't mean you cannot use other libraries and do other stuff besides basic HTTP request processing.
Recently I had the misfortune of doing a contract on a classic SPA project with Flask and sqlalchemy on the backend and React on the frontend, and the amount of code necessary to add a couple of fields to a form is boggling.
Comment by globular-toast 22 minutes ago
Comment by otherme123 11 hours ago
Same here, and the reason to do all the Flask + SQLAlchemy + React was to keep things simple, as they are simple tools but Django is a complex tool. In particular the Flask part was juggling plugins for admin, forms and templates that Django already has included. But yeah, I am sure it is easier to code and to mantain because Flask is made for simple sites :/.
Comment by bodge5000 8 hours ago
Maybe my experience of working with Django on complex applications has coloured my view on it a bit, but I always think the opposite; it seems overkill for simple CRUD, even if I love using it
Comment by giancarlostoro 22 hours ago
Comment by jszymborski 21 hours ago
Naively, I would probably just copy the sqlite file. Is that a bad idea?
Comment by simonw 17 hours ago
VACUUM INTO eliminates that risk.
Comment by modo_mario 13 hours ago
Comment by amanzi 20 hours ago
Comment by striking 22 hours ago
Comment by dnautics 18 hours ago
Comment by Cthulhu_ 12 hours ago
Comment by cenamus 15 hours ago
Comment by bb88 21 hours ago
But once something gets significantly complex, the ORM starts to fall down, and DRF becomes more of a hindrance.
But if you're just doing simple CRUD apps, Django is perfectly serviceable.
Comment by sgt 14 hours ago
Comment by compounding_it 12 hours ago
Then as a programmer, you have to find workarounds in Django instead of workarounds with programming.
PS: Dealing with a lot of scaling issues right now with a Django app.
Comment by sgt 11 hours ago
The framework itself is not the limiting factor. The main constraint of performance usually comes from Python itself (really slow). And possibly I/O.
There are well established ways to work around that. In practice, lots of heavy lifting happens in the DB, can you can offload workloads to separate processes as well (whether those are Python, Go, Rust, Java etc).
You need to identify the hotspots, and blindly trusting a framework to "do the job for you" (or for that matter, trusting an LLM to write the code for you without understanding the underlying queries) is not a good idea.
I'm not saying you are doing that, but how often do you use the query planner? Whenever I've heard someone saying Django can't scale, it's not Django's fault.
> When you start to run into scaling problems, your solution is within that framework and that becomes a limiting factor from my experience.
Using Django doesn't mean that everything needs to run inside of it. I am working on an API that needs async perf, and I run separate FastAPI containers will still using Django to maintain the data model + migrations.
Occasionally I will drop down to raw SQL, or materialized views (if you are not using them with Django, you are missing out). And the obvious for any Django dev; select_related, prefetch_related, annotate, etc etc.
Comment by otherme123 10 hours ago
And sometimes not so obvious, I have been bitten by forgetting one select_related while inadvertedly joining 5 tables but using only 4 select_related: the tests work OK, but the real data has a number of records that cause a N+1. A request that used to take 100ms now issues "30 seconds timeout" from time to time.
Once we added the missing select_related we went back to sub-second request, but it was very easy to start blaming Django itself because the number of records to join was getting high.
The cases that we usually walk out of the Django path is for serializations and representations, trying to avoid the creation of intermediate objects when we only need the "values()" return.
Comment by tclancy 11 hours ago
Comment by halfcat 10 hours ago
The mental unlock here is: Django is only a convention, not strictly enforced. It’s just Python. You can change how it works.
See the Instagram playbook. They didn’t reach a point where Django stopped scaling and move away from Django. They started modifying Django because it’s pluggable.
As an example, if you’re dealing with complex background tasks, at some point you need something more architecturally robust, like a message bus feeding a pool of workers. One simple example could be, Django gets a request, you stick a message on Azure Service Bus (or AWS SQS, GCP PubSub, etc), and return HTTP 202 Accepted to the client with a URL they can poll for the result. Then you have a pool of workers in Azure Container Apps (or AWS/GCP thing that runs containers) that can scale to zero, and gets woken up when there’s a message on the service bus. Usually I’d implement the worker as a Django management command, so it can write back results to Django models.
Or if your background tasks have complex workflow dependencies then you need an orchestrator that can run DAGs (directed acyclic graph) like Airflow or Dagster or similar.
These are patterns you’d need to reach for regardless of tech stack, but Django makes it sane to do the plumbing.
The lesson from Instagram is that you don’t have to hit a wall and do a rewrite. You can just keep modifying Django until it’s almost unrecognizable as a Django project. Django just starts you with a good convention that (mostly) prevents you from doing things that you’ll regret later (except for untangling cross-app foreign keys, this part requires curse words and throwing things).
Comment by boxed 14 hours ago
Comment by graemep 7 hours ago
I am not using the main menu module, but tables and forms work really well.
Comment by dgroshev 11 hours ago
Comment by synack 22 hours ago
Comment by 6bb32646d83d 4 hours ago
I've been vibe coding some side projects with Claude Code + Django + htmx/tailwind, and when it's time to go some manual work in the codebase I know exactly where things are and what they do, there's way fewer weird patterns or hack the way Claude tends to do when it's not as guided
Comment by jdahlin 17 hours ago
Been building a project in the side to help my studies and it usually implement new complete apps from one prompt, working on the first try
Comment by megaman821 6 hours ago
Comment by sgt 15 hours ago
Comment by scott_w 15 hours ago
Comment by tecoholic 15 hours ago
Comment by Nextgrid 10 hours ago
Comment by christophilus 11 hours ago
I presume you could do the same thing with Django— use Django’s validation feature to validate everything including your config. It’s a nice pattern that gives uniformity and predictability to all of your validation logic.
Comment by scott_w 2 hours ago
The situation is worse than that because any plugins usually define their own settings which also don’t validate their contents.
I think something centralised that lets you properly scope and validate settings would be nice. If you mistype a key, you’d want an error that it’s just not valid.
Comment by pbreit 16 hours ago
I also do not see much reason to do more than emit JSON on the server side.
Comment by senko 10 hours ago
Django with DRF or django-ninja works really nice for that use case.
Comment by JodieBenitez 14 hours ago
Well... that's a valid reason. Why should I work with tool B when I prefer tool A ?
> I also do not see much reason to do more than emit JSON on the server side.
That's the "SPA over API" mindset we need to reconsider. A lot (and I mean A LOT) of projects are way easier to produce and maintain with server-side rendered views.
Comment by gertburger 12 hours ago
Comment by JodieBenitez 11 hours ago
Comment by vasco 15 hours ago
Comment by kurtis_reed 1 hour ago