Good ole Django. Worked with a number of frameworks (tm), but nothing really quite scratches my itch like Django does. I still find the ORM and database migration system unmatched.
> Some light load testing (with (ab -n 1000 -c 1) shows that right now we can serve about 2-3 requests per second (on a ~$10/month VM).
> After turning on template caching, it seems like the site can now pretty easily handle 12 requests per second or so without using all of the CPU. I have not carefully benchmarked the before and after but it seems like it’s made a pretty big difference.
That seems crazy low, I think there has to be something else going on here.
I don't think the performance issues here are a result of Django. The author says only 2-3 requests per second (rps). Even in debug mode just using django runserver I can get 60~ rps on my $WORK repo. Part of this is likely their choice of only using '-c 1', which doesn't accurately represent a web workload.
For example on a local dev server I get (in format -c n. rps)
1. 58
2. 61
3. 55
as expected because we only have 1 worker, in debug with little caching.
but when looking at an example production config we see
1. 1: 11
2. 2: 23
3. 5: 54
4. 10: 95
5. 20: 153
6. 30: 165
7. 40: 177
8. 50: 192
and so on....
All of these are far in excess of the 2-3 observed by the author.
Note these will heavily depend on how many database hits you have per request as that is the majority of workload, as well as your http server such as using http 1/2/3, and the TLS settings used etc...
The Django filter syntax with the double underscores is like fingernails on a chalkboard to me. I find it insane that they didn't just use operator overloading to create a real query expression language.
I mostly use Go + SQLite for all the things I used to use Rails, JavaScript, or Python for.
I find python django wastes too much resources, just look at memory usage.
One of my web app backend (go) is serving approx 100 req/s right now and i look at pprof i see it's not bottlenecked by CPU but mostly IO and i love this.
Writing concurrent code in Go is easy, the code i wrote 10yrs ago still compiles with no issue! This is why i am never gonna switch.
My go apps use very little memory, so we can scale to many users for very cheap.
For larger apps i use postgres (why? replication is easy using pgfailover, high demand apps need multiple api servers so it's out of process db like postgres is fine) but most of my web app use HTMX and if we need some reactivity, i use react (simply due to react experience from work)
For our maintenance calorie tracking app, which is free and has no ads, we have to use as few resources as possible as we scale to thousands of users: macrocodex (which figures out maintenance calories from weight and calorie intake). We initially used Haskell.
Later, it became slow and cumbersome to develop in (developing on an Apple Silicon Mac and deploying to x64 is a pain), even though I liked writing Haskell code. I even tried nix and wasted a day on that! I had a choice between OCaml and Rust. I picked Rust and never looked back.
The algorithm serves in 0.1 ms on Rust. In Haskell, it was 0.2 ms, and memory usage was twice that of Rust. There are many optimization possible in Rust which i didn't do (for sake of simplicity) yet i received good performance.
Yeah, I use Docker to compile Rust, but it's pretty fast, much faster than what I had with Haskell, so the developer experience is great.
By switching to Rust, the LOC dropped to half of what we had in Haskell.
project turned out to be successful. It has already produced guaranteed weight loss or weight gain for many people.
So I set out to create an algorithmic workout app, for which I am using Rust and Go. The mobile app is in Flutter.
I dont understand the comparison of Go even with Sqlite and Rails or Django?
one is a programming language with a db server and the others are full stack web frameworks.
How fast can you create (without LLMs) an authentication system plus a form and saving the answers in DB (only for authenticated users) with sane default good secure protections in Go and Sqlite?
And assuming you are amazing at Go (and probably you are) then ask the same question above for any average Go developer vs any average Django or Rails developer.
I have been using Django since 0.95 and I haven't seen anything which is so flexible with amazing DSLs while also making it easy to understand the magic behind it.
For the last 10 years, even in a Golang stack or Java stack, I still use Django for models and migration. I even have generators which generate Gorm (or other framework) DAO or Java hibernate classes using Django models.
With LLMs, it becomes easier since I can now write all the model, custom querysets in Django, ask the LLM to generate Golang DAO, setters and getters... and test the query against the Django generated queries for completeness.
Atlas, sqlx, sqlc and all other ORM like things in golang cannot do migrations the way Django does.
> as a meta comment: I’ve been working on talking about my programming opinions by just saying “THING does not feel good to me, I prefer OTHER THING instead”. That post I linked to says that function-based views are the “right way”. I’m not very invested in whether it’s “right”, but it’s validating to know that other people feel similarly to me about inheritance
I admire this about Julia a lot. Her texts and zines are exploring software in a way that encourages curiosity rather than promoting a singular point of view.
There are so many footguns[1] in async python but it really has stolen the zeitgeist of modern python web. Synchronous django has a lot to commend it. If you deploy it reasonably well (workers, behind a caching reverse proxy etc) it's easy to operate in production even under duress.
It's not going to be the right stack for long lived websocket connections or whatever but for a CRUD-ish or enterprise app, often very productive.
[1] buffer bloat is too easy to sleep walk into
queue = asyncio.Queue() # oops
unbounded concurrency
await asyncio.gather(*(fetch(item) for item in items)) # look mum! no outbound sockets left or maybe even no file descriptors
accidental blocking
data = requests.get(url).json() # oops! we're blocking the event loop
and sure you can setup a separate task to measure the event loop latency and alert on that but this is the kinda thing you'll never find in a tutorial, you just need to experience this stuff and figure out a solution you like
I can go on and on about async python (cancellation bugs, leaking a task - that has a cataclysmic failure mode where if you forget to hold a reference to your asyncio.create_task() then it's all weak refs in that machinery so your task can get garbage collected before it ran or completed in production! super tricky forensics. Then there's all the obvious stuff like race conditions, new ways of creating deadlocks, blah blah i really can go on for days.
Please go on. I also feel like asyncio is a big hack. Even just accidentally blocking the event loop is way too easy. And I couldn't believe it when I read that you need to store a reference to a task in a set to keep it from accidentally getting canceled. How did this become the main stack of AI backends? It's like node but slower AND much easier to mess up because of synchronous IO.
Yep, if you are going to make asynchronous programming, you need either a small, plain, and obvious layer that calls insulated synchronous code or some stack with high quality assurances.
async python is always slower than just naively using threads. I don't think there is any application in production anywhere that would not prove me right on this. the complete lack of any kind of scheduling is a disaster. unless you know exactly what I mean by a scheduler please don't reply to this.
I don’t think there’s anything Python specific about that criticism. Most languages with cooperative concurrency have simple or no schedulers and run coroutines pretty much directly (or unpredictably, if they’re scheduled into parallel executors). Even Java and Tokio’s scheduling, such as it is, isn’t that directly controllable or observable and doesn’t magically fix loop-blocking or cache efficiency or whatever.
I think you might just not like cooperative async IO multiplexing as a programming model. Which is fine, but has nothing to do with Python.
Everytime I have to deal with websockets in Django, I want to rip my eyeballs out.
Most of the time what I really want is just push support, and don't need bidirectional communication, i.e. SSE. But it's the same thing: both are a pain to work with in Django. I find django-channels so cumbersome.
So, for my latest projects, I've ended up offloading this push functionality to a service like Centrifugo. Django talks to Centrifugo, and clients connect to it as well. It makes Django still be django, without worrying about django-channels, ASGI and all of that complicated setup. I've been very, very, very happy with this decision, even if I do have some additional complexity to articulate the two. It is nowhere near as confusing and convoluted as django-channels and uvicorn/daphne, though.
As for async python in general, fully agree. Every couple of years I decide to give it a go with some new tool or framework, thinking surely now it'll finally just click and I'll get with the times. Every time I feel disappointed with footguns and absolutely terrible ergonomics. No, thanks.
I agree that there's plenty of footguns with async python, but I think the hype is a bit overblown. A few sensible defaults in the standard library would go a long way, e.g. requiring a maxsize argument to Queue (or None if you really need it to be unbounded). The "accidental blocking" thing seems like less of design issue - it has much more to do with python's super long history as a sync-first language. Taking a step back, though, async is popular because it is genuinely extremely useful! If you need to do a bunch of work and that work is mostly IO-bound, then it makes sense to live in an async world.
I think people in the Python world just don't yet have a good mental model of async either. I recently have had conversations with a number of people who have switched to FastAPI because "it's faster" even when they're using it to serve ML models which are compute bound and there's nothing you can await on.
Do not use the development http server in a production setting. Use gunicorn or some equivalent.
I had the same issue with incredibly low throughput on beefy machines and it's because the dev server implementation is single threaded and does not do concurrency at all.
For Django's default template language, does it still have these limitations?
1. Brackets aren't allowed to help with boolean expressions like {% if a and (b or c) %}
2. You can't do basic arithmetic like {{ x * 2 }}, but you're allowed to do {{ x | add:"2" }}. There's hacks to multiply using {% widthratio a 1 b %} or division with {% widthratio a b 1 %} though (https://stackoverflow.com/questions/18350630/multiplication-...).
3. You can't assign expressions to variables like {% with x = a or b %}, so you have to repeat yourself.
4. You can't capture HTML generated from template code in a variable to pass into a partial template to write slots-style HTML components e.g. {% capture x %}Hello {{ username }}{% endcapture %}{{ include "partials/header.html" with body_html=x }}.
5. You can't pass variables to model methods.
I understand there's a philosophy that templates shouldn't contain complex logic, but I find the above pretty arbitrary and leads to code that's harder to maintain. Addition is okay but not multiplication? Boolean logic is okay but not with brackets? I often have to puzzle out some way to get my code to work that goes against what I'd normally want to do, some I'm forced to duplicate template code because you can't put expressions in variables or move basic one-off logic into views (which has poor locality https://htmx.org/essays/locality-of-behaviour/ and makes it harder to move template snippets between pages).
It's like hiding the kitchen knives because they might be misused.
Is Jinja2 a practical alternative or there's friction to using it?
> It's like hiding the kitchen knives because they might be misused.
I used to agree with this more strongly but over time decided the Django model was actually more effective as your cue to ask whether you should have been writing a Python function instead and calling it (which has been super easy since something like 1.4 or so IIRC).
Unless you have a small, very diligent development team it always seemed to end up with people coming up with thickets of template logic which were harder to understand and test but because they’d grown so complex people would act like it’d be an excessive amount of work to switch. I had a few projects where I delivered order of magnitude performance improvements while replacing hundreds of lines of ugly code, especially since using Python directly made things like caching and more complex formatting much easier.
> the site can now pretty easily handle 12 requests per second.
Right prod architecture (nginx -> gunicorn -> django, with nginx serving static assets), decent PRAGMAs for sqlite3 and caching for generic content should improve the RPS one or two orders of magnitude.
Not to be a downer since a lot of JEvans’ content is great. People should really give .NET a try. I don’t know if we are just old greybeard curmudgeons who look at this and go “is this a new thing? haven’t we been doing this for decades?”, but .NET out of the box with a few packages for databases, logging, etc is so pleasant you don’t even think about it. Maybe it’s just not a vocal community idk.
automatic migrations terrifies me. i use elixir/ecto. just write a migration!
besides, some times you might want more than one schema for a db table (e.g. one with minimal information, for menus, dropdowns etc, and one with full information for your full CRUD operations)
You can definitely get more than that in Django. We could much more that back 15 years ago on very basic hardware, rendering templates and doing semi complex UIs.
My experience from running a django app with thousands of migrations and dozen of apps
- Testing involving models is slow. It runs all your migration. Python's own "Unittest" is fast for functions not involving models.
- The suggested pattern for business logic is "active record", which is putting functions about a model alongside the model itself. Good for clean case. Doesn't really work when your operation involve multiple models.
- But if you put function about a model inside a model, the migration doesn't serialize the function into migration history (I could be wrong)
- "Data" migration is handy when you want enum-like data in database available to all teammates. e.g. list of currencies
- It is very tempting for new team member just to create an django app, with its own view and model, because it feels like starting a fresh without needing to care about existing business context. On the other hand, designing conceptual boundary between apps is very important. Because starting app is easy, and often (not always) wrong.
- The squash migration utils are limited because I guess of relatively low usage. It squashes linear migration history (with manual curation of starting and ending migration node I think? Not sure about latest state). I wrote a util to squash whole migration history by finding linear segments and squash those segment of history one by one. Didn't release it, but wrote some notes https://gist.github.com/kmcheung12/08b4c234b38c23efd43550da9...
Django is nice, but I'm currently migrating a project from Django to Go because Django is too large. I would love to stay with it, but simply assimilating the docs has been a chore. The PDF is 3000 pages, and I love working with tools where all the parts can fit in my head.
When it comes to thinking about performance, the first thing to do is install the Django debug toolbar and use that to work out what's going on with your views and the queries they trigger.
35 comments
[ 4.3 ms ] story [ 91.3 ms ] thread> After turning on template caching, it seems like the site can now pretty easily handle 12 requests per second or so without using all of the CPU. I have not carefully benchmarked the before and after but it seems like it’s made a pretty big difference.
That seems crazy low, I think there has to be something else going on here.
I imagine even a $10 VPS should be a much more powerful machine than a high-end server from 2005 or so.
I don't think the performance issues here are a result of Django. The author says only 2-3 requests per second (rps). Even in debug mode just using django runserver I can get 60~ rps on my $WORK repo. Part of this is likely their choice of only using '-c 1', which doesn't accurately represent a web workload.
For example on a local dev server I get (in format -c n. rps)
1. 58
2. 61
3. 55
as expected because we only have 1 worker, in debug with little caching.
but when looking at an example production config we see
1. 1: 11
2. 2: 23
3. 5: 54
4. 10: 95
5. 20: 153
6. 30: 165
7. 40: 177
8. 50: 192
and so on....
All of these are far in excess of the 2-3 observed by the author.
Note these will heavily depend on how many database hits you have per request as that is the majority of workload, as well as your http server such as using http 1/2/3, and the TLS settings used etc...
I find python django wastes too much resources, just look at memory usage.
One of my web app backend (go) is serving approx 100 req/s right now and i look at pprof i see it's not bottlenecked by CPU but mostly IO and i love this.
Writing concurrent code in Go is easy, the code i wrote 10yrs ago still compiles with no issue! This is why i am never gonna switch.
My go apps use very little memory, so we can scale to many users for very cheap.
For larger apps i use postgres (why? replication is easy using pgfailover, high demand apps need multiple api servers so it's out of process db like postgres is fine) but most of my web app use HTMX and if we need some reactivity, i use react (simply due to react experience from work)
For our maintenance calorie tracking app, which is free and has no ads, we have to use as few resources as possible as we scale to thousands of users: macrocodex (which figures out maintenance calories from weight and calorie intake). We initially used Haskell.
Later, it became slow and cumbersome to develop in (developing on an Apple Silicon Mac and deploying to x64 is a pain), even though I liked writing Haskell code. I even tried nix and wasted a day on that! I had a choice between OCaml and Rust. I picked Rust and never looked back.
The algorithm serves in 0.1 ms on Rust. In Haskell, it was 0.2 ms, and memory usage was twice that of Rust. There are many optimization possible in Rust which i didn't do (for sake of simplicity) yet i received good performance.
Yeah, I use Docker to compile Rust, but it's pretty fast, much faster than what I had with Haskell, so the developer experience is great.
By switching to Rust, the LOC dropped to half of what we had in Haskell.
project turned out to be successful. It has already produced guaranteed weight loss or weight gain for many people.
So I set out to create an algorithmic workout app, for which I am using Rust and Go. The mobile app is in Flutter.
one is a programming language with a db server and the others are full stack web frameworks.
How fast can you create (without LLMs) an authentication system plus a form and saving the answers in DB (only for authenticated users) with sane default good secure protections in Go and Sqlite?
And assuming you are amazing at Go (and probably you are) then ask the same question above for any average Go developer vs any average Django or Rails developer.
For the last 10 years, even in a Golang stack or Java stack, I still use Django for models and migration. I even have generators which generate Gorm (or other framework) DAO or Java hibernate classes using Django models.
With LLMs, it becomes easier since I can now write all the model, custom querysets in Django, ask the LLM to generate Golang DAO, setters and getters... and test the query against the Django generated queries for completeness.
Atlas, sqlx, sqlc and all other ORM like things in golang cannot do migrations the way Django does.
I admire this about Julia a lot. Her texts and zines are exploring software in a way that encourages curiosity rather than promoting a singular point of view.
It's not going to be the right stack for long lived websocket connections or whatever but for a CRUD-ish or enterprise app, often very productive.
[1] buffer bloat is too easy to sleep walk into
unbounded concurrency accidental blocking and sure you can setup a separate task to measure the event loop latency and alert on that but this is the kinda thing you'll never find in a tutorial, you just need to experience this stuff and figure out a solution you likeI can go on and on about async python (cancellation bugs, leaking a task - that has a cataclysmic failure mode where if you forget to hold a reference to your asyncio.create_task() then it's all weak refs in that machinery so your task can get garbage collected before it ran or completed in production! super tricky forensics. Then there's all the obvious stuff like race conditions, new ways of creating deadlocks, blah blah i really can go on for days.
I think you might just not like cooperative async IO multiplexing as a programming model. Which is fine, but has nothing to do with Python.
Most of the time what I really want is just push support, and don't need bidirectional communication, i.e. SSE. But it's the same thing: both are a pain to work with in Django. I find django-channels so cumbersome.
So, for my latest projects, I've ended up offloading this push functionality to a service like Centrifugo. Django talks to Centrifugo, and clients connect to it as well. It makes Django still be django, without worrying about django-channels, ASGI and all of that complicated setup. I've been very, very, very happy with this decision, even if I do have some additional complexity to articulate the two. It is nowhere near as confusing and convoluted as django-channels and uvicorn/daphne, though.
As for async python in general, fully agree. Every couple of years I decide to give it a go with some new tool or framework, thinking surely now it'll finally just click and I'll get with the times. Every time I feel disappointed with footguns and absolutely terrible ergonomics. No, thanks.
I had the same issue with incredibly low throughput on beefy machines and it's because the dev server implementation is single threaded and does not do concurrency at all.
Switch to gunicorn.
1. Brackets aren't allowed to help with boolean expressions like {% if a and (b or c) %}
2. You can't do basic arithmetic like {{ x * 2 }}, but you're allowed to do {{ x | add:"2" }}. There's hacks to multiply using {% widthratio a 1 b %} or division with {% widthratio a b 1 %} though (https://stackoverflow.com/questions/18350630/multiplication-...).
3. You can't assign expressions to variables like {% with x = a or b %}, so you have to repeat yourself.
4. You can't capture HTML generated from template code in a variable to pass into a partial template to write slots-style HTML components e.g. {% capture x %}Hello {{ username }}{% endcapture %}{{ include "partials/header.html" with body_html=x }}.
5. You can't pass variables to model methods.
I understand there's a philosophy that templates shouldn't contain complex logic, but I find the above pretty arbitrary and leads to code that's harder to maintain. Addition is okay but not multiplication? Boolean logic is okay but not with brackets? I often have to puzzle out some way to get my code to work that goes against what I'd normally want to do, some I'm forced to duplicate template code because you can't put expressions in variables or move basic one-off logic into views (which has poor locality https://htmx.org/essays/locality-of-behaviour/ and makes it harder to move template snippets between pages).
It's like hiding the kitchen knives because they might be misused.
Is Jinja2 a practical alternative or there's friction to using it?
I used to agree with this more strongly but over time decided the Django model was actually more effective as your cue to ask whether you should have been writing a Python function instead and calling it (which has been super easy since something like 1.4 or so IIRC).
Unless you have a small, very diligent development team it always seemed to end up with people coming up with thickets of template logic which were harder to understand and test but because they’d grown so complex people would act like it’d be an excessive amount of work to switch. I had a few projects where I delivered order of magnitude performance improvements while replacing hundreds of lines of ugly code, especially since using Python directly made things like caching and more complex formatting much easier.
Right prod architecture (nginx -> gunicorn -> django, with nginx serving static assets), decent PRAGMAs for sqlite3 and caching for generic content should improve the RPS one or two orders of magnitude.
Studying CS to be a web dev is like studying Physics to turn on the oven.
besides, some times you might want more than one schema for a db table (e.g. one with minimal information, for menus, dropdowns etc, and one with full information for your full CRUD operations)
You can definitely get more than that in Django. We could much more that back 15 years ago on very basic hardware, rendering templates and doing semi complex UIs.
- Testing involving models is slow. It runs all your migration. Python's own "Unittest" is fast for functions not involving models.
- The suggested pattern for business logic is "active record", which is putting functions about a model alongside the model itself. Good for clean case. Doesn't really work when your operation involve multiple models.
- But if you put function about a model inside a model, the migration doesn't serialize the function into migration history (I could be wrong)
- "Data" migration is handy when you want enum-like data in database available to all teammates. e.g. list of currencies
- It is very tempting for new team member just to create an django app, with its own view and model, because it feels like starting a fresh without needing to care about existing business context. On the other hand, designing conceptual boundary between apps is very important. Because starting app is easy, and often (not always) wrong.
- The squash migration utils are limited because I guess of relatively low usage. It squashes linear migration history (with manual curation of starting and ending migration node I think? Not sure about latest state). I wrote a util to squash whole migration history by finding linear segments and squash those segment of history one by one. Didn't release it, but wrote some notes https://gist.github.com/kmcheung12/08b4c234b38c23efd43550da9...