Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
Some more things about Django I've been enjoying (jvns.ca)
174 points by surprisetalk 9 days ago | hide | past | favorite | 126 comments
 help



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.


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.


You don't need django-channels anymore for SSE: https://docs.djangoproject.com/en/6.0/ref/request-response/#...

For lots of applications, Mercure is a great replacement for websockets and it works wonderfully with django

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.

Well references in python are mutable by default, so you essentially combine an asynchronous model with mutable shared state. Combine that model with unknown caller exceptions in any subfunction and you're in for a world of hurt.

I'm not sure i see it as a hack, but i do feel unduly burdened as a developer by async in python. I feel like there's a lot that i have to think about and i'd appreciate more help from the language.

I don't want to be overly critical, there's languages that people complain about and then there are languages that no one uses... If i compare it to js/ts, some stuff is genuinely better in Python - e.g. if you missed an await. While both ecosystems have lint tools available for this, but the behaviour is just friendlier in python.

Structured concurrency is better in python, but even if TaskGroup is nicer to use than AbortController, it still has its own foibles which means i'll usually advocate for AnyIO.

But the js/ts ecosystem just generally benefits from being async from the get-go where python you're just a time.sleep() away from a bug that will slip through dev envs and ci pipelines undetected and only rear its head under load in prod.

If i have a tip to share its the debug flag for asyncio run:

    asyncio.run(f(), debug=True)  # find some more issues before prod

Or just env PYTHONASYNCIODEBUG=1.

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.

I think the mental model is the right thing to focus on. I'm not denying there are cases where async at the edge of an app can make sense but there's no free lunch and i that doesn't appear to be well understood.

Maybe i will write a blog post, if nothing else than to get my own thoughts in order.


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.


I don't like it, but I don't like it because it is impossible to make it performant.

What if I told you there was one of the greatest engineering achievements of mankind ready to make sure your threads run whenever they can, but also fairly and carefully balancing context switching cost and throughput, and instead you use python async and get 1/10th the performance, weird impossible to avoid pauses an order of magnitude longer than the threaded version's median response time, and just constantly have to fix bugs about callbacks firing on canceled blah blah blah.


Those are all fair points! They apply more or less equally to, say, nodejs or Perl's AnyEvent.

I'm totally onboard with "use the OS thread scheduler when you can"; it's a brilliant piece of technology. And if you are in a situation where

> pauses [are] an order of magnitude longer than the threaded version's median response time

...then fully cooperative, single-thread async programming isn't for you!

But async programming was made for the inverse use-case of that: backend functions which spend the vast majority of their time waiting for I/O--slow databases, HTTP requests, and whatnot--and which needed to serve very high concurrency demands on very cheap servers. If, say, your web route handler wants to probe or parallel-fetch from 100 unique-per-request HTTP endpoints, and you expect to handle 100 web requests concurrency, using threads is going to cost a lot of resources (the portion stack space that's allocated eagerly, start/stop time, and so on). For some folks, those resources might be readily available, in which case threads may still be the way to go! But for other folks (tiny servers, or more orders of magnitude than 100/100--use cases like proxies or CDNs) cooperative async might be a better way to go. Similarly, if your concurrent code is sharing a lot of data between routines but is primarily waiting on I/O, single-thread async concurrency might be a less race-condition-bug-prone model to use.

I'm generally with you that it's a tool that's prioritized above threads in a lot of cases when it shouldn't be. But it is still definitely valuable for a lot of common challenges.

A lot of the above is rehashing the comments I made here: https://news.ycombinator.com/item?id=39247876


You clearly know what you are talking about and I agree with what you are saying 100%, if you are spending almost all your time waiting around, you should address that. Generally the OS has a better solution there as well, in fact linux has several. Python also has several, and solutions like Stackless or Twisted are just a joy compared to the cancer of python async.

Even if you buy into callback-style async style programming like node or python async, Python async is not just bad because the general idea of it is bad, it is also bad-at-what-it-is-trying-to-be. It's a bad, hack, incompetent implementation of async programming, which in this style (cooperative callback style) is a bad architecture. It's unforgivably bad. Look in the stdlib and read the code for it, it's full of gotchas and confusion and patch after patch after patch putting in exceptions (like the "we want to do W but if this task is x then actually don't do this but if it's y do this other thing and if it's z then do a third thing kind of exception) and checks and double checks every 3rd line because the design itself is broken. Whole features (like un-cancel task) were put in because one influential person demanded it because the library they wrote can't work without it, even though it breaks a fundamental invariant that should never be violated.

Fundamentally, when you choose a tool or architecture, it should not be the thing you spend 50% of your time debugging. Every Python async project I've ever worked on required me to spend half my time tracing around in the guts of the async library itself to figure out what weird edge case we were running into that made something completely broken. This is like the hallmark of bad software.


I wish Stackless/Twisted had succeeded, I really do, but they unfortunately lost to what I agree is a worse implementation. Like you, I've spent probably thousands of hours in my career debugging bizarre Python async internal machinery and writing tools that shave off some of the rough edges, and you're right that it's bad out there.

I think the main things that cripple the Python stdlib/asyncio design and the ecosystem that arose around it are:

1. Thread:loop locality. The design of event loop-per-thread is ... admirably flexible, but doomed to smack into the reality that many folks aren't in control of what threads their code is invoked on, and what may or may not have kept a reference to an event loop, which brings me to...

2. The fact that event loop objects can be replaced/started/stopped. That's a terribly confusing API with lots of inherently global state at the edges (open files/pipes used for self-polling, signal handlers), which trips up all sorts of code--including the stdlib itself at times.

A better API design would have, I think, had loops be utility objects without a notion of "running" or not, ideally with the ability to be addressed across threads (note that I'm not asking for coroutines to be auto-scheduled onto multiple threads, just for "asyncio.run"-style entry points and loop objects to work on first access in any thread context). That'd save on the wacky complexity needed for libraries like asgiref (which does a very good job at something that should not be this hard) and reduce "you can't run this coroutine because it's loop is stopped" and "you can't run this coroutine because it's owned by another loop"-type bugs.

Swapping in other loops like uvloop should be a stdlib call that has to happen before any async functionality has run anywhere in the program, and doing it after that should be an error. Or just ... don't support other loops. Realistically there are only two, and the internal "loop" abstraction is so low-level that making it swappable can only speed up certain other kinds of code; a lot of perf is left on the table behind those ugly and expensive "introspect the loop/task/passed-in-awaitable types and states on every single asyncio function in the stdlib" checks you correctly complain about.

The ability to lapse into/out of "blocks" of async code in a largely-synchronous codebase (or between tests) could have been provided with a few simple utilities that asserted things like "crash if any timers/sockets/etc. are still registered with the event loop" or "give me a weakref-tracked list of all live Future and Task handles that the loop has ever manipulated". A ten-liner I use in most async Python projects I touch is basically "write a wrapper class for concurrent.Future and asyncio.Future which stores a weakref from the Future to any async Task that was active when anything wrote to the Future", which makes "is there anything async-condvar shaped that hasn't been GC'd yet?"-type checks trivial.

3. Cancellation is a tarpit, as you pointed out. I'm a big fan of the ability to observe cancellations (the Nodejs model and the Rust model both leave a lot to be desired here), and it's useful to be able to intercept and take action in response to them within reason. However, modelling them as a regular exception runs afoul of too much "except:"-using code, and the idea of "sheild", uncanceling, and accidentally swallowing cancellations is a poorly designed API. Ideally, cancellation handlers would be used in the same way that signal handlers are used--constant-time, minimally-side-effectful code with a guaranteed jump back to resume cancellation propagation when they're done. I understand what making cancels preventable/handleable enables, and it's indeed valuable ... but it's not worth it compared to the pervasive bugs that emerge around the choice of making them a regular exception. Like, sure, it is technically the programmers' fault for misusing CancelledError, but past a point, it's easier to fix the tool than the users.

4. Future objects (especially concurrent.Future's ability to serve as a sync/async "bridge") are treated as low-level tools users should rarely reach for directly. The stdlib them wraps them with some pretty questionably behaving and poorly performing code. I wish the original APIs around asyncio had made the creation and use of Futures the main language things speak, rather than endless utility methods that end up "turducken wrapping" Futures/tasks internally. Relatedly, it's frankly insane the amount of hoops you have to jump through to customize exception propagation between a pair of Future-ish things when chaining them (if A fails with X, sometimes I want B to fail with the same thing, sometimes I don't. If X is CancelledError, the best-practices tools in the stdlib require creating no fewer than six nested/intertwined futures to express "if A fails with a real error, B should fail; if A is cancelled, B should return/do nothing/whatever").

5. I wish the asyncio stdlib had given us more tools to bridge thread/async computation early on (run_coroutine_threadsafe and and run_in_executor are simultaneously too high- and too low-level). Honestly, a ton of the asyncio stdlib feels like it was written as if some of the held-for-constant-time synchronization locks involved in e.g. "call_soon_threadsafe" evoked more fear in the maintainers than the 8 stack frames of chunky pure-python code involved in doing normal thread-local operations. Unconditionally taking out some usually-uncontended coordination locks whenever communicating between different sync stacks/async coroutines would not break the bank--hell, this is what they did for every data object in the program when they removed the GIL. It's fine. Again, modelling an "event loop" as something fixed and cross-thread without a running/stopped state would make this moot, but here we are.

6. There are a lot of preforking systems in Python, from multiprocessing to gunicorn to mod_python. Async code should have been fork-safe--at least as fork-safe as Python's threads, which are hellishly unsafe to fork in the presence of on paper, but which the GIL and interpreter behavior made fork-safe in practice in a huge number of situations (the evidence of which is that thousands of users didn't even know they were in those situations and things kept working fine).

On the other hand, some things that a lot of people rail about in Python async are, I believe, good ideas that should be emulated:

1. Python's got a giant legacy of synchronous code, and (at least when asyncio was created), didn't have the ability to effectively spread work across threads. Given that, the refusal to make async stuff automatically schedule onto multiple threads when a coroutine blocks was a good idea. Loop blocking was just always going to be more common and frustrating in Python async than in JavaScript (with no synchronous legacy), Java (where everyone's used to their code being plopped onto random thread pools anyway), or Rust (with compiler support for expressing that a chunk of code can be moved between threads or not).

2. The taxonomy of awaitables from regular "async def" (fast, no permanent tracked stack) to Futures to Tasks (promises) is a good design. A good async system needs something in all those niches. I just wish the situations where one was automatically promoted to another were handled better.

3. PYTHONASYNCIODEBUG is very useful.

4. Tricky as they are to work with, async generators are very powerful.

5. The decision to implement asyncio itself on top of the original interpreter's generator machinery was a good one (caveat the flaw in the original generator machinery that StopIteration is a regular exception that users can subclass and raise--just like CancelledError, that was not a good decision).

Things like Trio can definitely help shave off some of the rough edges of Python's async (and the stdlib has adopted structured concurrency too nowadays), but even using them inevitably causes some problems since it's too easy to mix and match asyncio and Trio.

At the end of the day, I think Python did pretty well here: it's a big synchronous language with a million deployment targets adopting an entirely new control flow primitive without changing any invariants. The fact that they did as well as they did without industry backing is due in large part both to careful design and the willingness to compromise/ship and improve on incomplete solutions by folks like Victor Stinner. They deserve props. There's still a lot that could be done better.


I think you are right on every point, which is why we should agree that using python async is a bad decision and anyone starting a new project should not use it. Threads might not be what all the cool LLMs are doing, but I have never spent half of all my work time for a month debugging a threading bug, much less half of my day every day forever like with python async. Anyone who says they don't spend half their time debugging python async internals either doesn't use it or doesn't realize they are spending 500x too much on hosting.

I’m not sure I’d go as far as “always a bad decision”, but Python async is definitely something that shouldn’t be adopted without carefully considering alternatives and being aware of the requisite expertise needed. It’s an advanced and sharp-edged tool, and I wish people forced to use it (e.g. due to the use of libraries that only expose an async API) had better tools to make its limited adoption in an otherwise-sync codebase easier.

Especially in Python, threads and blocking code should definitely be encouraged as a first resort.


I would love a blog post on this, in case you ever considered writing one!

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.

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.


> I still use Django for models and migration

There are dozens of us! It's a great db management toolkit. I've used it to much success many times for things like managing migrations from mysql to postgres and php to python.

My opinions:

- Django apps are an anitipattern for large internal / single purpose products due to migration overhead as FKs cross application boundaries. I will die on this hill. No team is ever disciplined enough to keep apps as boundaries for relationships and constraints. Strong contrast to the rails crowd that doesn't rely on referential integrity in the db by default where this isn't "a thing".

- Goose[0] migrations in Go are really great but you have to let go of the dsl and the idea that your ORM drives your migrations, as you explicitly called out. Laravel[1] is on par with django IMO and a delight to use when in php-land. I've not tried to repurpose it like I have with Django and sqlalchemy.

- sqlalchemy and alembic is a great toolchain outside of django that get's a bit of a bad wrap / confusion from django devs. it gives you that same ability to drive the changes from the classes / structs without having to drag around all of django. It having more verbose

[0] https://github.com/pressly/goose

[1] https://laravel.com/docs/13.x/migrations


  - Django apps are an anitipattern for large internal / single purpose products due to migration overhead as FKs cross application boundaries
Totally agree with this. Now we have thousands of unsquashable migrations that require a ton of work to fix.

Can you share more on this? Is it better to keep all models in one app?

I found parts of the Django RAPID architecture [0] to speak to me. It specifically advocates for just one app. Here's a part of the justification.

> Slicing up your project into apps is something that must be done early, often at the very start of development. This is a simple result of the fact that you need somewhere to put the code you're writing as you go along. In the early days and weeks of work on a new codebase, manage.py startapp gets used a lot, as the high-level structure of the project starts to take shape.

> The issue here is that at this early stage, you often don't really know enough about what the project's final form will look like to correctly draw the boundaries around the apps. Functionality that feels separate at first often becomes deeply entangled, and features that sound similar end up sharing little. Over time, the key concepts and models in your system become clear, and if these are colocated with unrelated or irrelevant code, the waters of the project become muddy and maintenance becomes difficult - before the project is even in production!

> While it is technically possible to migrate models between apps, Django doesn't make it easy, particularly if the models in question have foreign key or many-to-many relationships with other models. And if you think about it, it's not really a technical limitation of the sort that could easily be solved with a PR into Django. It's a conceptual problem: if migrations are a historical record of changes to models, and migrations are encapsulated in the same app as those models, then moving a model to a different app necessarily creates a historical coupling between the two apps that shouldn't really exist.

[0] https://www.django-rapid-architecture.org/structure/


Well whether it's better or worse depends on your specific situation.

What I'm facing right now is a 10 year monolith modularized with applications. Like a sibling comment already discussed these applications are not reusable application so almost all advantages of having that are moot.

Now, the consequence of having this structure is that we now have complex dependencies between the migrations of these apps i.e. cyclic dependencies, dependecy constrains that are underspecified. Thousands of migrations that now take a significant amount of CI time. We would like to squash them but it requires a lot of review and manual work and if we keep using multiple apps we are going to ave the same problem in the future.

The only meaningfull gain we had with multiple apps is that we have less migration conflicts when people create migrations concurrently.

Is that advantage worth the pain? I don't think so.


We have a distributed monolith that's 20-30 years old, that has various systems written in various different languages interacting with multiple interconnected databases. We're working on migrating all of it to python, using django models.

The key thing in a system like this, that was kind of stumbled upon by previous devs who started the python migrations, is the database should be treated as its own independent unit and managed separately from the individual applications that connect to it. For example, we've defined the django models in an independent library that the individual applications import. It also contains the configuration for routing queries to the different databases - only takes a couple of lines of boilerplate in the application's settings file.

We haven't yet switched to using django migrations and are still doing updates manually, but after some work the past couple of years it would now be an option. They would go in the common library, avoiding the pain points you've listed, and could be run from any application that needed it - django tracks the migrations that were run inside the database so they'd all see the same thing and act the same way.

For us, it has absolutely been a major benefit.


Not the author, but I no not understand the desire to use multiple apps. I am never going to want to carve one out as a separate module I re-use in another product. It is all interconnected, why add arbitrary boundaries separating them? Put everything into a "core" app and move on with life. Maybe if you are an enormous organization working on Instagram where you have rigid responsibilities per team.

So you’d prefer one file with all models? Do you break out into apps for business logic? Keep models in one app/file. Then domain driven design elsewhere?

You don't have to put all your modules into a single file. You can break them into multiple files and the import them into a model file just so that Django loads it from the expected location.

Instead of apps you can just split your components inside a single app using regular python modules.


As things get more complicated, I will separate out by some kind of concept. Which can be something as simple as:

  /coreapp
    /forms_foo.py
    /forms_bar.py
    /models_bar.py
    /models_foo.py
    /views_bar.py
    /views_foo.py
You can do a more sophisticated module layout, but essentially something as straightforward as the above, all under a single "core" application. Prevents Django from fighting you when you want to work across the arbitrary "app" boundary.

You can also have a /models/ package with foo.py, bar.py under it. It is easier to maintain.

As an additional bonus, you can add an __init__.py that imports all models if you want to be able to do a `from .models import FooModel`.


In go, for my personal projects only I don't think it would scale, I made my own migration tool since I am not using an ORM. It works pretty well for my use case and is quite simple.

https://github.com/oxodao/micromigrations

EDIT: Also Doctrine is really great in the Symfony world, I like it much more than django-orm


You may want to elaborate that Django apps are mini apps inside of the Django app (which is called "project"). Those unfamiliar to Django might think of a Django project as an app.

I've never done it, but now that you say it, it makes a lot of sense. Using Django just to manage the database and do migrations, even behind other language stacks.

You also get the Django admin interface for free.

I once tried using SqlAlchemy but I couldn't help asking myself why it felt so complicated compared to Django.


Running your API in Rust/Go/etc and then doing the admin and migrations via Django is a hidden superpower. I’ve said this on HN in the past few years.

People who hate ORMs have just never tried Django :D

Or Laravel's Eloquent

> 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.


Those numbers sound... Single-threaded. Like they're using the development runserver instead of uwsgi or gunicorn.

A $10/month VM might only have a single thread anyway. Some providers like lightsail are really slow too.

You can run multiple OS threads (gunicorn workers) on one VM thread so the workers don’t have to wait for each others request to finish though, right?

And if you pay $10/month for a single threaded machine, you’re overpaying by a lot.


True, but in this case with SQLite, there's unlikely to be much of a difference because there isn't the spare time available when waiting for a separate database server. I don't know what providers are good for a $10/month instance these days.

How are you spending any non-negligible time reading from SQLite at 12 requests per second though? That would mean you’re spending something like 50 ms per request on reading from SQLite.

Most of the time you're hitting SQLite, you're just reading from it, and so it doesn't hold anything up.

For a cheap VM, I'd still be expecting in the range of 500-1000 connections a second. Green threads are cheap, even with a single processor.

For a half-decent VM, I'd be expecting multi-thousand.

Single figures a second, is choked to a single connection at a time.


The $5/month VM I rent has 2 CPUs. And you want to overcommit because of IO blocking.

Anyway, more than a decade ago my django servers could handle close to 10 requests per second on each thread.


Development runserver has been multithreaded by default for over a decade. I think you need to go back to major version 1 to see it single-threaded by default. Maybe affected by the GIL though.

Pretty sure it still defaults to no threading [0], and just uses the plain WSGIServer instead of the threaded mixin.

[0] https://github.com/django/django/blob/main/django/core/serve...


Also, they may be serving static files through Django. Otherwise, it's really, really low.

Yeah... in the place I worked, for a while, they didn't have a package index for Python packages (similar to PyPI), so, I wanted to write one. At the time I had a love-hate relationship with Ada, so, after trying to do something with Python and thinking how much resources I would have to ask for and whether I'll need load balancing etc... I checked what Ada's (somewhat unfortunately named AWS...) would need to be used as that kind of index. Suffices to say that I wouldn't need any of the "reverse proxy" servers, no caching, no load-balancers... It would be fast enough to service a company with thousands of employees on a very modest h/w setup.

Using Django is like trying to walk on a highway, with a crutch. Even though it has some convenience features, it's just so impossibly slow you would have to invest a lot of engineering time and resources to mitigate that slowness.


If Django could run reasonable numbers of requests on early-200s0 servers... what has gone wrong since?

I imagine even a $10 VPS should be a much more powerful machine than a high-end server from 2005 or so.


It's because -c 1 only makes 1 request at a time, which is not representative of a real website load. This was also brought up on lobsters a few days ago https://lobste.rs/c/lctz1y

1000ms / 12 = 83ms per request which sounds normal

That’s a number I would been disappointed with 25 years ago, running Perl CGI on a 700MHz Pentium III.

Wow. With a web service in Go or any similar language that would measure in the thousands, at least.

Using Django back in the 2000s it was easy to get in the hundreds of requests per second unless you were doing truly pathological things in templates or gnarly database queries, and we have far more cores now. Usually something that low either meant things like not caching database connections or complex logic in templates.

Copying my comment from lobste.rs 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...


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.

I found Django a bit hard to get on with vs. other frameworks and I've used Rails, .NET MVC and Express (and friends). I just found more friction trying to achieve X for any given X for some reason. Not sure why.

That's the whole beauty of frameworks and languages. Some of them just "click" with you. At the end of the day we can all build what we need.

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.

At this point Django also qualifies as "for decades" - most (maybe all?) of what's in this post applied back when it was new. The basic design has been stable for a very long time.

To be fair, for grey beards, python predates .net and I was using Django in 2008, which predates .net mvc.

.NET also comes with "telemetry" built-in that sends your data to Microsoft without asking for your consent: https://github.com/dotnet/sdk/issues/6145

I wouldn't want to run software from someone who thinks taking such liberties with my machine and my data is OK.

> so pleasant you don’t even think about it

I'm sure Microsoft would prefer that people don't think about Microsoft is doing to them. But when do you think about it, you might reconsider using Microsoft software.


agreed 100%, the most under-appreciated programming ecosystem of them all.

It's also the only one that has support for making truly native apps for all four major platforms: Windows, Linux, MacOS and iOS. If you're willing to put in the work and make separate, native UIs for each, C# is the only way to go.

It's a rich enough language that it binds reasonably well to both Java (on Android) and Objective C / Swift (on iOs / MacOS), and having the core and UI layers of your app share the same language enables much higher fidelity bindings than what you can get out of E.G. UniFFI. And as a bonus, the same core is also re-usable on the server, and even in Web Assembly, if that's the way you want to go.

There used to be some issues around Storyboards and such on Apple platforms, but if you go nibless (which you should anyway in the age of AI), that's no longer a problem.


How is this different from any other language? Python can also make apps for all those platforms, either with native UIs or cross-platform UIs. There are many options.

Are you talking about .NET's AOT compilation and that's why you say "truly native"?


> under-appreciated programming ecosystem

idk, for a long time nswag was the openapi framework and it was atrocious and had many core issues that were unresolved for almost a decade. it came to where i implemented an api with something basic (i think it was multipart) and one consumer of my api asked me to change the api because nswag couldn't handle it. i told them to pr a fix to nswag (they didn't).


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?


> Is Jinja2 a practical alternative or there's friction to using it?

jinja2 is drop in by changing the template backend. You can actually run both at the same time (just can't mix them, ofc).

https://docs.djangoproject.com/en/6.0/topics/templates/


It's a practical alternative, but definitely not a drop-in replacement! I've been working on migrating a django project to jinja2 lately, see the diff: https://framagit.org/la-chariotte/la-chariotte/-/merge_reque...

A few notables differences:

- many controls are now functions/variables (that's good!), and some change name, for example `{% csrf_token %}` -> `{{ csrf_input }}`

- calling methods without parenthesis does not work (that's good!), for example `query.all` -> `query.all()`

- in tests` response.context` is replaced by `response.context_data`, which is not set when calling `render` directly (need to return a proper `TemplateResponse`)

- template folders need to be renamed from `templates` to `jinja2`; there may be a way to change this behavior, but i did not find it, and this change is written so small in the docs i lost an entire day over it


Yea, I was unclear. That's what I meant by "you can't mix them"... just meant using jinja2 is drop it for the backend.

I believe you just set the dirs property in the backend config to look in whatever directory / directories you want it just defaults to jinja2 as you discovered.


From what i understand `DIRS` is only for the top-level app. I couldn't find a way to apply it to inner apps (eg. `auth`, `order`, `etc`). I'd be happy if you know the way i don't have to rename all the template folders!

Thanks! Any noticeable downsides?

For now, no. But to be fair, it hasn't reached prod yet. So far, only great upsides: developer experience (in templates) is considerably better, and djlint/j2lint make it even better (one for HTML validation through the templating, the other for jinja syntax).

Also, it should be considerably faster to render, but i haven't properly measured yet. I just know on lower hardware, the django template "static" homepage (rendered, but no conditionals and no DB calls) takes average 23ms to serve with variations 14-53ms, while a real static page takes 3ms average with 1-6ms (that's all on localhost so no network instability is measured). Can't wait to have the jinja stats!


The migration is fairly simple and well documented.

The problem may be the lack of jinja support for 3rd party django apps (mostly legacy). So make sure they support it first.


> 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.


> 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

I get the motivation, but is disallowing brackets and proper variables really the right way to do this? These make code clearer to read and reduce duplication more than they introduce complexity so this is going too far in my opinion and feels arbitrary.


Oh, believe me, I’ve been on both sides there. My observation was that over time the burden of maintenance and on-boarding new developers pushed me from wanting more flexibility to saying that the restriction was more valuable than I thought.

> 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.


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.


Appart from the fact that you find that Python wastes too much memory, what is your point ? I think that any person choosing Django knows that Python by nature will not be the most efficient language. Apart from that Django is battle-tested and can help bring a stable "product" quite quickly.

if "2-3 requests per second" per author is what you wanna do on $10 server go ahead". My server does 100 RPS on $16 instance

neither you are saving any time, nor money.

>part from that Django is battle-tested and can help bring a stable "product" quite quickly.

this is a myth, you'll not save anytime. Only way you can save time is if you've experience in this but same is true if you write your app from scratch in Go from your learned patterns.


> if "2-3 requests per second" per author is what you wanna do on $10 server go ahead

That's not a Django limit and there's something going on with the authors setup. 100 RPS on a $16 instance would be easily doable with Django too.

> neither you are saving any time, nor money.

How do you know? I'm pretty sure I can set up the same webapp in Django much faster than in go, so I'm saving both.

> this is a myth, you'll not save anytime. Only way you can save time is if you've experience in this but same is true if you write your app from scratch in Go from your learned patterns.

Why do you think all the built in stuff in Django does not save time? Any argument for that?


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...


You can run with --no-migrations (https://pytest-django.readthedocs.io/en/latest/database.html...) and default the test DB to be in memory (works at least for PG and SQLite) to make it quite a bit faster.

> 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.


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.

Switch to gunicorn.


Better yet, put gunicorn behind nginx, make sure all static assets are served by nginx, add appropriate Cache Control headers. Furthermore understand and use Django’s page caching.

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.

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 think you can do that with querysets, Q, and F. The kwargs are a limited convenience.

Or now that python has ~types, this is really an area where things could be improved. Filtering would just be lambda predicate with fields auto complete as seen in .NET, scala, etc

There may be many reason other than rejecting that suggestion leading to what it is know. Your statement somehow suggests that it was deliberately decided against what you propose. I don't think we know that.

I can't quite picture how operator overloading would look like, could you give an example?


> I can't quite picture how operator overloading would look like, could you give an example?

Instead of this:

self.filter(end__gt=self._midnight(today))

You could write a "Field" class that implements __getattr__ and __gt__ so you could do

self.filter(Field.end > self._midnight(today))

The "Field.end > self._midnight(today)" would evaluate to an object that would just store "my field name is end and my value needs to be larger than xyz".

filter() can then look into its argument list and construct the filter criteria from the passed Field objects instead of the key value pairs as it does now.


SQLalchemy does that. One advantage of the Django syntax is that it can be (pretty much) directly dropped into a query string on any admin page or DRF query and filter the results. E.g. the admin page for all the events after noon today:

  admin/event/?end__gt=2026-07-26T12:00:00
Being able to do ad-hoc queries using the same paradigm your app queries are written in, and then pass urls around with those queries included (e.g., quick one-off reports or answers to client questions) is so helpful.

This convenience sounds a little dangerous. Would this allow the user to specify e.g. joins or SQL procedure calls using the query parameter?

If you mean “would it allow arbitrary queries in the admin interface?”, then yes, it would. That’s why it’s the admin interface.

If you mean “would it allow arbitrary queries from normal view code”, then no it would not—not unless you string eval on the backend or something else dangerous.


if self._midnight(today) returns a datetime object, than:

   self.filter(end__gt=self._midnight(today))
will evaluate to:

   self.filter(end__gt=<some_datetime_object>)

While

    self.filter(Field.end > self._midnight(today))
will evaluate to:

   self.filter(<True/False>)

Not if you do the magic with getattr and comparison overrides. You actually need to do it on the metaclass because the Field as I wrote it isn't an instance but this works:

    from datetime import datetime

    class Filter():
        def __init__(self, name):
            self.name = name
        def __gt__(self, value):
            return {
                "field": self.name,
                "operator": ">",
                "value": value
            }

    class FieldMeta(type):
        def __getattr__(cls, name):
            return Filter(name)

    class Field(metaclass=FieldMeta):
        pass

    print(Field.end > datetime(2024, 1, 1))
This gives:

    {'field': 'end', 'operator': '>', 'value': datetime.datetime(2024, 1, 1, 0, 0)}
You can make python return arbitrary values for comparisons by overriding __gt__ (and lt, eq) on the first operand (which we control here since it is a Field class), it doesn't have to be a bool.

Edit:

You can even make a little adapter to use this with the current filter system if you really want to:

    from datetime import datetime

    class Filter():
        def __init__(self, name):
            self.name = name
        def __gt__(self, value):
            return {
                "field": self.name,
                "operator": "gt",
                "value": value
            }
        
        def __lt__(self, value):
            return {
                "field": self.name,
                "operator": "lt",
                "value": value
            }
        
        def __eq__(self, value):
            return {
                "field": self.name,
                "operator": "eq",
                "value": value
            }

    class FieldMeta(type):
        def __getattr__(cls, name):
            return Filter(name)

    class Field(metaclass=FieldMeta):
        pass

    def _(*args):
        kwargs = {}
        for arg in args:
            k = arg["field"] + "__" + arg["operator"]
            kwargs[k] = arg["value"]
        return kwargs

    def filter(**kwargs):
        for k, v in kwargs.items():
            print(f"{k} = {v}")

    filter(**_(Field.end > datetime(2024, 1, 1)))
This prints

end__gt = 2024-01-01 00:00:00


Do this:

  def __gt__(self, value):
      return Q(**{ f"{self.name}__gt": value })
and your original code should work as-is without the need for _()

  self.filter(Field.end > self._midnight(today))
https://docs.djangoproject.com/en/6.0/topics/db/queries/#com...

If you change an operation that is meant to return a Boolean to return anything else, you are insta fired.

I would have agreed with this, and then they did the `pathlib.Path` bit of cuteness with the `/` operator: https://github.com/python/cpython/blob/5afbb60e0283caaf34990...

And despite my misgivings, it’s really ergonomic.


If you divide a Path by another Path, you get a Path. If you compare two Paths, you get a Boolean. It is not really the same.

That's a well established pattern in Python, for instance with Numpy. That's the point. Operations in Python aren't "mean to return" anything in particular. Each class can define the operations as it wants. That's a powerful feature that allows creation of specialized expression languages, as used by other ORMs besides Django.

Hi, don't mind me, but

You don't need to return a boolean. You need to return an object that implements __bool__.

Get it ?

And even then, when you're dealing with objects that are special to expressions, you don't actually even need __bool__ that much


You mean like the numpy authors that let the comparison operators return arrays?

Also, apparently SQLAlchemy does exactly what I proposed so apparently they are erring in their ways too.

I honestly don’t find it that bad.


No it won't, because there's no requirement that the result of `>` be True or False. It can return an object that then can participate in further expressions that "keep track" of what operations are done to the fields.

How would you model string comparisons with LIKE?

For those you would have a method on the field object (e.g., `Table.field.like("whatever")`).

Yeah, but that's different from the operator. It's a different way of thinking about it. Maybe that's the reason they used dou le underscores for everything, to keep it consistent.

Not really. Both method calls and operators are consistent with how Python normally works. Outside of Django you never do `obj__op(value)` or `obj__meth(value)` to do the equivalent of `obj op value` or `obj.meth(value)`. It is Django that is inconsistent with how operations are done in Python.

You might want to look at Peewee's query filtering syntax.

https://docs.peewee-orm.com/en/latest/peewee/querying.html#f...


I have, yeah. That's kind of my point: other ORMs do this better than Django.

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.

> 12 requests per second

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.


Is Django still a solid choice in 2026 for new backend projects? Have loved the orm, admin, etc.


Web dev is just mind-numbingly boring.

Studying CS to be a web dev is like studying Physics to turn on the oven.


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)


> some times you might want more than one schema for a db table

Django has the concept of "proxy models" where you can define a trimmed down model for the same database table.


what if two models access disjoint sets of fields (like a Data.Admin and Data.User)

Two proxy models that extend the same base model.

Holy 2003 that website



Consider applying for YC's Fall 2026 batch! Applications are open till July 27.

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: